【Java】多线程 - Java 锁的演化

Posted by 西维蜀黍 on 2019-02-28, Last Modified on 2021-09-21

锁存在的问题

Java 在 JDK1.5 之前都是靠 synchronized 关键字保证同步的,这种通过使用一致的锁定协议来协调对共享状态的访问,可以确保无论哪个线程持有共享变量的锁,都采用独占的方式来访问这些变量。独占锁(exclusive locking)其实就是一种悲观锁(pessimistic locking),所以说 synchronized 是一种悲观锁。

synchronized的缺陷

  • 在多线程竞争下,加锁、释放锁会导致比较多的上下文切换和调度延时,引起性能问题。
  • 一个线程持有锁会导致其它所有需要此锁的线程挂起。
  • 如果一个优先级高的线程等待一个优先级低的线程释放锁会导致优先级倒置问题(priority inversion problem),引起性能风险。

而另一个更加有效的锁就是乐观锁(optimistic locking)。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。

与锁相比,volatile 变量是一个更轻量级的同步机制,因为在使用这些变量时不会发生上下文切换和线程调度等操作,但是 volatile 不能解决原子性问题,因此当一个变量依赖旧值时就不能使用 volatile 变量。因此对于同步最终还是要回到锁机制上来。

锁的完善

如果使用 synchronized ,已经获取了锁的线程由于要等待I/O或者其他原因(比如调用sleep方法)被阻塞了,但是又没有释放锁,其他线程便只能干巴巴地等待。我们希望有一种机制,可以不让等待的线程一直无期限地等待下去(比如只等待一定的时间或者能够响应中断)。

或者,当在 synchronized 方法或代码块中读写文件时,任何的读操作或写操作两两操作会发生互斥现象。但是,为了提高效率,我们可能只希望写操作和写操作之间互斥现象,但是读操作和读操作是可以同时进行的(即不发生互斥)。

java.util.concurrent.locks 包中的 ReentrantLock 类和 ReentrantReadWriteLock 类正分别满足了上面的需求。

java.util.concurrent.lock 中的 Lock 框架是锁定的一个抽象,它允许把锁定的实现作为 Java 类,而不是作为语言的特性来实现。这就为 Lock 的多种实现留下了空间,各种实现可能有不同的调度算法、性能特性或者锁定语义。 ReentrantLock类实现了 Lock ,它拥有与 synchronized 相同的并发性和内存语义,但是添加了类似轮询锁、定时锁等候和可中断锁等候的一些特性。此外,它还提供了在激烈争用情况下更佳的性能。

java.util.concurrent.locks 包下常用的类

下面我们就来探讨一下 java.util.concurrent.locks 包中常用的类和接口。

Lock 接口

首先要说明的就是 Lock。Lock 是一个接口。

一般来说,使用Lock 接口(对应的实现类)必须在try{}catch{}块中进行,并且将释放锁的操作放在finally块中进行,以保证锁一定被被释放,防止死锁的发生。

通常使用 Lock 接口(对应的实现类)来进行同步的话,是以下面这种形式去使用的:

Lock lock = ...;
lock.lock();
try{
    //处理任务
}catch(Exception ex){
     
}finally{
    lock.unlock();   //释放锁
}

ReentrantLock 类

ReentrantLock,意思是**“可重入锁”(reentrancy locking)**。ReentrantLock 类是唯一实现了 Lock 接口的类,并且 ReentrantLock 提供了更多的方法。

一个例子:

public class Test {
    private ArrayList<Integer> arrayList = new ArrayList<Integer>();
    public static void main(String[] args)  {
        final Test test = new Test();
         
        new Thread(){
            public void run() {
                test.insert(Thread.currentThread());
            };
        }.start();
         
        new Thread(){
            public void run() {
                test.insert(Thread.currentThread());
            };
        }.start();
    }  
     
    public void insert(Thread thread) {
        Lock lock = new ReentrantLock();    //注意这个地方
        lock.lock();
        try {
            System.out.println(thread.getName()+"得到了锁");
            for(int i=0;i<5;i++) {
                arrayList.add(i);
            }
        } catch (Exception e) {
            // TODO: handle exception
        }finally {
            System.out.println(thread.getName()+"释放了锁");
            lock.unlock();
        }
    }
}

ReadWriteLock接口

ReadWriteLock也是一个接口,在它里面只定义了两个方法:

public interface ReadWriteLock {
    Lock readLock();
    Lock writeLock();
}

一个方法用来获取读锁,另一个用来获取写锁。

也就是说将文件的读写操作分开,分成2个锁来分配给线程,从而使得多个线程可以同时进行读操作。ReentrantReadWriteLock 类实现了 ReadWriteLock 接口。

ReentrantReadWriteLock 类

ReentrantReadWriteLock 类里面提供了很多丰富的方法,不过最主要的有两个方法:readLock()writeLock() 用来获取读锁和写锁。

假如有多个线程要同时进行读操作的话:

public class Test {
    private ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
     
    public static void main(String[] args)  {
        final Test test = new Test();
         
        new Thread(){
            public void run() {
                test.get(Thread.currentThread());
            };
        }.start();
         
        new Thread(){
            public void run() {
                test.get(Thread.currentThread());
            };
        }.start();
         
    }  
     
    public void get(Thread thread) {
        rwl.readLock().lock();
        try {
            long start = System.currentTimeMillis();
             
            while(System.currentTimeMillis() - start <= 1) {
                System.out.println(thread.getName()+"正在进行读操作");
            }
            System.out.println(thread.getName()+"读操作完毕");
        } finally {
            rwl.readLock().unlock();
        }
    }
}

执行结果

Thread-0正在进行读操作
Thread-0正在进行读操作
Thread-1正在进行读操作
Thread-0正在进行读操作
Thread-1正在进行读操作
Thread-0正在进行读操作
...

分析

说明 thread1 和 thread2 在同时进行读操作,这样就大大提升了读操作的效率。

需要注意的是,如果有一个线程已经占用了读锁,则此时其他线程如果要申请写锁,则申请写锁的线程会一直等待直到读锁被释放。

如果有一个线程已经占用了写锁,则此时其他线程如果申请写锁或者读锁,则申请的线程需要一直等待直到写锁被释放。

Lock 接口和 synchronized 关键字的选择

总结来说,Lock 接口和 synchronized 关键字有以下几点不同:

  1. Lock 是一个接口,而 synchronized 是 Java 中的关键字,synchronized是内置的语言实现;
  2. 采用 synchronized 不需要用户去手动释放锁,当 synchronized 方法或者 synchronized 代码块执行完之后,系统会自动让线程释放对锁的占用;而 Lock 则必须要用户去手动显式地释放锁,如果没有主动释放锁,就有可能导致出现死锁现象;
  3. synchronized 在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;而 Lock 在发生异常时,如果没有主动通过 unLock() 去释放锁,则很可能造成死锁现象,因此使用 Lock 时需要在 finally 块中释放锁;
  4. Lock 可以让等待锁的线程响应中断,而 synchronized 却不行,使用synchronized 时,等待的线程会一直等待下去,不能够响应中断;
  5. 通过 Lock 可以知道某个线程有没有成功获取锁,而 synchronized 却无法办到;
  6. Lock 可以提高多个线程进行读操作的效率。

在性能上来说,如果竞争资源不激烈,两者的性能是差不多的,而当竞争资源非常激烈时(即有大量线程同时竞争),此时 Lock 的性能要远远优于synchronized。所以说,在具体使用时要根据适当情况选择。

synchronized 关键字和 Reentrantlock 类异同

相同点

  • 都实现了多线程同步

  • 都保证了原子性(atomicity)和可见性(visibility)

  • 都是可重入锁(reentrant lock)

不同点

实现机制不同

  • synchronized 关键字通过 Java 对象头锁标记和 Monitor 对象实现
  • Reentrantlock 类通过CAS、AQS(AbstractQueuedSynchronizer)和 locksupport(用于阻塞和解除阻塞)实现
  • synchronized 依赖 JVM 内存模型保证包含共享变量的多线程内存可见性
  • Reentrantlock 类通过 AQS 的 volatile state 保证包含共享变量的多线程内存可见性

使用方式不同

  • synchronized 可以修饰实例方法(锁住实例对象)、静态方法(锁住类对象)、代码块(显示指定锁对象)
  • Reentrantlock 类需要显式调用 trylock()/lock() 方法,且需要在 finally 块中调用 unlock() 以释放锁

功能丰富程度不同

  • Reentrantlock 提供有限时间等候锁(设置过期时间)、可中断锁(lockInterruptibly)、condition(提供await、signal等方法)等丰富语义
  • Reentrantlock 提供公平锁和非公平锁实现
  • synchronized 不可设置等待时间、不可被中断(interrupted)

Reference