多线程-Sync
by shihang.mai
1. sync的基本用法
当sync所有的代码时=sync方法
public class T {
private int count = 10;
public synchronized void m() {
count--;
System.out.println(Thread.currentThread().getName() + " count = " + count);
}
//上下方法等同
public void m() {
synchronized(this){
count--;
System.out.println(Thread.currentThread().getName() + " count = " + count);
}
}
}
public class T {
private static int count = 10;
public static synchronized void m() {
count--;
System.out.println(Thread.currentThread().getName() + " count = " + count);
}
//上下方法等同
public static void mm() {
synchronized(T.class) {
count --;
System.out.println(Thread.currentThread().getName() + " count = " + count);
}
}
}
以下代码
public class Account {
String name;
double balance;
public synchronized void set(String name, double balance) {
this.name = name;
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
this.balance = balance;
}
public /*synchronized*/ double getBalance(String name) {
return this.balance;
}
public static void main(String[] args) {
Account a = new Account();
new Thread(()->a.set("zhangsan", 100.0)).start();
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(a.getBalance("zhangsan"));
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(a.getBalance("zhangsan"));
}
}
- 不打开getBalance()的synchronized,结果会是0,100
- 打开getBalance()的synchronized,结果会是100,100
以上现象原因: - 不打开getBalance()的synchronized,main线程调用getBalance()可以直接获取balance的值,此时balance的值为0
- 当打开getBalance()的synchronized,main线程调用getBalance()时,相当于synchronized(this,当前的account对象),而当前的account对象锁被 线程 持有,故getBalance()必须等待线程完成后,再执行
当sync加在静态方法,锁的是Class对象。当sync加载实例方法,锁的是对象本身
2. CAS
CAS:compare and swap/compare and exchange
1. 两个线程分别对内存中的N+1
2. 线程拉取内存的N,计算完后,往内存中写回时,对比一下E是否等于N
3. E==N?写回:重新拉取计算
CAS
2.1 CAS的ABA问题
1. 线程3拉取N=5,E=5
2. cpu切换到线程1,拉取N+1,然后写回内存,此时内存的N=6
3. cpu切换到线程2,拉取N-1,然后写回内存,此时内存的N=5
4. cpu切换回线程3,E=5,计算E+1=6,然后写回内存,E=N,写回
5. 这里就有问题了,其实N已经经历了线程1和线程2的过程,这就是典型的ABA问题
ABA
只需加上版本号,每改一次+1,这样就能解决上面的ABA问题
1. 线程3拉取N=5,E=5,version=0
2. cpu切换到线程1,拉取N+1,然后写回内存,此时内存的N=6,version=1
3. cpu切换到线程2,拉取N-1,然后写回内存,此时内存的N=5,version=2
4. cpu切换回线程3,E=5,计算E+1=6,然后写回内存,E=N,但是version不一样,重新计算
jdk中提供了一个类解决ABA问题的类,AtomicStampedReference
,使用如下
//2个参数,分别是初始值,初始版本
static AtomicStampedReference<Integer> ai = new AtomicStampedReference<>(4,0);
public static void main(String[] args) {
new Thread(() -> {
//四个参数分别是预估内存值,更新值,预估版本号,初始版本号
//只有当预估内存值==实际内存值相等并且预估版本号==实际版本号,才会进行修改
boolean b = ai.compareAndSet(4, 5,0,1);
System.out.println(Thread.currentThread().getName()+"是否成功将ai的值修改为5:"+b);
try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}
b = ai.compareAndSet(5,4,1,2);
System.out.println(Thread.currentThread().getName()+"是否成功将ai的值修改为4:"+b);
},"A").start();
new Thread(() -> {
try {Thread.sleep(5000);} catch (InterruptedException e) {e.printStackTrace();}
boolean b = ai.compareAndSet(4, 10,0,1);
System.out.println(Thread.currentThread().getName()+"是否成功将ai的值修改为10:"+b);
},"B").start();
while (Thread.activeCount() > 2) {
Thread.yield();
}
System.out.println("ai最终的值为:"+ai.getReference());
}
2.2 CAS的实现细节
下面用AtomicInteger.incrementAndGet()说明,下面是它的方法调用
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
本人能力有限,下面引用大佬的几张图
image
看到上面的native方法继续会调用Atomic::cmpxchg,而对于不同的内核是不同的实现,我们只看linux的实现
image
//如果是多核处理器,那么返回1,否则返回0
int mp = os::is_Mp();
//如果是多核处理器,那么在汇编指令cmpxchg前加入lock
LOCK_IF_MP()
下面来一个图说明以上所有的步骤
CAS的实现细节.png
3. sync实现细节
在字节码层面
-
当sync+在方法上
//在访问标志中增加了SYNCHRONIZED标识 Flags: PUBLIC, SYNCHRONIZED
-
当sync+在代码块上
sync字节码层面
可以看到出现了1个monitorenter和2个monitorexit
,其中前后一个为一堆很好理解,中间的monitorexit是为了异常加的
3.1 sync锁升级
jdk早期,sync是重量级锁,申请锁资源要用户态内核态切换,即0x80中断。jdk后期,sync有锁升级
首先来看markword记录的信息,其中记录了锁信息
markword锁 锁升级
-
new
- 偏向锁未启动,new的时候就是普通的对象
- 启动偏向锁,new的时候匿名偏向。
ps:jvm启动时,会有很多线程竞争(对象分配内存),所以默认不打开,过一段时间(默认4秒,可用-xx:biasedLockingStratDelay=0设置)再打开
-
普通对象(锁标志位:001)
- 偏向锁未启动,对对象上锁sync,那么立刻转为轻量级锁,即自旋锁
- 偏向锁启动了,加锁就会变为偏向锁
-
匿名偏向(锁标志位:101)
概念:没指向偏向那个线程,所以叫匿名偏向.
当向对象上sync锁,就会变为偏向锁
-
偏向锁(锁标志位:101)
- 那个线程先来,我就偏向它。当我第一个线程执行sync代码块时,先将线程地址记录到被锁对象markword上(偏向锁出现原因:工业实践,大多时间都是同一个线程访问一个sync代码块)
- 当一个线程竞争锁,CAS替换markword的线程地址,如果成功,那么就重偏向了这个线程。如果失败,那么就会进行偏向锁撤销。撤销撤销过程非常麻烦,它要求持有偏向锁的线程到达safe point,再将偏向锁替换成轻量级锁。正因为撤销的代价大,所有做了一个
epoch,批量重偏向和批量撤销
- 多个线程,重度竞争时,直接调用wait(),变为重量级锁
4.1 epoch解析
看下面2种场景
-
一个线程创建了大量对象并执行了初始的同步操作,之后在另一个线程中将这些对象作为锁进行之后的操作。这种case下,会导致大量的偏向锁撤销操作。
-
存在明显多线程竞争的场景下使用偏向锁是不合适的,例如生产者/消费者队列
批量重偏向解决case1,批量撤销解决case2
如上图所示,我们来逐步分析不同颜色的线。
锁对象所属的类会记录一个epoch值
- 对于黑色线:当多个线程都对该类的锁对象发生了偏向锁撤销,每撤销一次,epoch= epoch+1。
-
对于蓝色线: 当类的epoch值超过默认值20时,发生
批量重偏向
,会将这个值复制到当前线程栈在使用偏向锁的对象上。即:假如当前只有线程A在使用偏向锁对象A,那么就会把类的epoch值复制到锁对象A的epoch - 对于绿色线: 当发生下一次批量重偏向,与上一次批量重偏向时间是否超过了默认25s,如果是,类的epoch重置。
-
对于紫色线: 和绿色线一起看,意思就是在25秒内,类的epoch值超过了默认40,发生
批量撤销
,那么jvm就认为这个类的锁对象不适合偏向锁,批量撤销这个类的所有偏向锁,变为轻量级锁。并且在之后的加锁,直接加轻量级锁
- 轻量级锁,自旋锁(锁标志位:00)
各个线程在自身的线程栈中生成一个LR(lock record),各个线程竞争锁,竞争到锁的线程就是它的LR记录到被锁对象的markword上,其他线程继续CAS竞争。当重入时,会再多一个LR。实际上LR的生成,只要进入同步块即有,只是偏向锁没用,它记录了当前mark word的快照
竞争加剧,变为重量级锁,条件:
- 1.6之前:有线程超过10次自旋,次数可调整,-xx:PreBlockSpin。自旋线程数超过CPU核数的一半
- 1.6后加入了自适应自旋,jvm自己控制
-
重量级锁(锁标志位:10)
markword上记录的是ObjectMonitor的指针(JVM中用C++写的类),它其中有几个核心的属性
ObjectMonitor() {
//记录个数,重入锁
_count = 0;
//当前获取锁的线程
_owner = NULL;
//处于wait状态的线程,会被加入到_WaitSet
_WaitSet = NULL;
//处于等待锁block状态的线程,会被加入到该列表
_EntryList = NULL ;
}
在字节码的时候,会通过monitorenter表示sync代码开始,即进入锁竞争,monitorexit表示"释放锁",每次_count的值-1,当变为0时才是真正释放锁
monitor依赖操作系统的Mutex Lock实现,操作系统实现线程之间的切换时需要从用户态转换到内核态,所以早期sync才说是重量级锁
3.1.1 为什么有自旋锁还要重量级锁
首先自旋要消耗CPU资源的,如果锁的时间长,或者自旋的线程多,CPu会被大量消耗
ObjectMonitor中有一个waitSet属性,里面存放了所有申请锁而获取不到的线程,线程调度基于系统。有了waitSet,那么就不消耗CPU资源
3.1.2 偏向锁是否一定比自旋锁效率高
不一定,在明确知道会有多线程竞争情况下,偏向锁肯定会涉及锁撤销,这时应该直接使用自旋锁。例如:jvm启动时,会有很多线程竞争(对象分配内存),所以默认不打开,过一段时间再打开,默认4秒(-xx:biasedLockingStratDelay)
3.1.3 偏向锁存在的意义
- 偏向锁是为那些历史遗留的Collection类如Vector和Hashtable等类做的优化,它们里面方法都加了sync,
- 如果它们用在没有竞争的情况下使用Vector,却需要付出不停的加锁解锁的代价,如果使用偏向锁,这个代价就比较低了
- 偏向锁的撤销的代价太夸张,需要进入safepoint,如果真的竞争很激烈的多线程程序,一开始就关掉可能更好
- jdk15已经开始逐步删除
3.2 可重入锁
当一个线程获取某个对象锁时候,其他线程会被阻塞,那么当前线程再次获取这个对象锁时是不会被阻塞的,就叫这个锁为可重入锁
public class T {
synchronized void m() {
System.out.println("m start");
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("m end");
}
public static void main(String[] args) {
new TT().m();
}
}
class TT extends T {
@Override
synchronized void m() {
System.out.println("child m start");
super.m();
System.out.println("child m end");
}
}
- 程序出现异常,锁默认会被释放
- 重入次数必须记录,因为要解锁对应的次数
- 对于偏向锁,轻量级锁:每重入一次,线程栈上的LR就多一个,第一个LR会记录有一个叫displayed Header,这个displayed Header它记录着上一次状态的markword,hashcode存在这。对于重量级锁, hashcode记录在objectMonitor某个字段上
- 对于重量级锁,每重入一次,Monitor中的一个属性,即计算器就会+1
4. sync与lock区别及使用场景
对比项 | synchronized | Lock |
---|---|---|
锁升级 | synchronized涉及锁升级 | 没锁升级过程 |
锁释放 | synchronized是JVM关键字,并且在异常时会主动释放锁 | Lock要手动上锁释放锁,并且在发生异常时,不会自动释放锁。因此一般来说,使用Lock必须在try{}catch{}块中进行,并且将释放锁的操作放在finally块中进行,以保证锁一定被被释放,防止死锁的发生 |
锁中断 | synchronized当一个线程处于等待某个锁的状态,是无法被中断的,只有一直等待下去 | Lock使用tryLock进行尝试锁定,不管锁定与否,方法都将继续执行,也可以指定tryLock的时间,由于tryLock(time)抛出异常,所以要注意unclock的处理,必须放到finally中,当两个线程同时通过lock.lockInterruptibly()想获取某个锁时,假若此时线程A获取到了锁,而线程B只有在等待,那么对线程B调用threadB.interrupt()方法能够中断线程B的等待过程 |
锁类型 | synchronized是非公平锁。只能是独占锁 | Lock可以是公平锁,也可以是非公平锁。可以是独占锁,可以是共享锁 |
使用场景:
- 有规定时间或者根据可否获取锁的场景,可用Lock
- 可中断,可取消操作,用Lock
- 公平队列场景,用Lock,因为synchronized是非公平锁
- synchronized是独占锁,Lock有接口是共享锁
- 轻度中度竞争用Lock,重度竞争用sync
- 读多写少,用自旋锁Lock;写多读少,用重量级锁sync
5. 死锁
5.1 死锁定义
两个或两个以上的线程在执行过程中,因争夺资源而造成的互相等待的现象,在无外力作用的情况下,这些线程会一直相互等待而无法继续运行下去;
5.2 死锁产生的条件
死锁出现条件.png- 互斥条件:
强调的是只有一个线程能够获取资源
。该资源同时只由一个线程占用,如果还有其他线程请求该资源,只能等待,直至占用的线程释放该资源 - 不可剥夺:
强调的是线程使用资源期间不会被抢占
。当前线程的资源在使用完之前不能被其他线程抢占,只有等自己使用完才由自己释放 - 请求并持有:
强调的是单看一个线程的行为
。一个线程已经持有了至少一个资源,但又提出新资源请求,而新资源已被其他线程占有,那当前线程必阻塞,并不释放当前自己已获取的资源 - 环路等待:
强调的是从整体来看
。发生死锁时,必然存在一个线程一资源的环形链
5.3 常见的死锁
-
锁顺序导致死锁
A线程锁住left,尝试锁住right
B线程锁住right,尝试锁住left
结果永久等待
固定顺序获得锁即可解决
-
动态的顺序导致死锁
public void transferMoney(Account fromAccout,Account toAcount,DollarAoumt amout){ synchronized(fromAccout){ synchronized(toAcount){ ..... } } }
当账户A转给账户B,B账户又同时转给A账户,就会产生死锁
用System.identifyHashCode去解决
,先分别获取两个account的identifyHashCode,按照identifyHashCode的大小,按顺序获取锁即可。将由外面决定的顺序转为内部决定 -
协作对象之间,都在方法上加上sync关键字,相互调用导致死锁
用开放调用解决,即sync代码块而不是sync方法
5.4 避免死锁
- 造成死锁的原因和申请资源的顺序有很大关系,使用资源申请的有序性原则可以避免死锁
- 使用Lock.tryLock(Time)
6. 线程安全
线程安全问题是指,当多个线程同时读写一个共享资源并且没有任何同步措施时,导致出现脏数据或者其他不可预见的结果
保证线程安全措施
- synchronized
- cas
- threadLocal
- final
- 原子类
参考
《并发编程之美》
https://zhuanlan.zhihu.com/p/34556594
https://zhuanlan.zhihu.com/p/290991898
https://my.oschina.net/lscherish/blog/3117851