-
一、问题来源
-
1.1 分时系统与上下文切换
- 为了能够更加充分地利用CPU,引入分时系统对多线程进行实现,然而分时系统的上下文切换导致不同线程的指令交错,从而带来访问共享资源时的线程安全问题;
- 一个程序运行多个线程本身时没有问题的,问题出在多个线程访问共享资源;
- 在多个线程对共享资源读写操作时发生指令交错就会出现问题,如果只是读取的话没有问题;
-
1.2 临界区Critical Section
- 一段代码内如果存在对共享资源的多线程读写操作,称这段代码为临界区;
-
1.3 竞态条件Race Condition
- 多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称为发生了竞态条件;
-
1.4 解决问题——避免临界区的竞态条件发生
- 阻塞式解决方案:synchronized,Lock
- 非阻塞式解决方案:原子变量
-
-
二、synchronized解决方案(对象锁)
- 理解
- 采用互斥的方式让同一时刻至多只有一个线程能持有对象锁,其它线程再想获取这个对象锁时就会阻塞住。这样就能保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换;
- 加上对象锁之后临界区中的代码就变成串行的;
- 本质:利用对象锁保证了临界区代码的原子性;
- 代码格式
-
synchronized(锁对象){ //访问共享资源的核心代码 }
- 理解
-
- 加在方法上
- 语法
-
class Test{ public synchronized void test(){ } } 等价于 class Test{ public void test(){ synchronized(this){ } } }
-
class Test{ public synchronized static void test(){ } } 等价于 class Test{ public static void test(){ synchronized(Test.class){ } } }
- 加强:线程八锁问题
- 加在方法上
-
三、线程安全分析
-
3.1 成员变量和静态变量
- 如果没有被共享,则线程安全;
- 如果被共享
- 如果只有读操作,则线程安全;
- (成员变量和静态变量)如果有读写操作,则这段代码是临界区,需要考虑线程安全问题;
-
3.2 局部变量
- 如果是基本类型,则线程安全;
- 如果局部变量是引用类型
- 如果该对象没有逃离方法的作用范围,则线程安全;
- (局部变量引用类型)如果该对象逃离方法的作用范围,则需要考虑线程安全问题;
-
3.3 线程安全类
- 不可变类:String、Integer
- StringBuffer、Random、Vector、Hashtable、java.util.concurrent包下的类;
- 它们的每个方法都是原子的,但多个方法的组合不是原子的(组合方法对相同共享变量进行操作时);
-
3.4 分析过程
- 1. 找多个线程的共享变量;
- 2. 找是否存在对共享变量的读写操作,即临界区;
-
-
四、Monitor(监视器/管程)
-
4.1 Java对象头(以32位虚拟机为例)
- Header
- 普通对象(64 bits) = Mark Word(32 bits) + Klass Word(32 bits)
- 数组对象(96 bits) = Mark Word(32 bits) + Klass Word(32 bits) + array length(32 bits)
- Mark Word结构
- Header
-
4.2 Monitor与Java对象的关系
- Monitor对象是操作系统提供的;
- 当调用synchronized关键字(重量级锁)时,会将加锁的Java对象关联到一个Monitor对象上,该Java对象Header中的MarkWork就会被设置位指向Monitor对象的指针;
-
4.3 Monitor对象结构
- 结构图示
- Monitor对象管理加锁过程:
- (1)刚开始Monitor中的Owner为null;
- (2)当一个线程,如Thread-2执行到synchronized(obj)时,首先检查obj是否关联到一个Monitor对象,接着检查Monitor对象Owner属性是否设置,当Owner属性为null时,将Owner设置为Thread-2;
- (3)当其它线程(Thread-3, Thread-4)也执行到synchronized(obj)时,进行相同的检查动作,发现当前的Owner不为空,将Thread-3和Thread-4进入EntryList,线程进入阻塞状态;
- (4)当Thread-2执行完临界区代码,Monitor会通知EntryList中的线程来竞争锁,竞争是非公平的;
- 注意:
- 1.synchronized必须进入同一个对象的Monitor才有上述效果;
- 2.不加synchronized的对象不会关联monitor,不遵从以上规则;
-
4.4 进阶对象锁
- 🔅锁升级
- 由于Monitor是操作系统提供的,使用成本比较高,如果每次synchronized时都使用monitor,会对程序性能造成一定的影响;==》从直接使用重量级锁到使用轻量级锁、偏向锁优化;
- synchronized 是可重入、不公平的重量级锁,可进行优化;
- 锁升级过程:无锁 ---> 偏向锁 ---> 轻量级锁 ---> 重量级锁 【 随着竞争的增加,只能锁升级,不能降级】
- 🔅轻量级锁
- 使用场景:如果一个对象虽然有多个线程访问,但多线程访问的时间时错开的(此时没有竞争),那么可以使用轻量级锁来优化;
- 轻量级锁对使用者是透明的,语法仍然是synchronized;
- 加锁+锁重入流程
- 代码实例
- (1)当一个线程执行到第4行代码时,当前线程的栈帧会创建一个锁记录Lock Record对象,obj状态和线程栈帧状态如图;
- (2)执行到synchronized(obj)时,让锁记录中的Object reference指向obj,并尝试用cas将锁记录中的 [lock record 地址 + 00] 与 obj中的[MarkWord] 进行替换;
- (3)如果cas替换成功,obj的Header中存储了[lock record 地址 + 00] ,表示当前线程给对象加锁;
- (4) 如果cas失败:
- ➀如果是其它线程已经持有了该obj的轻量级锁,这时表明线程之间存在竞争,进入锁膨胀过程;
- ➁如果是自己执行了锁重入(如执行到第11行代码),那么再添加一条Lock Record作为重入的计数;
- (5)当退出 synchronized 代码块(解锁时)如果有取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重入计数减一;
- (6)当退出 synchronized 代码块(解锁时)锁记录的值不为 null,这时使用 cas 将 Mark Word 的值恢复给对象头 ;
- 成功,则解锁成功;
- 失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程;
- 🔅锁膨胀(将轻量级锁变为重量级锁)
- 当一个线程对obj尝试加轻量级锁的过程中,cas操作无法成功,且发现失败的原因是其它线程已经对obj加锁,这时需要进行锁膨胀;
- 锁膨胀的过程
- (1)出现竞争,例如当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁;
- (2)Thread-1加轻量级锁失败,进入锁膨胀流程:🔹为obj申请Monitor锁,让obj的Mark Work指向重量级锁地址;🔹自己进入Monitor的EntryList Blocked;
- (3)当 Thread-0 退出同步块解锁时,使用 cas 将 Mark Word 的值恢复给对象头,失败。这时会进入重量级解锁流程,即按照 Monitor 地址找到 Monitor 对象,设置 Owner 为 null,唤醒 EntryList 中 BLOCKED 线程;
- (1)出现竞争,例如当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁;
- 🔅自旋优化(竞争重量级锁时)
- 当竞争重量级锁时,如果锁已被占用,线程直接进入阻塞状态会导致上下文切换,耗费性能,此时使用自旋进行优化,如果持锁线程退出同步代码块,释放锁,则当前线程自旋成功,避免了当前线程进入阻塞;
- 自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
- 在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。
- Java 7 之后不能控制是否开启自旋功能;
- 🔅偏向锁
- 对轻量级锁进行优化,避免在没有竞争时每次重入都需要执行的CAS操作;
- 原理:只有第一次使用CAS操作将线程ID设置到对象头的MarkWord里,之后发现是同一线程使用锁就表示没有竞争,不需要重新CAS。只要没有竞争发生,就表明加锁对象归该线程所有;
- 偏向的特性
- 1.对象创建时,如果开启了偏向锁(默认开启),那么对象的MaekWord值为0x05,即最后三位为101,其它位全部为0;
- 2.偏向锁默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加 VM 参数 -XX:BiasedLockingStartupDelay=0 来禁用延迟;
- 3.如果对象创建时没有开启偏向锁,那么对象的MarkWord值为0x01,即最后三位为001,其它位全部为0,第一次使用hashcode时hashcode和age才会赋值;
- 4.正常状态对象一开始是没有 hashCode 的,第一次调用才生成,调用了 hashCode() 后会撤销该对象的偏向状态;
- 5.添加 VM 参数 -XX:-UseBiasedLocking 禁用偏向锁【如果使用场景是多线程竞争访问锁对象,偏向锁不适用,需要禁用偏向锁】
- 撤销偏向
- (1) 调用对象的hashcode()
- (2) 其它线程使用对象(时间片错开时)
- 升级为轻量级锁
- (3) 调用wait/notify
- 只有重量级锁支持wait/notify
- 批量重偏向
- 重偏向:当对象被多个线程访问但没有竞争时,偏向锁有机会不直接升级为轻量级锁,而是将当前对象进行重偏向,即不改变偏向状态并重置对象的ThreadID
- 触发批量重偏向:对某个类型的对象撤销偏向锁超过20次后,jvm会判定为该类型的对象发生偏向错误,因此在之后其它线程给该类型处于偏向状态的对象加锁时重偏向至新的加锁线程;
- 批量撤销
- 触发批量撤销偏向:对某个类型的对象撤销偏向锁超过40次后,jvm会判定为该类型的对象不应该偏向,因此该类型的所有对象都会变为不可偏向,新创建的该类型对象也不可偏向;
- 🔅锁消除
- JIT即时编译器会对字节码做进一步优化,当加多对象不是共享的时,即时编译器会做锁消除优化;
- 🔅锁升级
-
-
五、wait/notify
- 问题来源
- 【同步方法】当某个线程由于条件不满足而不能继续执行时,为了避免该线程一直占用CPU,使用wait()方法将当前线程加入WaitSet,当其他线程调用notify()方法时,wait的线程重新加入竞争锁的队列
- 底层原理
- Owner 线程发现条件不满足,调用 wait() 方法,即可进入 WaitSet 变为 WAITING 状态,释放锁;
- BLOCKED和WAITING的线程都处于阻塞状态,不占用 CPU 时间片;
- BLOCKED线程会在Owner线程释放锁时唤醒;
- WAITING 线程会在 Owner 线程调用 notify 或 notifyAll 时唤醒,但唤醒后并不意味者立刻获得锁,仍需进入EntryList 重新竞争;
- api
- obj.wait()
- 让Object的Owner线程加入WaitSet等待,释放对象锁;
- obj.notify()
- 让Object上正在WaitSet 等待的线程中挑一个唤醒
- obj.notifyAll()
- 让 object 上正在 WaitSet 等待的线程全部唤醒
- 它们都是线程之间进行协作的手段,都属于 Object 对象的方法。必须获得此对象的锁,才能调用这几个方法;
- obj.wait()
- 正确使用
- sleep(long n) vs. wait(long n)
- (1) sleep是Thread方法,wait是Object方法;
- (2) sleep不需要和synchronized配合使用,wait必须和synchronized一起使用;
- (3) sleep在睡眠的时候不会释放锁,wait等待的时候会释放对象锁,wait相当于当前线程的继续执行需要依赖其它某个线程执行完毕;
- (4) 线程状态都是TIMTED_WAITING
- tips
- 使用 notifyAll() + while循环 防止虚假唤醒
- 使用 notifyAll() + while循环 防止虚假唤醒
- sleep(long n) vs. wait(long n)
- 问题来源
-
六、park/unpark
- 使用方式
- 都是 LockSupport 类中的方法
- 可以先park再unpark,也可以先unpark再park
- 都是 LockSupport 类中的方法
- 特点(与Object的wait/notify比较)
- park/unpark不需要配合锁使用,而wait/notify必须配合重量级锁使用;
- park/unpark以线程为单位来阻塞和唤醒线程,比较精确,而notify随机唤醒一个wait线程,不那么精确;
- park & unpark 可以先 unpark,而 wait & notify 不能先 notify
- 原理(比喻)
- park相当于能量耗尽,检查是否有干粮,没干粮时暂停执行,有干粮时消耗干粮;
- unpark相当于补充干粮,最多补充一份,然后检查是否有干粮,但没暂停时不消耗干粮,暂停时消耗完干粮继续前进;
- unpark没调用过时,初始干粮状态是没有干粮;
- 使用方式
-
七、线程状态转换
- 解读线程状态转化图
- 单向箭头表示状态的单向转换,双向箭头表示状态之间可以互相转换
- 1: start()
- 2/3/4: wait-notify / join / LockSupport.park-unpark
- 5/6/7/8: wait(long n) / join(long n) / sleep(long n) / LockSupport.parkNanos(long nanos) 或 LockSupport.parkUntil(long millis)
- 9: 竞争锁失败--被唤醒后竞争锁成功
- 10: 当前线程所有代码运行完毕
-
八、多把锁
- 一把锁时会存在并发度太低的问题
- 解决方法:减小锁的粒度,多把锁增加并发度
-
九、线程的活跃性
- 由于各种原因导致线程一直无法执行完全部代码的现象
- 来源
- 1.死锁
- 多个线程多把锁,不同线程各自获得锁,同时想获得对方的锁时发生;
- eg:哲学家就餐问题
- 定位死锁
- 方法一:使用jconsole
- 方法二:使用jps查看java进程id,再用jstack pid可以定位死锁
- 解决死锁:破坏获取共享资源的顺序,使用顺序加锁的方式
- 2.活锁
- 两个线程互相改变对方的结束条件,导致线程无法结束
- eg:一个线程对共享资源自增,一个线程对共享资源自减
- 解决活锁:让执行时间交错,使两个线程的执行变得不平衡
- 3.饥饿
- 一个线程由于优先级太低,始终得不到 CPU 调度执行,也不能够结束
- eg: 用顺序加锁解决死锁问题时会导致线程饥饿
- 解决死锁和饥饿:使用ReentrantLock设置锁超时
- 1.死锁
-
十、ReentrantLock
- 基本语法
- 特点
- 可重入
- 同一线程可以多次获得同一把锁
- 可中断blocked状态
- lock.lockInterruptibly()
- 没有竞争时获得锁;
- 有竞争时进入阻塞队列,但blocked状态可以在其它线程调用当前线程的interrupt()方法打断,被动不会死等;
- 锁(可设置)超时
- lock.tryLock()
- 返回是否获得锁,获得不到也不会进入EntryList
- lock.tryLock(long, TimeUnit)
- 获得不到锁的话blocked一段时间,等待时间内可以获得锁
- 这个方法也支持可打断的特性
- lock.tryLock()
- 公平锁
- 唤醒时按进入阻塞队列的顺序获得锁,先到先得
- ReentrantLock lock = new ReentrantLock(true); 默认参数是false,非公平锁,阻塞队列中依然是公平的,被唤醒的线程会与新来的线程竞争;
- 公平锁一般没有必要,会降低并发度;
- 支持一把锁多个条件变量(即多个WaitSet)
- 避免了synchronized的wait/notify机制的虚假唤醒问题
- 使用
- step1: 创建一个ReentrantLock对象lock
- step2: (按需)创建条件变量,con1 = lock.newCondition()
- step3: 线程获得锁后不满足条件则等待,lock.lock(); -> con1.await();
- step4: 条件满足时,其它线程获得锁唤醒等待线程,con1.signal()或con1.singalAll();
- step5(*): 记得释放锁
- 可重入
- 基本语法