线程的三大特性
有序性、原子性、可见性。任一特性没有被保证都会导致线程安装。

synchronized

定义:使用阻塞的方式使临界区的代码互斥执行

4.5 Monitor 概念

Java 对象头

以 32 位虚拟机为例,普通对象的对象头结构如下,其中的Klass Word为指针,指向对应的Class对象;

mod - 图1

数组对象

mod - 图2

其中 Mark Word 结构为

mod - 图3

所以一个对象的结构如下:

mod - 图4

Monitor 原理

Monitor被翻译为监视器或者说管程

每个java对象都可以关联一个Monitor,如果使用synchronized给对象上锁(重量级),该对象头的Mark Word中就被设置为指向Monitor对象的指针,中的owner属性就会被设置为指向持有锁对象的线程。

线程

mod - 图5

  • 刚开始时Monitor中的Owner为null
  • 当Thread-2 执行synchronized(obj){}代码时就会将Monitor的所有者Owner 设置为 Thread-2,上锁成功,Monitor中同一时刻只能有一个Owner
  • 当Thread-2 占据锁时,如果线程Thread-3,Thread-4也来执行synchronized(obj){}代码,就会进入EntryList中变成BLOCKED状态
  • Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争时是非公平的
  • 图中 WaitSet 中的 Thread-0,Thread-1 是之前获得过锁,但条件不满足进入 WAITING 状态的线程,后面讲wait-notify 时会分析

注意:synchronized 必须是进入同一个对象的 monitor 才有上述的效果不加 synchronized 的对象不会关联监视器,不遵从以上规则

synchronized原理

代码如下 Test17.java

  1. static final Object lock=new Object();
  2. static int counter = 0;
  3. public static void main(String[] args) {
  4. synchronized (lock) {
  5. counter++;
  6. }
  7. }

反编译后的部分字节码

  1. 0 getstatic #2 <com/concurrent/test/Test17.lock>
  2. # 取得lock的引用(synchronized开始了)
  3. 3 dup
  4. # 复制操作数栈栈顶的值放入栈顶,即复制了一份lock的引用
  5. 4 astore_1
  6. # 操作数栈栈顶的值弹出,即将lock的引用存到局部变量表中
  7. 5 monitorenter
  8. # 将lock对象的Mark Word置为指向Monitor指针
  9. 6 getstatic #3 <com/concurrent/test/Test17.counter>
  10. 9 iconst_1
  11. 10 iadd
  12. 11 putstatic #3 <com/concurrent/test/Test17.counter>
  13. 14 aload_1
  14. # 从局部变量表中取得lock的引用,放入操作数栈栈顶
  15. 15 monitorexit
  16. # 将lock对象的Mark Word重置,唤醒EntryList
  17. 16 goto 24 (+8)
  18. # 下面是异常处理指令,可以看到,如果出现异常,也能自动地释放锁
  19. 19 astore_2
  20. 20 aload_1
  21. 21 monitorexit
  22. 22 aload_2
  23. 23 athrow
  24. 24 return

注意:方法级别的 synchronized 不会在字节码指令中有所体现

synchronized 原理进阶

轻量级锁

轻量级锁的使用场景是:如果一个对象虽然有多个线程要对它进行加锁,但是加锁的时间是错开的(也就是没有人可以竞争的),那么可以使用轻量级锁来进行优化。轻量级锁对使用者是透明的,即语法仍然是synchronized,假设有两个方法同步块,利用同一个对象加锁

static final Object obj = new Object();
public static void method1() {
     synchronized( obj ) {
         // 同步块 A
         method2();
     }
}
public static void method2() {
     synchronized( obj ) {
         // 同步块 B
     }
}
  1. 每次指向到synchronized代码块时,都会在栈帧中创建锁记录(Lock Record)对象,每个线程都会包括一个锁记录的结构,锁记录内部可以储存对象的Mark Word和对象引用reference
    1. mod - 图6
  2. 让锁记录中的Object reference指向对象,并且尝试用cas(compare and sweep)替换Object对象的Mark Word ,将Mark Word 的值存入锁记录中
    1. mod - 图7
  3. 如果cas替换成功,那么对象的对象头储存的就是锁记录的地址和状态01,如下所示
    1. mod - 图8
  4. 如果cas失败,有两种情况
    1. 如果是其它线程已经持有了该Object的轻量级锁,那么表示有竞争,将进入锁膨胀阶段
    2. 如果是自己的线程已经执行了synchronized进行加锁,那么那么再添加一条 Lock Record 作为重入的计数
      1. mod - 图9
  5. 当线程退出synchronized代码块的时候,如果获取的是取值为 null 的锁记录 ,表示有重入,这时重置锁记录,表示重入计数减一
    1. mod - 图10
  6. 当线程退出synchronized代码块的时候,如果获取的锁记录取值不为 null,那么使用cas将Mark Word的值恢复给对象
    1. 成功则解锁成功
    2. 失败,则说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程

锁膨胀

如果在尝试加轻量级锁的过程中,cas操作无法成功,这是有一种情况就是其它线程已经为这个对象加上了轻量级锁,这是就要进行锁膨胀,将轻量级锁变成重量级锁。

  1. 当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁
    1. mod - 图11
  2. 这时 Thread-1 加轻量级锁失败,进入锁膨胀流程
    1. 即为对象申请Monitor锁,让Object指向重量级锁地址,然后自己进入Monitor 的EntryList 变成BLOCKED状态
    2. mod - 图12
  3. 当Thread-0 推出synchronized同步块时,使用cas将Mark Word的值恢复给对象头,失败,那么会进入重量级锁的解锁过程,即按照Monitor的地址找到Monitor对象,将Owner设置为null,唤醒EntryList 中的Thread-1线程

自旋优化

重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即在自旋的时候持锁的线程释放了锁),那么当前线程就可以不用进行上下文切换就获得了锁

  1. 自旋重试成功的情况
    1. mod - 图13
  2. 自旋重试失败的情况,自旋了一定次数还是没有等到持锁的线程释放锁
    1. mod - 图14

自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。Java 7 之后不能控制是否开启自旋功能

偏向锁

在轻量级的锁中,我们可以发现,如果同一个线程对同一个2对象进行重入锁时,也需要执行CAS操作,这是有点耗时滴,那么java6开始引入了偏向锁的东东,只有第一次使用CAS时将对象的Mark Word头设置为入锁线程ID,之后这个入锁线程再进行重入锁时,发现线程ID是自己的,那么就不用再进行CAS了

mod - 图15

偏向状态

mod - 图16

一个对象的创建过程

  1. 如果开启了偏向锁(默认是开启的),那么对象刚创建之后,Mark Word 最后三位的值101,并且这是它的Thread,epoch,age都是0,在加锁的时候进行设置这些的值.
  2. 偏向锁默认是延迟的,不会在程序启动的时候立刻生效,如果想避免延迟,可以添加虚拟机参数来禁用延迟:-XX:BiasedLockingStartupDelay=0来禁用延迟
  3. 注意:处于偏向锁的对象解锁后,线程 id 仍存储于对象头中
  4. 实验Test18.java,加上虚拟机参数-XX:BiasedLockingStartupDelay=0进行测试

    public static void main(String[] args) throws InterruptedException {
         Test1 t = new Test1();
         test.parseObjectHeader(getObjectHeader(t));
         synchronized (t){
             test.parseObjectHeader(getObjectHeader(t));
         }
         test.parseObjectHeader(getObjectHeader(t));
     }
    
    1. 输出结果如下,三次输出的状态码都为101
      biasedLockFlag (1bit): 1
      LockFlag (2bit): 01
      biasedLockFlag (1bit): 1
      LockFlag (2bit): 01
      biasedLockFlag (1bit): 1
      LockFlag (2bit): 01
      

测试禁用:如果没有开启偏向锁,那么对象创建后最后三位的值为001,这时候它的hashcode,age都为0,hashcode是第一次用到hashcode时才赋值的。在上面测试代码运行时在添加 VM 参数-XX:-UseBiasedLocking禁用偏向锁(禁用偏向锁则优先使用轻量级锁),退出synchronized状态变回001

  1. 测试代码Test18.java 虚拟机参数-XX:-UseBiasedLocking
  2. 输出结果如下,最开始状态为001,然后加轻量级锁变成00,最后恢复成001
    biasedLockFlag (1bit): 0
     LockFlag (2bit): 01
    LockFlag (2bit): 00
    biasedLockFlag (1bit): 0
     LockFlag (2bit): 01
    

撤销偏向锁-hashcode方法

测试 hashCode:当调用对象的hashcode方法的时候就会撤销这个对象的偏向锁,因为使用偏向锁时没有位置存hashcode的值了

  1. 测试代码如下,使用虚拟机参数-XX:BiasedLockingStartupDelay=0 ,确保我们的程序最开始使用了偏向锁!但是结果显示程序还是使用了轻量级锁。 Test20.java

     public static void main(String[] args) throws InterruptedException {
         Test1 t = new Test1();
         t.hashCode();
         test.parseObjectHeader(getObjectHeader(t));
    
         synchronized (t){
             test.parseObjectHeader(getObjectHeader(t));
         }
         test.parseObjectHeader(getObjectHeader(t));
     }
    
  2. 输出结果

    biasedLockFlag (1bit): 0
     LockFlag (2bit): 01
    LockFlag (2bit): 00
    biasedLockFlag (1bit): 0
     LockFlag (2bit): 01
    

撤销偏向锁-其它线程使用对象

这里我们演示的是偏向锁撤销变成轻量级锁的过程,那么就得满足轻量级锁的使用条件,就是没有线程对同一个对象进行锁竞争,我们使用waitnotify 来辅助实现

  1. 代码 Test19.java,虚拟机参数-XX:BiasedLockingStartupDelay=0确保我们的程序最开始使用了偏向锁!
  2. 输出结果,最开始使用的是偏向锁,但是第二个线程尝试获取对象锁时,发现本来对象偏向的是线程一,那么偏向锁就会失效,加的就是轻量级锁
    biasedLockFlag (1bit): 1
     LockFlag (2bit): 01
    biasedLockFlag (1bit): 1
     LockFlag (2bit): 01
    biasedLockFlag (1bit): 1
     LockFlag (2bit): 01
    biasedLockFlag (1bit): 1
     LockFlag (2bit): 01
    LockFlag (2bit): 00
    biasedLockFlag (1bit): 0
     LockFlag (2bit): 01
    

撤销 - 调用 wait/notify

会使对象的锁变成重量级锁,因为wait/notify方法之后重量级锁才支持

批量重偏向

如果对象被多个线程访问,但是没有竞争,这时候偏向了线程一的对象又有机会重新偏向线程二,即可以不用升级为轻量级锁,可这和我们之前做的实验矛盾了呀,其实要实现重新偏向是要有条件的:就是超过20对象对同一个线程如线程一撤销偏向时,那么第20个及以后的对象才可以将撤销对线程一的偏向这个动作变为将第20个及以后的对象偏向线程二。Test21.java

4.6 wait和notify

建议先看看wait和notify方法的javadoc文档

4.6.1同步模式之保护性暂停

即 Guarded Suspension,用在一个线程等待另一个线程的执行结果,要点:

  1. 有一个结果需要从一个线程传递到另一个线程,让他们关联同一个 GuardedObject
  2. 如果有结果不断从一个线程到另一个线程那么可以使用消息队列(见生产者/消费者)
  3. JDK 中,join 的实现、Future 的实现,采用的就是此模式
  4. 因为要等待另一方的结果,因此归类到同步模式

代码:Test22.java Test23.java这是带超时时间的

mod - 图17

Test23.java中jiang’dao’de关于超时的增强,在join(long millis) 的源码中得到了体现:

    public final synchronized void join(long millis)
    throws InterruptedException {
        long base = System.currentTimeMillis();
        long now = 0;

        if (millis < 0) {
            throw new IllegalArgumentException("timeout value is negative");
        }

        if (millis == 0) {
            while (isAlive()) {
                wait(0);
            }
        } else {
        // join一个指定的时间
            while (isAlive()) {
                long delay = millis - now;
                if (delay <= 0) {
                    break;
                }
                wait(delay);
                now = System.currentTimeMillis() - base;
            }
        }
    }

多任务版 GuardedObject图中 Futures 就好比居民楼一层的信箱(每个信箱有房间编号),左侧的 t0,t2,t4 就好比等待邮件的居民,右侧的 t1,t3,t5 就好比邮递员如果需要在多个类之间使用 GuardedObject 对象,作为参数传递不是很方便,因此设计一个用来解耦的中间类,这样不仅能够解耦【结果等待者】和【结果生产者】,还能够同时支持多个任务的管理。和生产者消费者模式的区别就是:这个生产者和消费者之间是一一对应的关系,但是生产者消费者模式并不是。rpc框架的调用中就使用到了这种模式。 Test24.java

mod - 图18

4.6.2异步模式之生产者/消费者

要点

  1. 与前面的保护性暂停中的 GuardObject 不同,不需要产生结果和消费结果的线程一一对应
  2. 消费队列可以用来平衡生产和消费的线程资源
  3. 生产者仅负责产生结果数据,不关心数据该如何处理,而消费者专心处理结果数据
  4. 消息队列是有容量限制的,满时不会再加入数据,空时不会再消耗数据
  5. JDK 中各种阻塞队列,采用的就是这种模式

“异步”的意思就是生产者产生消息之后消息没有被立刻消费,而“同步模式”中,消息在产生之后被立刻消费了。

mod - 图19

我们写一个线程间通信的消息队列,要注意区别,像rabbit mq等消息框架是进程间通信的。

4.7 park & unpack

4.7.1 基本使用

它们是 LockSupport 类中的方法 Test26.java

// 暂停当前线程
LockSupport.park();
// 恢复某个线程的运行
LockSupport.unpark;

park函数是将当前调用Thread阻塞,而unpark函数则是将指定线程Thread唤醒。

4.7.2 park unpark 原理

每个线程都有自己的一个 Parker 对象,由三部分组成 _counter, _cond和 _mutex

  1. 打个比喻线程就像一个旅人,Parker 就像他随身携带的背包,条件变量 _ cond就好比背包中的帐篷。_counter 就好比背包中的备用干粮(0 为耗尽,1 为充足)
  2. 调用 park 就是要看需不需要停下来歇息
    1. 如果备用干粮耗尽,那么钻进帐篷歇息
    2. 如果备用干粮充足,那么不需停留,继续前进
  3. 调用 unpark,就好比令干粮充足
    1. 如果这时线程还在帐篷,就唤醒让他继续前进
    2. 如果这时线程还在运行,那么下次他调用 park 时,仅是消耗掉备用干粮,不需停留继续前进
      1. 因为背包空间有限,多次调用 unpark 仅会补充一份备用干粮

可以不看例子,直接看实现过程

先调用park再调用upark的过程

1.先调用park

  1. 当前线程调用 Unsafe.park() 方法
  2. 检查 _counter ,本情况为 0,这时,获得 _mutex 互斥锁(mutex对象有个等待队列 _cond)
  3. 线程进入 _cond 条件变量阻塞
  4. 设置 _counter = 0

mod - 图20

2.调用upark

  1. 调用 Unsafe.unpark(Thread_0) 方法,设置 _counter 为 1
  2. 唤醒 _cond 条件变量中的 Thread_0
  3. Thread_0 恢复运行
  4. 设置 _counter 为 0

mod - 图21

先调用upark再调用park的过程

  1. 调用 Unsafe.unpark(Thread_0) 方法,设置 _counter 为 1
  2. 当前线程调用 Unsafe.park() 方法
  3. 检查 _counter ,本情况为 1,这时线程无需阻塞,继续运行
  4. 设置 _counter 为 0

mod - 图22

4.10 ReentrantLock

相对于 synchronized 它具备如下特点

  1. 可中断
  2. 可以设置超时时间
  3. 可以设置为公平锁
  4. 支持多个条件变量,即对与不满足条件的线程可以放到不同的集合中等待

与 synchronized 一样,都支持可重入

基本语法

// 获取锁
reentrantLock.lock();
try {
 // 临界区
} finally {
 // 释放锁
 reentrantLock.unlock();
}

可重入

可重入是指同一个线程如果首次获得了这把锁,那么因为它是这把锁的拥有者,因此有权利再次获取这把锁如果是不可重入锁,那么第二次获得锁时,自己也会被锁挡住

可打断

直接看例子:Test31.java

锁超时

直接看例子:Test32.java

使用锁超时解决哲学家就餐死锁问题:Test33.java

公平锁

synchronized锁中,在entrylist等待的锁在竞争时不是按照先到先得来获取锁的,所以说synchronized锁时不公平的;ReentranLock锁默认是不公平的,但是可以通过设置实现公平锁。本意是为了解决之前提到的饥饿问题,但是公平锁一般没有必要,会降低并发度,使用trylock也可以实现。

条件变量

synchronized 中也有条件变量,就是我们讲原理时那个 waitSet 休息室,当条件不满足时进入 waitSet 等待
ReentrantLock 的条件变量比 synchronized 强大之处在于,它是支持多个条件变量的,这就好比

  1. synchronized 是那些不满足条件的线程都在一间休息室等消息
  2. 而 ReentrantLock 支持多间休息室,有专门等烟的休息室、专门等早餐的休息室、唤醒时也是按休息室来唤

使用要点: Test34.java

  1. await 前需要获得锁
  2. await 执行后,会释放锁,进入 conditionObject 等待
  3. await 的线程被唤醒(或打断、或超时)取重新竞争 lock 锁,执行唤醒的线程爷必须先获得锁
  4. 竞争 lock 锁成功后,从 await 后继续执行

同步模式之顺序控制

  1. 固定运行顺序,比如,必须先 2 后 1 打印
    1. wait notify 版 Test35.java
    2. Park Unpark 版 Test36.java
  2. 交替输出,线程 1 输出 a 5 次,线程 2 输出 b 5 次,线程 3 输出 c 5 次。现在要求输出 abcabcabcabcabc 怎么实现
    1. wait notify 版 Test37.java
    2. Lock 条件变量版 Test38.java
    3. Park Unpark 版 Test39.java