四、共享模型的管理
一个线程五千次自增,一个线程五千次自减。
结果可能是正数、负数、零。为什么呢?因为 Java 中对静态变量的自增,自减并不是原子操作,要彻底理解,必须从字节码来进行分析。
例如对于 i++ 而言(i 为静态变量),实际会产生如下的 JVM 字节码指令:
临界区
一个程序运行多个线程本身是没有问题的,问题出在多个线程访问共享资源。
多个线程读共享资源其实也没有问题。
在多个线程对共享资源读写操作时发生指令交错,就会出现问题
一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区
竞态条件
多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件
为了避免临界区的竞态条件发生,有多种手段可以达到目的。
1.阻塞式的解决方案:synchronized,Lock
2.非阻塞式的解决方案:原子变量
4.2synchronized解决方案
虽然 java 中互斥和同步都可以采用 synchronized 关键字来完成,但它们还是有区别的:
1.互斥是保证临界区的竞态条件发生,同一时刻只能有一个线程执行临界区代码
2.同步是由于线程执行的先后、顺序不同、需要一个线程等待其它线程运行到某个点
1.synchronized(对象) 中的对象,可以想象为一个房间(room),有唯一入口(门)房间只能一次进入一人进行计算,线程 t1,t2 想象成两个人
2.当线程 t1 执行到 synchronized(room) 时就好比 t1 进入了这个房间,并锁住了门拿走了钥匙,在门内执行count++ 代码
3.这时候如果 t2 也运行到了 synchronized(room) 时,它发现门被锁住了,只能在门外等待,发生了上下文切换,阻塞住了
4.这中间即使 t1 的 cpu 时间片不幸用完,被踢出了门外(不要错误理解为锁住了对象就能一直执行下去哦),这时门还是锁住的,t1 仍拿着钥匙,t2 线程还在阻塞状态进不来,只有下次轮到 t1 自己再次获得时间片时才能开门进入
5.当 t1 执行完 synchronized{} 块内的代码,这时候才会从 obj 房间出来并解开门上的锁,唤醒 t2 线程把钥匙给他。t2 线程这时才可以进入 obj 房间,锁住了门拿上钥匙,执行它的 count-- 代码
synchronized 实际是用对象锁保证了临界区内代码的原子性,临界区内的代码对外是不可分割的,不会被线程切换所打断。
我的理解:线程上下文切换,别的线程无法获取锁,只能blocked。
被synchronized包裹的代码具有原子性。
如果 t1 synchronized(obj1) 而 t2 synchronized(obj2) 会怎样运作?
不可以,如果要保护共享资源,要对同一个对象加锁,不能对不同对象加锁,否则两者互不影响。
如果 t1 synchronized(obj) 而 t2 没有加会怎么样?如何理解?
如果线程1运行时,线程2不尝试获取锁,也就不会被阻塞。(对线程2来说,这个room不加锁)
面向对象改进
把需要保护的共享变量放入一个类
用this来完成
同时获取值的时候也需要加锁
4.3方法上的synchronized
4.4变量的线程安全
成员变量和静态变量是否线程安全?
1.如果它们没有共享,则线程安全
2.如果它们被共享了,根据它们的状态是否能够改变,又分两种情况
如果只有读操作,则线程安全
如果有读写操作,则这段代码是临界区,需要考虑线程安全
局部变量是否线程安全?
1.局部变量是线程安全的
2.但局部变量引用的对象则未必(局部对象引用堆中对象)
如果该对象没有逃离方法的作用访问,它是线程安全的
如果该对象逃离方法的作用范围,需要考虑线程安全
局部变量的字节码与静态变量不同
静态一个有getstatic ,add但局部变量直接iinc,
每个线程调用 test1() 方法时局部变量 i,会在每个线程的栈帧内存中被创建多份,因此不存在共享.
但当局部变量引用的是对象,就不同了。
执行
如果线程2 还未 add,线程1 remove 就会报错
无论哪个线程中的 method2 引用的都是同一个对象中的 list 成员变量
method3 与 method2 分析相同
改进方法:将list修改为局部变量
list 是局部变量,每个线程调用时会创建其不同实例,没有共享。
而 method2 的参数是从 method1 中传递过来的,与 method1 中引用同一个对象。
method3 的参数分析与 method2 相同。(局部变量 线程内独享)
有其它线程调用 method2 和 method3时,不会有问题(相当于别的线程引用这个方法,用它自己的list局部变量)
但在以上的基础上,为 ThreadSafe 类添加子类,子类覆盖 method2 或 method3 方法,子类重写方法,重启一个线程调用局部变量引用的对象,就会有问题。(method3对于新线程和旧线程来说是一个共享资源,子类重写来新建线程,引用的还是list,就会暴露list给其它线程,使线程不安全。)
看局部变量是否暴露
常见的线程安全类
String
Integer
StringBuffer
Random
Vector
Hashtable
java.util.concurrent 包下的类(JUC)
(源码有synchronize)
这里说它们是线程安全的是指,多个线程调用它们同一个实例的某个方法时,是线程安全的。
它们的每个方法是原子的,但注意它们多个方法的组合不是原子的。(如果需要在外部在加一个线程保护)
不可变类线程安全性
String、Integer 等都是不可变类,因为其内部的状态不可以改变,因此它们的方法都是线程安全的
String 有 replace,substring 等方法【可以】改变值啊,那么这些方法又是如何保证线程安全的呢?
通过return new String.
4.6Monitor
Java对象头
32位虚拟机:int 4字节 Integer 8 字节 + 4 字节(value)
Monitor原理
Monitor 被翻译为监视器或管程
每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的Mark Word 中就被设置指向 Monitor 对象的指针
Monitor 结构如下 (synchronized(obj)一个这样的锁对应一个monitor )
1.刚开始 Monitor 中 Owner 为 null
2.当 Thread-2 执行 synchronized(obj) 就会将 Monitor 的所有者 Owner 置为 Thread-2,Monitor中只能有一个 Owner
3.当Thread-2 上锁的过程中,如果 Thread-3,Thread-4,Thread-5 也来执行 synchronized(obj),就会进入EntryList BLOCKED
4.Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争的时是非公平的
5.图中 WaitSet 中的 Thread-0,Thread-1 是之前获得过锁,但条件不满足进入 WAITING 。
Synchronized
1. 轻量级锁
轻量级锁的使用场景:如果一个对象虽然有多线程要加锁,但加锁的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。
轻量级锁对使用者是透明的,即语法仍然是 synchronized.
假设有两个方法同步块,利用同一个对象加锁。
创建锁记录(Lock Record)对象,每个线程都的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的Mark Word
让锁记录中 Object reference 指向锁对象,并尝试用 cas 替换 Object 的 Mark Word,将 Mark Word 的值存入锁记录。
如果 cas 替换成功,对象头中存储了锁记录地址和状态 00 ,表示由该线程给对象加锁,这时图示如下
如果 cas 失败,有两种情况
1.如果是其它线程已经持有了该 Object 的轻量级锁,这时表明有竞争,进入锁膨胀过程
2.如果是自己执行了 synchronized 锁重入,那么再添加一条 Lock Record 作为重入的计数
当退出 synchronized 代码块(解锁时)如果有取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重入计数减一。
当退出 synchronized 代码块(解锁时)锁记录的值不为 null,这时使用 cas 将 Mark Word 的值恢复给对象头
成功,则解锁成功
失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程
锁膨胀
如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。
当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁
这时 Thread-1 加轻量级锁失败,进入锁膨胀流程
即为 Object 对象申请 Monitor 锁,让 Object 指向重量级锁地址
然后自己进入 Monitor 的 EntryList BLOCKED
当 Thread-0 退出同步块解锁时,使用 cas 将 Mark Word 的值恢复给对象头,失败。这时会进入重量级解锁 流程,即按照 Monitor 地址找到 Monitor 对象,设置 Owner 为 null,唤醒 EntryList 中 BLOCKED 线程。
自旋优化
重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。
1.自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
2.在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。
3.Java 7 之后不能控制是否开启自旋功能
偏向锁
轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。
Java 6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现这个线程 ID 是自己的就表示没有竞争,不用重新 CAS。以后只要不发生竞争,这个对象就归该线程所有
偏向状态
Biased_lock为0 表示没有启用偏向锁,1为启用了偏向锁
一个对象创建时:
1.如果开启了偏向锁(默认开启),那么对象创建后,markword 值为 0x05 即最后 3 位为 101,这时它的thread、epoch、age 都为 0。
2.偏向锁是默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加 VM 参数 -XX:BiasedLockingStartupDelay=0 来禁用延迟。
3.如果没有开启偏向锁,那么对象创建后,markword 值为 0x01 即最后 3 位为 001,这时它的 hashcode、age 都为 0,第一次用到 hashcode 时才会赋值。
撤销-调用对象hashcode
调用了对象的 hashCode,但偏向锁的对象 MarkWord 中存储的是线程 id,如果调用 hashCode 会导致偏向锁被撤销
轻量级锁会在锁记录中记录 hashCode
重量级锁会在 Monitor 中记录 hashCode
在调用 hashCode 后使用偏向锁,记得去掉 -XX:-UseBiasedLocking
撤销-其它线程使用对象
当有其它线程使用偏向锁对象时,会将偏向锁升级为轻量级锁。
同一个线程反复申请同一个锁,会让锁变为偏向锁
不同线程申请其它线程的偏向锁,会清除mark word并将锁升级为轻量级锁
批量重偏向
如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程 T1 的对象仍有机会重新偏向 T2,重偏向会重置对象的 Thread ID。
当撤销偏向锁阈值超过 20 次后,jvm 会这样觉得,我是不是偏向错了呢,于是会在给这些对象加锁时重新偏向至加锁线程。(从偏向t1变成偏向t2)
批量撤销
当撤销偏向锁阈值超过 40 次后,jvm 会这样觉得,自己确实偏向错了,根本就不该偏向。于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的。
锁消除
加入锁消除,性能会变好
Wait notify
1.Owner 线程发现条件不满足,调用 wait 方法,即可进入 WaitSet 变为 WAITING 状态.
2.BLOCKED 和 WAITING 的线程都处于阻塞状态,不占用 CPU 时间片.
3.BLOCKED 线程会在 Owner 线程释放锁时唤醒.
4.WAITING 线程会在 Owner 线程调用 notify 或 notifyAll 时唤醒,但唤醒后并不意味者立刻获得锁,仍需进入EntryList 重新竞争。
(Blocked是正在等待锁的线程,waiting是已经获得了锁,又放弃了锁,进入到waiting set)
API介绍
obj.wait() 让进入 object 监视器的线程到 waitSet 等待 (有锁才能调用wait)
obj.notify() 在 object上正在 waitSet 等待的线程中挑一个唤醒
obj.notifyAll() 让 object 上正在 waitSet 等待的线程全部唤醒
它们都是线程之间进行协作的手段,都属于 Object 对象的方法。必须获得此对象的锁,才能调用这几个方法
1.wait() 方法会释放对象的锁,进入 WaitSet 等待区,从而让其他线程就机会获取对象的锁。无限制等待,直到notify 为止
2.wait(long n) 有时限的等待, 到 n 毫秒后结束等待,或是被 notify
Wait和notify
sleep(long n) 和 wait(long n) 的区别
1) sleep 是 Thread 方法,而 wait 是 Object 的方法
2) sleep 不需要强制和 synchronized 配合使用,但 wait 需要和 synchronized 一起用
3) sleep 在睡眠的同时,不会释放对象锁的,但 wait 在等待的时候会释放对象锁
4) 它们状态 TIMED_WAITING
用while可以循环判定条件,防止虚假唤醒。
Park和unpark
它们是 LockSupport 类中的方法,用来暂停当前线程和恢复当前线程
特点
与 Object 的 wait & notify 相比
1.wait,notify 和 notifyAll 必须配合 Object Monitor 一起使用,而 park,unpark 不必
2.park & unpark 是以线程为单位来【阻塞】和【唤醒】线程,而 notify 只能随机唤醒一个等待线程,notifyAll 是唤醒所有等待线程,就不那么【精确】
3.park & unpark 可以先 unpark,而 wait & notify 不能先 notify
原理
每个线程都有自己的一个 Parker 对象,由三部分组成 _counter , _cond 和 _mutex 打个比喻
1.线程就像一个旅人,Parker 就像他随身携带的背包,条件变量就好比背包中的帐篷。_counter 就好比背包中的备用干粮(0 为耗尽,1 为充足)
2.调用 park 就是要看需不需要停下来歇息
如果备用干粮耗尽,那么钻进帐篷歇息
如果备用干粮充足,那么不需停留,继续前进
3.调用 unpark,就好比令干粮充足
如果这时线程还在帐篷,就唤醒让他继续前进
如果这时线程还在运行,那么下次他调用 park 时,仅是消耗掉备用干粮,不需停留继续前进
因为背包空间有限,多次调用 unpark 仅会补充一份备用干粮
即使先调用unpark,后调用park,也可以放行。
1. 当前线程调用 Unsafe.park() 方法
2. 检查 _counter ,本情况为 0,这时,获得 _mutex 互斥锁
3. 线程进入 _cond 条件变量阻塞
4. 设置 _counter = 0
1. 调用 Unsafe.unpark(Thread_0) 方法,设置 _counter 为 1
2. 唤醒 _cond 条件变量中的 Thread_0
3. Thread_0 恢复运行
4. 设置 _counter 为 0
1. 调用 Unsafe.unpark(Thread_0) 方法,设置 _counter 为 1
2. 当前线程调用 Unsafe.park() 方法
3. 检查 _counter ,本情况为 1,这时线程无需阻塞,继续运行
4. 设置 _counter 为 0
线程状态转换
多把锁
多把不相干的锁
一间大屋子有两个功能:睡觉、学习,互不相干。
现在小南要学习,小女要睡觉,但如果只用一间屋子(一个对象锁)的话,那么并发度很低
解决方法是准备多个房间(多个对象锁)
将锁的粒度细分
好处,是可以增强并发度。
坏处,如果一个线程需要同时获得多把锁,就容易发生死锁。
活跃性
死锁
有这样的情况:一个线程需要同时获取多把锁,这时就容易发生死锁。
t1 线程获得 A对象锁,接下来想获取 B对象的锁,
t2 线程 获得 B对象锁,接下来想获取 A对象的锁。
定位死锁
检测死锁可以使用 jconsole工具,或者使用 jps 定位进程 id,再用 jstack 定位死锁。
避免死锁要注意加锁顺序。
另外如果由于某个线程进入了死循环,导致其它线程一直等待,对于这种情况 linux 下可以通过 top 先定位到CPU 占用高的 Java 进程,再利用 top -Hp 进程id 来定位是哪个线程,最后再用 jstack 排查。
活锁
活锁出现在两个线程互相改变对方的结束条件,最后谁也无法结束,例如
饥饿
很多教程中把饥饿定义为,一个线程由于优先级太低,始终得不到 CPU 调度执行,也不能够结束,饥饿的情况不易演示,讲读写锁时会涉及饥饿问题。
死锁:
解决方法:顺序加锁
都通过先获得锁A,再获得锁B的方式(可以解决哲学家就餐问题)
但这样方式容易引发饥饿问题。(但有的线程没有死锁,但获得锁的机会或时间太小了,比如有的哲学家吃不到饭,有人猛吃)
Reentrantlock
相对于 synchronized 它具备如下特点
1.可中断
2.可以设置超时时间(可以放弃争抢锁)
3.可以设置为公平锁(防止线程饥饿,先到先得)
4.支持多个条件变量(多个waiting set,不会像synchronized一样一次都唤醒)
与 synchronized 一样,都支持可重入
可重入
可重入是指同一个线程如果首次获得了这把锁,那么因为它是这把锁的拥有者,因此有权利再次获取这把锁,如果是不可重入锁,那么第二次获得锁时,自己也会被锁挡住
解锁 lock.unlock()方法要调用try catch模块
可打断
Lock.lock()方法不可打断,所以要使用lock.lockInterruptibly()方法
如果没有竞争,那么此方法会获得lock对象锁。
如果有竞争,就进入阻塞队列,可以被其它线程用interrupt方法打断。
锁超时
立刻失败:
超时失败:
公平锁
ReentrantLock 默认是不公平的
公平锁一般没有必要,会降低并发度
条件变量
synchronized 中也有条件变量,就是我们讲原理时那个 waitSet 休息室,当条件不满足时进入 waitSet 等待。
ReentrantLock 的条件变量比 synchronized 强大之处在于,它是支持多个条件变量的,这就好比:
1.synchronized 是那些不满足条件的线程都在一间休息室等消息。
2.而 ReentrantLock 支持多间休息室,有专门等烟的休息室、专门等早餐的休息室、唤醒时也是按休息室来唤醒。
使用要点:
await 前需要获得锁。
await 执行后,会释放锁,进入 conditionObject 等待。
await 的线程被唤醒(或打断、或超时)取重新竞争 lock 锁。
竞争 lock 锁成功后,从 await 后继续执行。
进入吸烟室休息(进入吸烟waiting set) |
创建休息室(waiting set) |
叫醒线程 |
Await和singal就是类似于synchronized的wait notify