欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

Java中锁的实现与原理(一) 自己实现一个锁

程序员文章站 2024-03-21 09:42:40
...

Java中的锁机制

在学习Java的并发编程中, 锁机制是一个重点和难点, 在Java中并发常用到的锁相关的主要是synchronized关键字和java.util.concurrent.locks类, 两者的区别包括

  • synchronized是关键字, 依赖JVM实现锁机制, Lock是JDK中的一个接口, 其实现最典型的是ReentrantLock
  • synchronized的实现是编译期加入了管程的机制, ReentrantLock的实现是依赖底层的AbstractQueuedSynchronizer(AQS), 而AQS又使用到了Java中的CAS机制.
  • synchronized不需要手动释放, 在临界区的代码出现异常时, 也能够正确的进行自动解锁. Lock的实现类需要手动上锁和解锁, 为了保证代码出现异常时能够释放锁, 需要将代码段包在try-finally语句中
  • Lock实现类比synchonized更加灵活, 支持tryLock方法能够设置超时时间, 也可以控制被阻塞的线程是否能够相应中断, synchonized不行
  • Lock的实现类中读写锁可以提高读操作效率, 实现类也能够知道是否获得了锁
  • 在竞争不激烈时, 两者相差不大, 竞争激烈的情况下, Lock性能远优于synchonized
  • synchronized是可重入锁

说了怎么多, 不如动手写点代码, 从代码中了解并发编程中应该如何用锁, 以及一个锁大致是怎么实现.

一个并发操作的Demo

import java.util.LinkedList;
import java.util.List;

public class TestConcurrent {
    public static void main(String[] args) throws InterruptedException {
        Share share = new Share();
        List<Thread> tList = new LinkedList<>();
        for (int i = 0; i < 5; i++) {
            Thread t = new Thread(
              ()-> {
                  for (int j = 0; j < 10000; j++)
                    share.incr();
              }  
            );
            tList.add(t);
            t.start();
        }
        for (Thread t: tList) {
            t.join();
        }
        System.out.println("i in share is " + share.i);
    }
}

class Share {
    int i = 0;
    public void incr() {
        i++;
    }
}

// output: i in share is 39980

这样的结果显然是错的, 这是由于i++不是一个原子操作, 多个线程并发对i进行操作时, 交错进行, 一个线程从堆中拷贝了一个副本到虚拟机栈操作, 在完成操作前, 其他线程修改了对象的成员变量, 而该线程在操作完后把以旧值为基础的计算结果直接覆盖到主存中, 造成了加少了的现象.

用atomic实现并发控制(无阻塞)

首先想到的是把i++变成一个原子操作. Java中已经提供了这样的一系列类位于Java.util.concurrent.atomic包中. 于是我们可以把以上的代码修改为

/*
TestConcurrent类代码不变
*/
class Share {
    AtomicInteger i = new AtomicInteger();
    public void incr() {
        i.incrementAndGet();
    }
}

AtomicInteger类使用unsafe类中实现的基于CAS的操作保证了对变量操作的原子性.

要讲清楚Java中的CAS无锁机制, 又必须讲一讲JMM(JVM的内存模型), 感兴趣的参考文章末尾的简述CAS无锁机制

CAS机制实现的, 大体上就是一个自旋锁(忙等)的功能, 当竞争不激烈, 临界区小的时候, 上下文切换的开销大于忙等开销, 此时选择自旋锁更好, 但是当竞争激烈的时候, CAS机制很难通过, 会导致效率降低, 这时基于阻塞的锁更好.

用lock实现并发控制

class Share {
    int i;
    Lock lock = new ReentrantLock();
    public void incr() {
        lock.lock();
        try { // 当发生异常时, 能够释放锁, 否则会导致死锁.
            i++;
        }
        finally {
            lock.unlock();
        }
    }
}

lock的实现也不难, 主要就是注意要用try-finally语句把临界区给包起来.ReentrantLock是JDK实现的可重入锁类. 可重入是指获得锁的线程可以再次进入临界区(例如递归情形), 此时只增加可重入锁中的计数.

锁主要做的工作就是两个部分, 一是判断自己是否能够被某个线程获得, 二是当线程请求获得锁而得不到时, 将这个线程挂到自己(锁即是资源)的等待队列中. 所以锁内部还需要一个线程安全的队列, 用来记录挂在自己这里的线程.

自己写一个实现Lock接口的类

有了以上对锁的功能的分析, 我们自己可以做一个简单锁实现.

public class MyLock implements Lock {
    AtomicReference<Thread> owner = new AtomicReference<>(); // 线程拥有者
    // 等待锁的线程队列 选择blockingQueue是因为其是线程安全 
    // 选择linked是因为没有随机访问但是有频繁的增删
    BlockingQueue<Thread> waiter = new LinkedBlockingDeque<>(); // 阻塞线程
    @Override
    public void lock() {
        while (!owner.compareAndSet(null, Thread.currentThread())) { 
            // 抢不到锁的情况 -- 放入等待列表 -- 阻塞
            waiter.add(Thread.currentThread()); // 放入等待列表
            LockSupport.park(); // 静态方法 让当前线程自己阻塞自己
            waiter.remove(Thread.currentThread()); // 不会让队列不断增加, 造成内存泄露
            // 为什么remove是写在这里? 很有意思, 当线程被唤醒时, 是从这一行开始执行的, 所以它退出了阻塞队列, 这是并发编程中比较难理解的逻辑
        }
    }

    @Override
    public void unlock() {
        // 持有锁的线程能够成功
        if (owner.compareAndSet(Thread.currentThread(), null)) { // 为什么是if 因为不存在竞争
            // 唤醒其他等待线程
            for (Object object : waiter) {
                Thread next = (Thread) object;
                LockSupport.unpark(next);
            }
        }
        /*
        还有其他需要实现的方法都可以不用实现 用默认值, 暂时用不到
        */
    }

各种锁的实现, 大体都是按照上述流程实现的, 但是具体的细节有一些差异, 然后是锁的一些概念.

  • 公平锁:表示线程获取锁的顺序是按照加锁的顺序来分配的,及先来先得,先进先出的顺序。(唤醒阻塞队列头的线程)

  • 非公平锁:表示获取锁的抢占机制,是随机获取锁的,和公平锁不一样的就是先来的不一定能拿到锁(实现上唤醒所有被阻塞的线程)

  • 读写锁, 读写互斥, 写写互斥, 用readLockwriteLock两个锁实现更好的读效率

  • 锁降级: 锁降级指的是写锁降级成为读锁。如果当前线程拥有写锁,然后将其释放,最后再获取读锁,这种分段完成的过程不能称之为锁降级。锁降级是指把持住(当前拥有的)写锁,再获取到读锁,随后释放(先前拥有的)写锁的过程。

    虽然我们手写了一个Lock, 但是这个类还有很多问题, 其中很明显的bug就是LinkedBlockingDeque自身就是用ReentrantLock实现的, 虽然我们这里没有用到其真正的阻塞功能, 但是锁实现里面用到了已经实现的锁怎么也有点说不过去的. 因此下一步我们想要一探, 一个真正的锁实现类ReentrantLock是怎么解决线程安全的阻塞队列这个问题的, 这里就涉及到一个很关键的抽象类AQS.

简述CAS无锁机制

讲CAS之前必须提到的是JVM的内存模型

JVM的内存结构和内存模型说的不是一个东西, 内存结构包括运行时数据区, 方法区, 堆, 虚拟机栈那些内容, 而内存模型和并发紧密相连, 主要是主内存, 工作内存等之间的关系, 以及Java线程的通信实现机制

Java中锁的实现与原理(一) 自己实现一个锁
上图描述了JVM如何控制线程和内存打交道的, 线程只能从主内存中先拷贝一份数据到自己的工作内存, 然后写自己的工作内存, 线程之间的通信也只能通过主内存而不能直接访问别的线程的工作内存.

CAS做的事情就是在线程从主内存拉了一个副本过来, 完成计算, 在写回的时候, 先比较下拉下来的副本原始值和主内存中还是否一样, 如果一样则通过一个原子操作把数据写过去, 如果不一样说明自己的副本已经过时了, 在一个while循环中重新从拉副本开始执行.

参考资料:
浅谈偏向锁、轻量级锁、重量级锁
synchronized 是可重入锁吗?为什么?
java的读写锁中锁降级的问题
CAS无锁机制必须了解的JVM内存模型
面试必备之深入理解自旋锁
Java doc Class ReentrantLock

相关标签: Java