Java 并发 - 锁机制

274

一、公平性

1.1 公平锁

1.1.1 概念

多线程按照申请锁的顺序来获取锁,先来后到。

线程获取锁的时候会先查看此锁维护的等待队列,如果为空,当前线程是等待队列的第一个,就占用锁;否则加入等待队列,按照 FIFO 来获取锁

性能下降,顺序保证

1.1.2 Demo

Lock lock = new ReentrantLock(true); // 公平锁

1.2 非公平锁

1.2.1 概念

多线程获取锁的顺序并不是按照申请锁的顺序,后来申请的线程可能先获得锁,可能会造成线程优先级反转或饥饿

线程获取锁的时候,直接尝试占用,如果尝试成功,则占用锁;否则不断尝试

性能升高(吞吐量更高了),顺序无法得到保证

Synchronized 重量锁,也是一种非公平锁

1.2.2 Demo

Lock lock = new ReentrantLock(); // 非公平锁

二、可重入性

2.1 可重入锁

2.1.1 概念

同一线程获得该方法代码的锁之后,内层方法的锁会自动获取。也就是说,线程可以进入任何一个它已经拥有的锁的代码的内层同步代码块的锁

但有个前提:前提锁对象得是同一个对象或者 Class 对象

这是因为,这些代码的锁都由同一个管程对象 this 所同步,一个线程获取到管程对象上的锁,那么它就可以访问这个管程对象同步的所有代码块

外层一层又一层的调用,也使得可重入锁被称之为递归锁

最大作用就是避免死锁

2.1.2 Demo

Java 里面常用的可重入锁有两种:

  • synchronized

  • Reentrantlock

下面分别写两个验证锁为可重入锁的 Demo

1. 手写验证 ReentrantLock 为可重入锁

unlock 写在 finally 可以保证这个锁对象可以被解锁以便其它线程能继续对其加锁

class Test {
    public static void main(String[] args) {
        ReentrantDemo re = new ReentrantDemo();
        for (int i = 0; i < 10; i++) {
            new Thread(re).start();
        }
    }
}

class ReentrantDemo implements Runnable {
    private Lock reentrantLock = new ReentrantLock();

    @Override
    public void run() {
        out();
    }

    public void out() {
        try {
            reentrantLock.lock();
            System.out.println(Thread.currentThread().getName() + " out ");
            in();
        } finally {
            reentrantLock.unlock();
        }

    }

    public void in() {
        try {
            reentrantLock.lock();
            System.out.println(Thread.currentThread().getName() + " in ");
        } finally {
            reentrantLock.unlock();
        }
    }
}

2. 手写验证 synchronized 为可重入锁

class Test {
    public static void main(String[] args) {
        ReentrantDemo re = new ReentrantDemo();
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                re.out();
            }, "T-" + i).start();
        }
    }
}

class ReentrantDemo {
    public synchronized void out() {
        System.out.println(Thread.currentThread().getName() + " out ");
        in();
    }

    public synchronized void in() {
        System.out.println(Thread.currentThread().getName() + " in ");
    }
}

两个 Demo 的结果中可知,in 与 out 的输出,必定是同个线程输出,说明他们获得的是同一把锁

2.2 不可重入锁

2.2.1 概念

同一线程获得该方法代码的锁之后,内层方法的锁不会自动获取。也就是说,线程可以进入任何一个它已经拥有的锁的代码,再尝试获取内层同步代码块的锁的时候会卡住

Java 里面没有不可重入锁的内置实现

但我们可以自己实现不可重入锁,它的基本原理就是:

基于自旋锁中条件来判断的,就是判断是否当前锁没被线程占用,若没有占用,那么尝试获取其锁的线程就可以获得,但并没有判断是否是同一线程,也就是说同一线程获得了锁,希望再次获取一次同一把锁,会被阻塞!

public class Lock{
    boolean isLocked = false;

    public synchronized void lock()
        throws InterruptedException{
        while(isLocked){
            wait();
        }
        isLocked = true;
    }
    ...
}

2.2.2 Demo

手写一个不可重入锁

设计与测试简单的不可重入锁,采用自旋判断的方式来实现:

执行后,会发现会被阻塞

PS:这里的实现的不可重入锁是自旋判断,但却不属于自旋锁实现,以下的实现与下文简单自旋锁实现的区别就是,前者是改变了线程的状态为阻塞,而自旋锁没有阻塞线程

class Test {
    public static void main(String[] args) {
        UnRenentrantDemo unReDemo = new UnRenentrantDemo();
        for (int i = 0; i < 10; i++) {
            new Thread(unReDemo, "T-" + i).start();
        }
    }
}

class UnRenentrantDemo implements Runnable {

    UnReentrantLock unReentrantLock = new UnReentrantLock();

    @Override
    public void run() {
        out();
    }

    private void out() {
        try {
            unReentrantLock.lock();
            System.out.println(Thread.currentThread().getName() + " out ");
            in();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            unReentrantLock.unlock();
        }
    }

    private void in() {
        try {
            unReentrantLock.lock();
            System.out.println(Thread.currentThread().getName() + " in ");
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            unReentrantLock.unlock();
        }
    }


}

class UnReentrantLock {
    private boolean isLock = false;

    public synchronized void lock() throws InterruptedException {
        while (isLock) { 
            wait();
        }
        isLock = true;
    }

    public synchronized void unlock() {
        isLock = false; //现更新后,再唤醒某一阻塞的线程
        notify();
    }
}

同时,实现抽象类 java.util.concurrent.locks.Lock 也可以自己定制自己的锁,或者通过 AQS 来实现自己需要的锁

三、阻塞性

3.1 自旋锁

3.1.1 概念

尝试获取锁的线程不会立即阻塞,而是采用循环的方式去不断尝试获取锁

好处:减少线程上下文切换的消耗

坏处:循环时消耗 CPU 资源

Java 里并没有像 ReentrantLock 那样提出一个具体的自旋锁实现结构,但自旋锁的思想却处处有用到,例如:

  • 原子整型 AtomicInteger
  • 可重入锁 ReentrantLock

这两个举例的底层是用 Unsafe 类 的实例通过 CAS 判断的。这里 CAS 用的思路就是自旋锁思想,这又是一种乐观锁思想的体现,关于乐观锁,下文会划出一块讲解

Java 实现的自旋锁,默认限定次数是 10 次,调整限定参数是 -XX:PreBlockSpin 来调整,超过次数,还没获得,线程将挂起

虽然没有自旋锁的具体实现,但自旋锁常常有几种不同性质的实现:

  • Spin Lock:简单的自锁锁实现
  • Ticket Lock:解决简单自旋锁的公平性问题
  • MCS Lock:提供公平锁的实现,并且大大减少 Ticket Lock 不必要的处理器缓存同步次数
  • CLH Lock:与 MCS Lock 解决的问题类似,代码实现要更简单

3.1.2 Demo

手写一个简单自旋锁

本质就是自旋 + CAS 的方式实现的,由于用到了 CAS ,就需要借助原子引用实现了,实现与测试如下:

class SpinLock {
    private AtomicReference<Thread> threadRef = new AtomicReference<>(null); //初始为 null

    public void lock() {
        Thread currentThread = Thread.currentThread();
        while (!threadRef.compareAndSet(null, currentThread)) ; //循环判断
    }

    public void unlock() {
        Thread currentThread = Thread.currentThread();
        threadRef.compareAndSet(currentThread, null); //还原原子引用状态
    }
}

class Test {
    public static void main(String[] args) {
        SpinLock spinLock = new SpinLock();
        new Thread(() -> {
            spinLock.lock();
            try {
                System.out.println(Thread.currentThread().getName() + " holds the lock");
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                spinLock.unlock();
            }
        }, "T1").start();

        System.out.println(Thread.currentThread().getName());

        new Thread(() -> {
            spinLock.lock();
            try {
                System.out.println(Thread.currentThread().getName() + " holds the lock");
            } finally {
                spinLock.unlock();
            }
        }, "T2").start();
    }
}

每次主线程必定先启动,紧接着两个线程优先级不定,但一旦 T1 线程先占有锁的时候,就要占有 3 S 才能轮到 T2,才会输出 T2 的信息

可以看到,利用原子引用的 CAS ,自旋锁也相当于是一种乐观锁的实现

3.1.2 适应性自旋锁

为了解决 CPU 自旋时间过长的问题,提出了适应性自旋锁的概念

适应性自旋锁就是指的是调整自旋的次数,自旋次数不再固定,由两个条件动态决定:

  • 由前一次在同一个锁上的自旋时间
  • 拥有者的状态来决定

对于同一个锁,一线程自旋等待它刚刚才获得过这个锁,并且持有该锁的对象还在运行,那么 JVM 会认为这次自旋很有可能会再次成功,进而允许它自旋等待更长的时间

对于同一个锁,它自旋等待时很少获得过锁,那么以后在尝试这个锁时可能省略自旋过程,直接阻塞了,这样可以避免浪费处理器资源

适应性自旋 JDK 1.7 后由 JVM 控制是否使用

3.2 阻塞锁

3.2.1 概念

阻塞锁跟自旋锁相反,指的就是在线程尝试占用锁,它却已经被占用时,就立即阻塞

结合本文章节 2.2.2 ,我们也可以自己手动实现一个阻塞锁。拿章节 2.2.2 的实现分析,是否阻塞线程其实是锁的一种性质,如果阻塞,例如实现的不可重入锁 Demo ,那么它就是阻塞锁;如果不阻塞,例如 3.1.2 简单自旋锁 Demo,那么它就是非阻塞锁

Java 的具体实现如 synchronized 就是阻塞类型的锁

3.2.2 Demo

参考章节 2.2.2,它就具有阻塞锁的性质

四、共享性

4.1 独占锁

4.1.1 概念

独占锁,也叫排他锁,该锁一次只能被一个线程所持有

例如,ReentrantLock 和 synchronized 都属于独占锁

4.1.2 Demo

关于 RentrantLock 与 synchronized 的实现 Demo,可以分别参考章节 2.1.2 Demo 与 章节 2.2.2 Demo,也就是可重入锁实现与不可重入锁实现,他们都是属于独占锁的体现

4.2 共享锁

4.2.1 概念

该锁一次可被多个线程所持有

Java 具体的内置实现结构是 ReentrantReadWriteLock ,可重入读写锁中的读锁

对于 ReentrantReadWriteLock 来说:

  • 读锁是共享锁
  • 写锁是独占锁

关于共享锁,我们也主要以该结构来讨论,它的读锁具有共享性,这个特性,保证了数据的一致性,也保证了并发的高效性

当然,如果有需求也可以使用 AQS 来手动实现一个共享锁

总结一下,ReentrantReadWriteLock 可以保证数据的写安全,保证写操作是具有原子性,但看 4.2.2 Demo 多执行几次之后会发现:

  • 写的时候是原子的,但线程并发不是原子的,也就是后面的线程可能先写,但写这个过程是不允许被打断的
  • 读的时候不是原子的,可以线程并发同时读取容器,但是注意!由于没有保证线程并发的有序性,所以是可能存在读的时候,数据还未写入的!

4.2.2 Demo

实现一个简单缓存,测试验证可重入读写锁的读写分离,以及它的读锁为共享锁

class SimpleCache<K, V> {
    private ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
    private volatile Map<K, V> cache = new HashMap<>();

    public void put(K k, V v) {
        rwLock.writeLock().lock();
        try {
            System.out.println(Thread.currentThread().getName() + " writing " + k + " " + v);
            cache.put(k, v);
        } finally {
            System.out.println(Thread.currentThread().getName() + " writing over");
            rwLock.writeLock().unlock();
        }

    }

    public V get(K k) {
        rwLock.readLock().lock();
        try {
            V val = cache.get(k);
            System.out.println(Thread.currentThread().getName() + " reading " + val);
            return val;
        } finally {
            System.out.println(Thread.currentThread().getName() + " reading over");
            rwLock.readLock().unlock();
        }
    }

    public void clear() {
        cache.clear();
    }
}

class Test {
    public static void main(String[] args) {
        SimpleCache<Integer, Integer> cache = new SimpleCache<>();
        for (int i = 1; i <= 10; i++) {
            final int I = i;
            new Thread(() -> {
                cache.put(I, I * I);
            }, "T" + i).start();
        }

        for (int i = 1; i <= 10; i++) {
            final int I = i;
            new Thread(() -> {
                cache.get(I);
            }, "T" + i).start();
        }
    }
}

多试几次,会发现存在读线程读的时候,写线程还未把数据写进去,进而读到了 null 的情况!

五、锁定性

5.1 乐观锁

5.1.1 概念

对于同一个数据的并发操作,乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入,如果有更新,则报错或者重试。

5.1.2 实现

乐观锁就没有具体的 Demo 之说了,它和自锁锁一样,是一种思想,一种策略,在并发的时候可以选择这种策略,自旋锁这种思想是依赖于乐观锁这种思想的

具体的机制 / 思想 / 工具是用到了乐观锁思想的有:

  • CAS 机制
  • 版本号(Version)
  • 时间戳(Time Stamp)
  • Git、SVN 等版本控制工具
  • 自旋锁
  • 数据库事务并发控制:往表中添加使用版本号 + CAS 重试实现
  • 数据库事务并发控制:往表中添加时间戳 + CAS 重试实现
  • Java 原子引用:使用时间戳原子引用解决 ABA 问题
  • Java 原子整型:使用 CAS 重试来解决并发问题

5.1.3 适用

乐观锁适用于读多,写少的场景,不加锁会提高读的并发读

数据库事务的乐观锁应用就是回滚重试

5.2 悲观锁

5.2.1 概念

对于同一个数据的并发操作,悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改

5.2.2 实现

和乐观锁一样,悲观锁没有一种具体的结构叫悲观锁的,它也是一种思想,对于这种思想描述,我们只能说是某个算法思想 / 机制 / 结构用到了悲观锁的思想

具体的机制 / 思想是用到了悲观锁的思想的有:

  • Java 重量锁:synchronized
  • Java Lock 类的实现类:
    • 可重入锁:ReentrantLock
    • 可重入读写锁:ReentrantReadWriteLock
  • 数据库的锁:
    • 行锁
    • 表锁

5.2.3 适用

悲观锁适用于写多读少的并发环境,因为写多还用乐观锁会增加重试时间消耗 CPU 资源

数据库事务中的悲观锁就是阻塞事务

六、锁升级

6.1 前言

6.1.1 概念

锁升级指的是一系列为 synchronized 也就是重量锁优化的策略,因为重量锁在 JVM 是通过操作系统内核的互斥锁来实现的,性能非常不好。JDK 1.5 之后,引入了锁升级策略来优化重量锁的性能

注意,锁升级指的是锁对象这个对象的升级

6.1.2 锁状态

首先 Java 的对象组成:

  • 对象头
  • 实例数据
  • 对齐填充

关注对象的对象头组成:

  • 第一部分 — Mark Word:存储自身运行时数据
    • 哈希码
    • GC 分代年龄
    • 锁状态标志位
    • ...
  • 第二部分 — Klass Point:存储执行它的类的元数据的指针

关注对象头的第一部分中的锁状态标志位,JVM 就是通过这个字段来判断线程应该采取哪种并发控制的!

锁对象的锁状态标志位大小 2bit,也就是可以表示四种情况,它也存在 Mark Word 中,同时随着状态不同,Mark Word 存的其他内容也会动态改变:

锁状态Mark Word 的其他存储字段锁状态值
无锁对象的 hashCode、对象分代年龄、是否是偏向锁(0)01
偏向锁偏向线程ID、偏向时间戳、对象分代年龄、是否是偏向锁(1)01
轻量级锁指向栈中锁记录的指针00
重量级锁指向互斥量(重量级锁)的指针10

总结:对于不同情况,我们能改变锁对象状态位从而对线程并发控制采用不同策略

6.2 无锁

无锁就是不对资源加锁,自旋锁就是无锁的一种思路实现,所以无锁也属于乐观锁策略,Java 里当状态为无锁时,就是通过 CAS 自旋锁实现的

一句话,无锁状态的线程,必须得通过自旋锁等无锁策略来竞争锁对象

6.3 偏向锁

偏向锁就是 JVM 对于总是获得锁的那个线程持偏向态度,它能自动获取锁,降低获得它获取锁的代价。所以当线程对象现在是偏向锁状态时,它总能获取到锁,一旦出现其他线程尝试竞争该偏向锁时,持有偏向锁的线程才释放锁,释放后,线程恢复到 “无锁” 状态或者升级到 “轻量级锁” 状态

注意,升级到偏向锁的线程对象它的锁状态标志还是无锁的 ”01“ ,但 Mark Word 中有一个字段 ”是否为偏向锁“ 修改为 1

一句话,偏向锁的线程,总是占用锁,不过一旦有其他线程也参与竞争,它就释放锁对象并升级或恢复

6.4 轻量级锁

轻量级锁指的就是当锁状态为偏向锁的线程,因为其他线程参与竞争,它就会释放锁,升级到轻量级锁状态,而其他线程将保持 “无锁“ 状态来自旋竞争锁

升级到轻量级锁的线程竞争锁对象的方式,和 ”无锁“ 很像,都是通过 CAS 实现

一个轻量级锁状态的线程竞争锁的过程是这样的:

  • 在轻量级锁状态的线程的栈中建立一个锁记录(Lock Record)空间
  • 把锁对象的 Mark Word 拷贝到 Lock Record 中,这样的拷贝的记录叫 Displaced Mark Word
  • 轻量级锁线程通过 CAS 来尝试将自己的 Mark Word 指向线程的 Lock Record
    • 成功,则获得锁
    • 失败,再判断是否已经指向栈帧了
      • 若是,说明已经取得锁,直接进入
      • 不是,说明锁被其他线程,抢占失败,轻量级锁膨胀为重量级锁

一个轻量级锁状态的线程解锁过程是这样的:

  • 判断锁对象的 Mark Word 是否仍然指向自己
    • 是,则 CAS 替换成功
    • 不是,说明有别的线程替换过锁对象的 Mark Word,轻量级锁膨胀位重量级锁

一句话,轻量级锁 CAS 判断线程的 Lock Record 与锁对象的 Mark Word 来确定是否要膨胀为重量级锁

6.5 重量级锁

通过操作系统内核的互斥锁实现,升级到重量级锁会把占用锁对象的线程之外的线程全部阻塞

如果 javap -c 反编译查看一个带有 synchronized 的代码块的代码,会发现 synchronized 是由:

  • monitorenter
  • monitorexit

两条字节码指令构成的,并且会有两条 monitorexit 指令,这是为了避免底层出现死锁,第一条是正常退出,第二条是异常退出,总之就是保证一定会退出,所以重量锁一般没有 try-finally 语句块,而可重入锁一般要,如果没有,可重入锁可能发生死锁

6.6 总结

锁升级,也就是所谓的锁膨胀,其实就是从各有特点的 CAS 不断 ”挣扎“ 到悲观锁的过程,这样做全都是一个经验数据来的:大部分锁的同步周期内,锁不存在竞争

七、总结

全文大概花了三天整理,其实锁有那么多种分类,都是从不同角度来看的

Java 具体提供给我们用的锁的结构就是三种:

  • synchronized
  • ReentrantLock
  • ReentrantReadWriteLock

说来说去也就这些,要分析一个锁的特点就要从其性质或者某个角度入手分析,锁的不同性质间也都是互相联系以来的

本文说了五个性质:

  • 公平性
  • 可重入性
  • 阻塞性
  • 共享性
  • 锁定性

五个性质,每一个性质都有两种,无论是哪个锁,肯定具备这五个性质中的其中一种

例如锁定性,一个锁要么是悲观性质,要么是乐观性质,肯定具备一点,其他四个性质同理

本文也只是从五个角度来分析锁,但实际上也可以有其他角度,例如可中断性,那也可以说一个锁肯定是可中断或者不可中断的。

重量锁 VS 可重入锁

性质重量锁可重入锁
公平性无法实现公平锁可以实现公平锁
层次性系统底层实现关键字JDK 实现的 API
通信对线程的要么唤醒一个要么全唤醒可以实现精准唤醒一个线程
中断性无法中断可中断
解锁安全性底层保证能解锁安全退出需要放在 try-finally 保证一定解锁

引用