内容:Synchronized及底层原理;ReentrantLock
为了避免临界区的竞争条件发生,有两种方法:
阻塞式:synchronized, Lock(ReentrantLock对象) 非阻塞式:原子变量
两种用法:锁住代码块 或者 加在方法上,当加在方法上时:
加在普通方法上等效于: synchronized(this){} 本质是锁 this实例对象
加在静态方法上等效于: synchronized(类.class){} 本质是锁 类对象
成员变量和静态变量:看该变量是否共享,且是否有读写操作(如果只有读操作,则线程安全)
局部变量:线程安全(因为栈帧是每个线程私有的);但是如果局部变量引用了同一个堆对象,不安全。
如果子类继承了父类,也可能引起线程安全问题。所以private和final的修饰很重要(闭合原则),可以防止子类调用父类对象引起的线程安全问题。
String;Integer等包装类;StringBuffer;Random;Vector;Hashtable;java.util.concurrent包下的类
String,Integer等都属于不可变类,都是线程安全的。
eg. String为什么是final类,为了防止子类覆盖引用父类,使得线程不安全
相对于Synchronized的优点:可中断,支持锁超时,支持公平锁,支持多个条件变量;同时与synchronized一样都支持可重入
基本语法:lock()加锁, unlock()解锁
reentrantLock.lock(); //获取锁
try{
//临界区
}finally{
reentrantLock.unlock(); //释放锁
}
(1)可重入:指同一个线程可重复递归调用锁。
(2)可打断:在阻塞队列等待的过程中可以被其他线程调用interrupt()方法打断。
private static ReentrantLock lock = new ReentrantLock();
public static void main(String[] args){
new Thread(() -> {
try{
//如果没有竞争,此方法会获取Lock对象的锁
//如果有竞争,会进入阻塞队列,可以被其他线程用interrupt方法打断
lock.lockInterruptibly();
}catch(InterruptedException e){
e.printStackTrace();
}
}).start();
}
(3)锁超时:设置线程等待时间。tryLock(等待时间,时间单位)或tryLock()方法
(4)公平性:synchronized会在阻塞队列中随机选取一个,而不是先来先到顺序,因此是不公平锁。
ReentrantLock默认也是不公平锁,但可以设置成公平锁:ReentrantLock lock = new ReentrantLock(true)
(5)条件变量:synchronized中也有条件变量:线程满足条件时继续执行,不满足条件时进入waitList等待
ReentrantLock支持多个条件变量(waitList)
static ReentrantLock lock = new ReentrantLock();
public void static main(String[] args){
Condition condition1 = lock.newCondition(); //创建两个条件变量
Condition condition2 = lock.newCondition();
lock.lock();
condition1.await(); //效果等同于wait()
}
其他线程可以用condition1.signal()或者condition1.signalAll()方法唤醒
(1)死锁:一个线程同时需要获取多把锁,容易产生死锁
eg1. Thread1已经获得了A对象锁,接下来想获得B对象锁
Thread2已经获得了B对象锁,接下来想获得A对象锁
此时发生死锁
eg2. 哲学家就餐问题
定位死锁方法:jconsole工具或者先用jps定位线程ID,再用jstack定位死锁
(2)活锁:两个线程互相改变对方的结束条件,最后谁也无法结束
static volatile int count = 10;
new Thread(() -> {
while(count > 0){
count--;
}
}).start();
new Thread(() -> {
while(count < 20){
count++;
}
}).start();
(3)饥饿:一个线程由于优先级太低,始终得不到CPU调度执行,无法结束
互斥:使用synchronized或者lock达到共享资源互斥,代码原子性
同步:使用wait/notify或者Lock的条件变量完成线程间通信
每个对象都有对象头:
eg. Integer对象大小为:8byte(对象头)+4byte(数据)+4byte(自动补齐) = 16字节
int大小为:4byte = 4字节
对象头:以32位虚拟机为例。MarkWord:存储该对象的信息,KlassWord:指针指向该对象的类型
普通对象:MarkWord(32bits) + KlassWord(32bits) 共64bits8个字节
数组对象:MarkWord(32bits) + KlassWord(32bits) + arraylength(32bits)
MarkWord具体细节(下图):以Normal为例,分别记录:hashcode值,垃圾回收分代年龄,是否是偏向锁,锁标志位
Monitor也称监视器或者管程。每个java对象都可以关联一个Monitor对象(该对象是操作系统提供的),MarkWord会改变。
注:如果加在静态方法上,则 类.class 类对象也是对象。
注:如果不加synchronized则不会关联monitor
字节码角度:
对象加锁前:会先执行dup,复制该对象(包含原始MarkWord等信息)到变量槽slot中。
加锁:执行monitorenter,将lock对象MarkWord置为Monitor指针。
执行完毕后解锁:从slot取出原始信息恢复原来MarkWord信息,唤醒Entrylist
(1)轻量级锁
使用场景:多线程的访问时间是错开的(无竞争)
步骤:
首先栈帧中会创建锁记录(Lock Record)对象,锁记录中包含:该对象的地址 和 锁记录
加锁时,会把引用地址指向该对象,并尝试用CAS把锁记录与该对象的MarkWord做交换,如下图,该对象由的锁标志位由01(无锁)变成00(轻量级锁),表示已经加锁。
如果CAS失败,有两种情况:
如果其他线程已经持有了该Object的轻量级锁,表示有竞争,进入锁膨胀
如果是自己执行了synchronized重入,那么再添加一条锁记录作为锁重入的记数。
锁重入时会再添加一条锁记录(因为是自己调用,在同一栈帧中),内容为null。解锁时会通过是否为null判断是否结束,如果是null则表示有锁重入,继续解锁;如果不是null则需将MarkWord再恢复给对象头。(成功:说明解锁成功;失败:轻量级锁已经锁膨胀了,需要重量级锁解锁)
(2)锁膨胀
有竞争时会锁膨胀,即加锁时发现对象头的Markword已经变成00。如下图,Thead-1尝试加锁,发现锁已经被Thread-0占用。
Thead-1加锁失败,进行锁膨胀:首先thead-1会为该Object对象申请Monitor锁,让Object指向重量级锁地址(即Monitor对象),Monitor会记录原来该对象的信息,然后自己进入Monitor的EntryList BLOCKED。
注:此时Markword为 monitor地址(30bits) + 10(2bit)
当Thead-0执行完退出时,会执行解锁。但发现此时Object内的锁标志位不再是00(轻量级),而是10(重量级)。因此会进入重量级解锁流程,先找到Monitor对象,设置Owner为null,唤醒EntryList中的BLOCKED线程。
(3)自旋优化
在重量级锁竞争时,可以用自旋进行优化。当新线程Thead-2想继续加锁时,发现对象头的锁标志位是10(重量级),它会先尝试自旋而不是立即阻塞。
注:JAVA6之后自旋是自适应的,即如果刚刚自旋成功,则会认为现阶段自旋成功可能性高,多自旋几次。反之少自旋。
成功情况:
失败情况:
(4)偏向锁
在轻量级锁加锁的过程中,每次锁重入仍然需要执行CAS操作。即尝试用锁记录去替换MarkWord,但是发现原先记录属于同一个线程,因此替换失败。(尽管失败,仍然每次都会开销)。因此在JAVA6中引入偏向锁对轻量级锁的锁重入进行优化。
偏向锁:第一次使用CAS将该线程ID记录到对象的MarkWord中(取代了原来的锁记录)。
优化前:
优化后:
总结:
无竞争时,如果开启了偏向锁,会优先加偏向锁;当锁未释放其他线程来竞争时,会撤销偏向锁变成重量级锁。当锁已释放其他线程来加锁时,会撤销偏向锁变成轻量级锁。
无竞争时,如果没开启偏向锁,会加轻量级锁;当其他线程来竞争时,轻量级锁变成重量级锁
偏向锁撤销情况:
hashcode():如果开启了偏向锁,一开始的hashcode和age不会存在(这些位都用来记录threadID了)。此时如果调用hashcode方法,会撤销偏向锁
其他线程对该对象加锁:如果此时已经解锁则转轻量级锁;如果此时没有解锁存在竞争转重量级锁。
wait/notify方法:只有重量级锁才有,会转重量级锁