Java多线程笔记(2)-synchronized的使用与原理
临界区
在多线程的场景下,会涉及到如下的一些概念:
临界资源:一次仅允许一个进程使用的资源称为临界资源
临界区:访问临界资源的代码块
竞态条件:多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件(竞争)
一个程序运行多个线程本身是没有问题的,多个线程读取共享资源也是没有问题的,而在多个线程对共享资源读写操作的时候发生了指令交错,就会出现问题。而为了避免临界区的竞态条件发生,解决线程安全问题,可以有如下两种解决方案:
- 阻塞式的解决方案:synchronized,lock
- 非阻塞式的解决方案:原子变量
synchronized
锁使用
synchronized:对象锁,保证了临界区内代码的原子性。临界区内的代码对外是不可分割的,只有获取到锁的线程才能执行临界区内的代码。采用互斥的方式让同一时刻至多只有一个线程能持有对象锁,其它线程获取这个对象锁时会阻塞,保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换。
synchronized的使用方式有:
- 修饰静态方法,对应的锁对象即为
类.class - 修饰普通方法,对应的锁对象即为该实例对象
- 修饰代码块,对应的锁对象即为指定的对象
1 | |
在分析利用synchronized的时候,主要需要关注锁住的对象是不是同一个
- 锁住类对象,所有类的实例的方法都是安全的,类的所有实例都相当于同一把锁
- 锁住 this 对象,只有在当前实例对象的线程内是安全的,如果有多个实例就不安全
锁原理-Monitor
首先我们需要回顾一下Java中的对象组成。Java中的对象组成分为Java对象头+实例数据,其中Java对象头又分为了Mark Word和Klass Word,如果是数组对象的话,还会有一个数组长度Array Length的记录。
在这里我们主要关注Mark Word。Mark Word在正常情况下,会存储hashcode、分代年龄、锁标志位等信息,但是在其他特殊情况下,存储的信息会有所改变,具体如下:(Mark Word的最后两位是锁标志位)
32位虚拟机
1 | |
64位虚拟机
1 | |
synchronzied的锁原理涉及到Monitor的概念。Monitor的源码由C++实现。
每个Java对象都可以关联一个Monitor对象,Monitor也是一个类,其实例存储在堆中。如果使用synchronized给对象上锁(重量级)之后,该对象头中的Mark Word就被设置为执行Monitor的指针。在Monitor中有一些关键属性,包括Owner,EntryList和WaitSet,分别指示锁持有线程,阻塞队列和等待队列。
具体工作流程如下:
- 一开始Monitor中的Owner为null,表示没有线程持有锁
- 当有线程Thread-2执行synchronzied(obj)的时候,就会将Monitor的所有者Owner置为Thread-2。同一个时刻,一个Monitor中只能有一个Owner
- 之后将obj对象的Mark Word设置为指向Monitor(即上面的重量级锁情况),将对象原有的Mark Word存入对应的Monitor对象中,用于后续释放时恢复
- 在Thread-2上锁的过程中,Thread-3、Thread-4和Thread-5也执行synchronized(obj),就会进入EntryList(双向链表),进入阻塞状态
- Thread-2 执行完同步代码块的内容,根据 obj 对象头中 Monitor 地址寻找,设置 Owner 为空,把线程栈的锁记录中的对象头的值设置回 MarkWord
- 之后唤醒 EntryList 中等待的线程来竞争锁,竞争是非公平的,如果这时有新的线程想要获取锁,可能直接就抢占到了,阻塞队列的线程就会继续阻塞
- WaitSet 中的 Thread-0和Thread-1,指的是调用wait进入WAITING状态的线程(wait-notify 机制)
注意:
- synchronzied必须是进入同一个对象的Monitor才会有上述的效果
- 不加synchronized的对象不会关联Monitor
- synchronized在字节码层面,通过异常try-catch机制,确保一定会被解锁
锁升级
轻量级锁
轻量级锁的使用场景:一个对象虽然有多线程访问,但是多线程访问的时间是相互错开的,实际上没有竞争,那么可以使用轻量级锁来进行优化。在轻量级锁的情况下,不会涉及到Monitor对象的关联等一系列操作,而是使用到线程栈帧中的锁记录对象
轻量级锁的工作原理如下:
首先创建锁记录(Lock Record)对象。每个线程的栈帧都会包含一个锁记录的结果,内部可以存储锁定对应的Mark Word。一开始,锁记录中存储了本身锁记录的地址,以及对应的锁对象的引用

开始执行加锁。即让锁记录中的Object Reference指向对应锁住的对象,并尝试用CAS来替换Object中的Mark Word,将Mark Word的值存入锁记录中
如果CAS替换成功,那么Object的Mark Word就存储了锁记录地址以及锁标志位00,表示轻量级锁。这样就表示由对应线程持有了锁

如果CAS替换失败,则可能的情况有两种
- 其他线程已经持有了该Object的轻量级锁,这样就表示此时发生了竞争,则进入锁膨胀的过程
- 该Object上的锁记录指向的是自己线程,即执行了synchronized的锁重入,则此时就再添加一条Lock Record作为重入的计数,其中原本记录锁记录地址的位置置为null

当退出synchronzed代码块,执行解锁的时候
- 如果有取值为null的锁记录,则表示重入,则重置锁记录,表示重入计数减一
- 如果锁记录的值不为null,则表示是一开始加的锁,则此时使用CAS将普通状态的Mark
Word值恢复给Object中的对象头
- 此时的CAS可能失败也可能成功,如果成功即表示解锁成功
- 如果失败,说明该轻量级锁已经升级成为了重量级锁,则进入重量级锁的解锁流程
锁膨胀即锁升级为重量级锁,关联到Monitor对象的一系列过程
偏向锁
轻量级锁在没有竞争的时候,每次重入仍然需要执行CAS操作,有一定的性能消耗。因此,Java 6中引入了偏向锁来及进行进一步的优化。
只有第一次需要使用CAS将线程ID设置到对象的Mark Word头中,之后发现这个线程ID是自己的就表示没有竞争,无需重新CAS。并且以后只要不发生竞争,这个锁对象就归该线程所有。
当有另外一个线程去尝试获取这个锁对象的时候,偏向状态就宣告结束,进行偏向撤销,然后恢复到未锁定或者轻量级锁的状态。
当一个对象创建的时候:
- 如果开启了偏向锁,那么对象创建之后,Mark Word的最后3位为101,thread、epoch、age都为0
- 如果没有开启偏向锁,那么对象创建后,Mark Word的最后3位为001,hashcode、age都为0,当第一次用到hashCode的时候才会赋值
- 偏向锁默认开启,也可以使用参数
-XX:-UseBiasedLocking来禁用偏向锁 - 偏向锁默认是延迟的,会在程序启动后延迟几秒生效。这是因为在刚开始执行代码的时候,可能会有很多线程进行竞争,开启偏向锁效率反而降低。我们也可以使用参数
-XX:BiasedLockingStartupDelay=0来避免延迟
偏向锁在一些情况下会被撤销:
- 调用对象的hashCode时,偏向锁被撤销。因为在偏向锁状态下无法存放hashCode
- 当有其他线程使用偏向锁对象的时候,会将偏向锁升级成为轻量级锁
- 调用wait - notify的时候,会将偏向锁升级为重量级锁,因为在Monitor中才有WatiSet
升级过程
上述的锁升级对于使用者来说都是透明的,使用语法都是synchronized。JVM会自动根据情况进行锁升级的过程。
1 | |
锁优化
自旋优化:重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功,即持锁线程已经退出了同步块释放锁,这时候当前线程就可以避免阻塞
- 优点:不会进入阻塞状态,减少线程上下文切换的消耗
- 缺点:消耗CPU资源
- 说明:
- 在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,比较智能
- Java 7 之后不能控制是否开启自旋功能,由 JVM 控制
锁消除:指对于被检测出不可能存在竞争的共享数据的锁进行消除,这是 JVM 即时编译器JIT的优化。锁消除主要是通过逃逸分析来支持,如果堆上的共享数据不可能逃逸出去被其它线程访问到,那么就可以把它们当成私有数据对待,也就可以将它们的锁进行消除(同步消除:JVM 逃逸分析)
wait - notify
wait - notify机制表示对象的等待和唤醒机制。wait、notify、notifyAll是Object的方法,需要用锁对象来调用。获取了该对象锁才能调用这些方法。 相关API如下:
1 | |
wait - notify的使用需要配合Monitor进行。调用wait的线程会变为WAITING状态,进入对应Monitor的WaitSet中。当Owner线程调用notify或者notifyAll唤醒处于WAITING状态的线程之后,被唤醒的线程进入EntryList重新竞争
sleep和wait的区别:
方法归属不同:
- sleep是Thread的静态方法
- wait是Object的成员方法
醒来时机不同:
- sleep(long)和wait(long)都可以在等待超过对应时间后醒来
- wait可以被notify和notifyAll进行唤醒
- 它们都可以被打断唤醒
锁特性不同:
- wait方法的调用必须先调用wait对象的锁,而sleep则没有这个限制
- wait方法执行后会释放对象锁,但是sleep不会放弃锁
使用wait - notify的时候,可能遇到虚假唤醒的问题。这种情况指的是,notify只能随机唤醒WaitSet中的一个线程,但是可能有多个线程处于WAITING状态,唤醒的不一定是正确的线程。针对这种情况,我们选择采用notifyAll
采用notifyAll之后,可以解决线程的唤醒问题。但是这会唤醒所有的线程,有的线程可能条件没有得到满足也被唤醒了。为了解决这个问题,我们一般不使用if + wait来进行条件判断,而应该使用while + wait。唤醒之后重新判断,如果条件不满足,则继续等待
park - unpark
park和unpark是LockSupport中提供的相关API:
LockSupport.park():暂停当前线程,挂起原语LockSupport.unpark(暂停的线程对象):恢复某个线程的运行
LockSupport 出现就是为了增强 wait & notify 的功能:
- wait,notify 和 notifyAll 必须配合 Object Monitor 一起使用;而 park、unpark 不需要
- park & unpark 以线程为单位来阻塞和唤醒线程,更加精确;而 notify 只能随机唤醒一个等待线程,notifyAll 是唤醒所有等待线程
- park & unpark 可以先 unpark,而 wait & notify 不能先 notify。类比生产消费,先消费发现有产品就消费,没有就等待;先生产就直接产生商品,然后线程直接消费
- wait 会释放锁资源进入等待队列,park 不会释放锁资源,只负责阻塞当前线程,会释放 CPU(进入WAITING状态)
park会让线程进入WAITING状态,但是我们打断park线程并不会清除打断标记,并且如果打断标记为true,调用park是无法让线程阻塞的
1
2
3
4
5
6
7
8
9
10
11
12
13
14Thread thread = new Thread(() -> {
log.debug("park...");
LockSupport.park();
log.debug("unpark...");
log.debug("打断标记: {}", Thread.currentThread().isInterrupted());
// log.debug("打断标记: {}", Thread.interrupted());
LockSupport.park();
log.debug("unpark...");
}, "thread1");
thread.start();
Thread.sleep(1000);
thread.interrupt();
线程分析
线程安全分析
对于成员变量和静态变量:
- 如果它们没有共享,则线程安全
- 如果它们被共享了,根据它们的状态是否能够改变,分两种情况:
- 如果只有读操作,则线程安全
- 如果有读写操作,则这段代码是临界区,需要考虑线程安全问题
对于局部变量:
- 局部变量是线程安全的
- 局部变量引用的对象不一定线程安全(逃逸分析):
- 如果该对象没有逃离方法的作用访问,它是线程安全的
- 如果该对象逃离方法的作用范围,需要考虑线程安全问题
常见线程安全类:String、Integer、StringBuffer、Random、Vector、Hashtable、java.util.concurrent 包
线程安全的是指,多个线程调用它们同一个实例的某个方法时,是线程安全的
每个方法是原子的,但多个方法的组合不是原子的,只能保证调用的方法内部安全:
1
2
3
4
5Hashtable table = new Hashtable();
// 线程1,线程2
if(table.get("key") == null) {
table.put("key", value);
}
无状态类线程安全:无状态类指的是没有成员变量的类
不可变类线程安全:String、Integer 等都是不可变类,内部的状态不可以改变,所以方法是线程安全
不可变类的设计:如果一个对象不能够修改其内部属性,则这个对象就是不可变对象。不可变对象是线程安全的,不存在并发修改和可见性问题
以String类为例,我们可以观察不可变类的设计:
- 类本身使用final修饰,保证该类中的方法不会被子类破坏
- 属性使用final修饰,保证该属性是只读的,不能被修改
- 不提供写入方法,保证外部不能对属性进行修改
- 在更改String类数据的时候,会构造新的字符串对象,生成新的
char[] value。这种通过创建对象来避免共享的方式称为保护性拷贝
线程活跃性
死锁
死锁产生的必要条件:
- 互斥条件:在一段时间内,某个资源只能由一个进程占用
- 请求和保持条件:进程保持至少一个资源,又提出了新的资源请求。请求阻塞的时候,对自己已经保持的资源保持不放
- 不剥夺条件:进程已经获得的资源,在未使用前不能被剥夺,只能在使用完之后自己释放
- 环路等待条件:在发生死锁的时候,必然存在两个或多个进程构成的环形链路,里面的每个进程都在等待下一个进程释放其占用的资源
死锁示例:
1 | |
死锁的定位:
- 使用
jps定位进程id,再用jstack id定位死锁,找到死锁的线程去查看源码 - 使用
jconsole工具进行检测死锁
活锁
活锁指的是任务并没有被阻塞,但是由于某些条件没有满足,导致一直重复尝试然后失败的过程。举例来说,下面的代码两个线程互相改变对方的结束条件,最后谁也没有办法结束
1 | |
- 这种情况下还是有可能结束的,和线程的调度有关,随机性很强
饥饿
饥饿:一个线程由于优先级太低,始终得不到 CPU 调度执行,也不能够结束