专门做会议的网站,wordpress网站数据库存在哪里,南通网站建设外包,苏州实力做网站公司有哪些《Java并发编程的艺术》中说到「如果失败#xff0c;表示其他线程竞争锁#xff0c;当前线程便尝试使用自旋来获取锁」#xff0c;并且下文所配的流程图中明确表示自旋失败后才会升级为重量级锁#xff0c;但《深入理解Java虚拟机》又说「如果出现两条以上的线程争用同一个…《Java并发编程的艺术》中说到「如果失败表示其他线程竞争锁当前线程便尝试使用自旋来获取锁」并且下文所配的流程图中明确表示自旋失败后才会升级为重量级锁但《深入理解Java虚拟机》又说「如果出现两条以上的线程争用同一个锁的情况那轻量级锁就不再有效必须要膨胀为重量级锁」到底会不会呢其实相信synchronized源码很少有人愿意去扒去看本文会尽量用简洁易懂的方式说清synchronized的原理。
只对实现原理感兴趣可以直接跳过到「synchronized实现原理」
synchronized基本使用 一般有三种方式
修饰普通方法锁this // 1. synchronized用在普通方法上默认的锁就是this当前实例public synchronized void method() {}修饰静态方法锁this.class // 2. synchronized用在静态方法上默认的锁就是当前所在的Class类// 所以无论是哪个线程访问它需要的锁都只有一把public static synchronized void method() {}同步代码块自定义锁对象 自定义锁对象可以是实例也可以是Class对象
synchronized (this) {}
synchronized(SynchronizedObjectLock.class){}抛出异常会释放锁 无论正常退出还是抛出异常synchronized都保证能够释放锁。
锁与happens-before规则 我们知道解锁操作 happens-before 加锁因此
首先有个变量a没有用volatile修饰
int a 0;线程A先执行
public synchronized void writer() { // 1a; // 2
} // 3线程B后执行
public synchronized void reader() { // 4int i a; // 5
} // 6 由h-b规则3 h-b 4再由as if serial和传递性原则因此2 h-b 5而h-b从开发人员的角度来说你就可以理解为2在5之前执行并且2的结果对5可见因此5处读到的a一定为1。
synchronized的内存语义 当线程释放锁时JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。
当线程获取锁时JMM会把该线程对应的本地内存置为无效
可以看到
锁释放与volatile写有相同的内存语义
锁获取与volatile读有相同的内存语义。
synchronized实现原理 下面我用尽量清晰简洁绕过虚拟机源码的方式来讲一下
会跳过一些源码细节的实现不会影响整体流程和理解
要了解实现原理第一步我会先看一下字节码指令
透过字节码看异常如何释放锁 synchronized修饰的方法会被加上 ACC_SYNCHRONIZED的flag。
而同步代码块的字节码是这样的
monitorenter
...
monitorexit
goto xxx
monitorexit
athrow
returnException table:
from to target type4 14 17 any17 20 17 any可以看到monitorenter和monitorexit指令分别对应synchronized同步块的进入和退出。
有两个monitorexit因为javac为同步代码块添加了一个隐式的try-finally在finally中会调用monitorexit命令释放锁。如果不知道字节码的Exception table是什么可以参考异常处理实现原理
尽管字节码通常都能帮助我们更好地理解语义但关于synchronized的语义也就到此为止了接下来就要深入虚拟机源码看看monitorenter获取锁和monitorexit释放锁到底都干了些什么不过在此之前
因为synchronized有四种锁状态而锁状态的实现依赖于Java对象的mark word这是实现synchronized的基础我们先来看mark word如何表达锁状态的。
Java中的每一个对象都可以作为一个锁包括Class对象。
四种锁状态 Java对象头的mark word
注意轻/重锁的mark word内是持有一个指向锁记录的指针的。
因此一个对象其实有四种锁状态,级别由低到高:
无锁状态
偏向锁状态
轻量级锁状态
重量级锁状态
1、无锁 释放轻量级锁没有线程在尝试获取锁也没有线程持有锁正在执行同步代码块就是无锁。
2、偏向锁JDK15被废弃 偏向锁在JDK1.6引入在JDK15被废弃了解即可。如果一定要用需要手动打开
-XX:UseBiasedLocking人们发现大多数情况下锁不仅不存在多线程竞争而且总是由同一线程多次获得于是有了偏向锁。
偏向锁顾名思义偏向于第一个访问锁的线程。偏向锁在资源无竞争情况下消除了同步语句连CAS操作都不做了提高了程序的运行性能。
当开启偏向锁功能时创建的新对象是可偏向状态此时mark word中的thread id为0也叫做匿名偏向。当该对象第一次被CAS成功时成为「偏向锁」。
在该线程又一次尝试获取该对象锁时发现thread id就是自己就可以不做CAS直接认为已经拿到了锁并执行同步代码块中的代码。
注意上述的所有都只出现了一个线程
当第二个线程出现并尝试获取锁无论如何都会升级成「轻量级锁」。
如果第一个线程正在执行同步代码块锁偏向的线程继续拥有锁当前线程升级该锁为「轻量级锁」。
如果第一个线程不在执行同步代码块先将对象头的mark word改为无锁状态再升级为「轻量级锁」。
也就是是要有两个线程尝试获取锁不论是否出现资源竞争升级为「轻量级锁」。
3、轻量级锁 升级到「轻量级锁」的条件是存在多个线程尝试CAS获取同一把锁尽管彼此之间互不影响。而「轻量级锁」继续膨胀为「重量级锁」的条件是只要CAS失败就升级即发生了一个线程正在执行同步代码块的同时另一个线程尝试获取锁。
轻量级锁会自旋吗 自旋不断尝试去获取锁一般用循环来实现。
这是不对的是网上最常见的错误之一你问chatGPT他也是这个答案但这就是个错误的答案。因为前面说的很清楚了只要发生哪怕一次CAS失败就不是「轻量级锁」了何来自旋呢
自旋的说法从何而来 《Java并发编程的艺术》(2015)原文是
线程在执行同步块之前JVM会先在当前线程的栈桢中创建用于存储锁记录的空间并将对象头中的Mark Word复制到锁记录中官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功当前线程获得锁如果失败表示其他线程竞争锁当前线程便尝试使用自旋来获取锁。
《深入浅出Java多线程1.0.0》原文是
然后线程尝试用CAS将锁的Mark Word替换为指向锁记录的指针。如果成功当前线程获得锁如果失败表示Mark Word已经被替换成了其他线程的锁记录说明在与其它线程竞争锁当前线程就尝试使用自旋来获取锁。
总之以上两位作者认为发生竞争自旋并没有指出自旋前会发生锁膨胀。
《深入理解Java虚拟机》(2019)原文是
如果这个更新操作失败了那就意味着至少存在一条线程与当前线程竞争获取该对象的锁。虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧如果是说明当前线程已经拥有了这个对象的锁那直接进入同步块继续执行就可以了否则就说明这个锁对象已经被其他线程抢占了。如果出现两条以上的线程争用同一个锁的情况那轻量级锁就不再有效必须要膨胀为重量级锁锁标志的状态值变为“10”此时Mark Word中存储的就是指向重量级锁互斥量的指针后面等待锁的线程也必须进入阻塞状态。
周志明大大的意思是出现两条以上的线程争用同一个锁的情况就要升级为重量级锁没有指出升级为重量级锁前要自旋。
显然这两种观点是有冲突的核心问题在于
轻量级锁状态下发生资源竞争到底是自旋还是立刻锁膨胀
如何考证说法的正确性 那么我们也只能自己去看源码来验证说法的正确性了但很少有人愿意看吧
下文我会尽量清楚地用文字表达出源码传达的意思
轻量级锁实现原理 获取锁 发现是无锁状态线程会把锁的Mark Word复制到自己的Displaced Mark Word栈帧中的一块空间 然后通过CAS尝试将锁的Mark Word修改为一根指针指向自己的Displaced Mark WordDisplaced Mark Word与原mark word的内容一模一样保存了HashCodeGC年龄等信息
发现处于轻量级锁状态
如果轻量级锁的markword指向自己的Displaced Mark Word代表重入锁那么获取锁成功如果是重入会将markword改为null空指针即0
如果轻量级锁的markword不是指向自己锁膨胀升级为「重量级锁」
CAS失败直接膨胀
释放锁 首先遍历线程栈拿到所有需要做解锁操作的锁对象
如果是null代表可重入的锁直接解锁成功
如果不是重入的锁
还原成功轻量级锁解锁成功
还原失败仍然是「尝试解锁重量级锁」
如果markword被修改说明发生了竞争已经成为「重量级锁」了「尝试解锁重量级锁」
如果markword没被修改尝试CAS还原对象的markword
补充说明线程A正在执行同步代码块时此时有线程CAS失败虽然升级为「重量级锁」但仍然由线程A持有锁「如何膨胀为重量级锁」后文马上分析
4、重量级锁 为了实现锁膨胀避免并发膨胀锁定义了四种膨胀锁状态
膨胀完毕
膨胀中
无锁
轻量级锁
下面依次对这些情况的膨胀进行分析
重量级锁的生成/锁膨胀 若膨胀完毕直接返回monitor
若膨胀中线程等待一会直到别的线程膨胀完毕然后拿到别人生成的monitor
从轻量级锁开始膨胀
创建monitor对象
CAS将锁状态修改为「膨胀中」
将markword保存至monitor
设置持有monitor的线程
将monitor地址设置为mark word
返回monitor对象
失败说明别人在膨胀了等待然后返回别人生成的monitor
成功
从无锁开始膨胀差不多
创建monitor对象
将markword保存至monitor
CAS将锁状态修改为「膨胀中」
失败说明别人在膨胀了等待然后返回别人生成的monitor
成功返回monitor对象
重量级锁实现原理 生成了重量级锁mark word会指向堆中实际生成的monitor对象我们先来看看monitor对象的结构 Contention List(cxq)所有请求锁的线程将被首先放置到该竞争队列是先进后出的栈结构
Entry ListContention List中那些有资格成为候选人的线程被移到Entry List
Wait Set那些调用wait方法被阻塞的线程被放置到Wait Set
OnDeck任何时刻最多只能有一个线程正在竞争锁该线程称为OnDeck
Owner获得锁的线程称为Owner
!Owner释放锁的线程
获取锁 对于重量级锁尝试获取锁具体是指尝试用CAS将monitor对象的Owner从nullptr改变为自己
当一个线程尝试获得重量级锁时
首先尝试「自旋」调用trySpin方法获取锁如果第一次失败再进行一次trySpin方法最坏情况拿不到锁会调用两次trySpin然后『用CAS的方式进入cxq』
进入cxq后陷入「死循环」死循环中可能会从cxq转移到EntryList可能阻塞也可能调用trySpin方法自旋。后文再详细分析「死循环」
可以看到「死循环」的实现也依赖trySpin自旋因此我们先来看看「自旋」的实现逻辑
1、自旋锁 自旋不断尝试去获取锁一般用循环来实现。
如果是单核CPU自旋是无意义的所以只有多处理器才会开启自旋功能
自旋的出现是为了避免切换到内核态因为线程的阻塞和唤醒依赖内核我们希望能够一定程度上避免这种内核态与用户态的切换因此有了「自旋锁」。那么自旋多少次更合适呢
在锁很快被释放时自旋既不会带来CPU资源的浪费还能提高运行效率。此时自旋次数过少可能会导致没能顺利拿到锁即使结束自旋后不久锁就被释放了。
在锁很久才被释放时自旋空转占用CPU资源却迟迟拿不到锁造成过多的CPU资源浪费。此时自旋次数过多反而会得不偿失。
因此JDK发明了自适应自旋来适应各种情况的锁。
自适应自旋 自适应自旋为了权衡自旋次数过多和过少带来的弊端它的基本思想是
自旋成功拿到锁了说明你下次成功的概率也很大下次自旋的次数会更多
自旋失败说明你下次也大概率拿不到下次自旋的次数会更少
自适应自旋参数如下 自旋逻辑trySpin 首选预自旋11次避免预自旋次数设置为0源码后面对这个参数加了1如果没拿到锁
开始自旋5000次假设是第一次开始自旋上限就为5000
成功下次100下次可以最多自旋5100次
失败下次- 200下次可以最多自旋4800次不会少于1000次
2、死循环 死循环主要是在「阻塞」和「自旋」之间切换
park阻塞注意不会移动到WaitSet中
unpark唤醒再次调用trySpin方法自旋获取锁如果失败陷入阻塞
只有释放锁时才会调用unpark唤醒进入自旋状态此时并不是一定能拿到锁的。
唤醒的时机 释放锁时才会唤醒且只会唤醒一个唤醒逻辑取决于Policy参数。
cxq和EntryList内线程的行为 这两个区域内的线程几乎是全阻塞的这两个区域内的线程保证最多只有一个线程去竞争锁资源这个被『释放锁时唤醒的唯一的线程』叫「假定继承人」即Monitor结构中的「OnDeck」。
注意只保证所有阻塞的线程只有一个去竞争锁资源仍然可能被外来的线程在进入cxq之前就抢到了锁所以说synchronized是不公平的。
EntryList内的线程全部来自cxq在释放锁与调用notify方法时可能进入EntryList
释放锁 通过CAS的方式将Monitor结构的Owner修改为nullptr
根据QMode参数的不同执行不同的逻辑
因为QMode默认值为0我们来看一下默认的逻辑
如果EntryList和cxq均为空什么也不做
如果EntryList非空就取EntryList首元素唤醒
如果EntryList为空cxq非空将cxq的所有线程放到EntryList再唤醒EntryList首元素
锁被持有时EntryList和cxq的所有线程都阻塞有且只有锁释放这唯一一个行为能够唤醒其中的一个线程。
为什么要区分cxq和EntryList 是为了解决CAS的ABA问题也能分散请求提高性能。
cxq和EntryList都是为了存储所有阻塞的线程但是
释放锁并唤醒时只会唤醒EntryList的线程这是删除操作
线程自旋次数过多需要被阻塞时只会插入cxq队列这是添加操作
把这两种操作分离开来有什么好处呢
提高性能 由于锁只有一把因此做删除操作的线程只有一个不存在线程安全问题不需要做CAS如果和添加操作混在一起就不得不考虑线程安全问题了。这样只需要在cxq内考虑CAS即可。
解决ABA问题 因为多个线程同时add不会有某个线程出现在cxq里两次因此只add不会有ABA问题。而一旦存在删除操作那么ABA问题就是有可能的。
可感知的锁控制权 现在知道了加解锁的原理那其实我们已经有能力知道释放锁时会唤醒哪个线程。暂时不考虑wait/notify
结论先阻塞的线程最晚获得锁。
有三个线程t1,t2,t3。这三个线程都自旋失败插入cxq由于是个栈越晚进入cxq的反而越早进入EntryList顺序为t3,t2,t1。而唤醒时是按照EntryList的顺序去唤醒的因此「并不是所谓的随机唤醒」。当然如果此时有别的线程t4自旋未进入cxq是有可能拿到锁的但我们保证t3先于t2被唤醒t2先于t1被唤醒
阶段性小结一 到这里应该对锁机制非常熟悉了你应该清楚
Monitor锁结构
自旋的原理和应用自旋不会出现在轻量级锁
重量级锁加解锁的逻辑
我们趁热打铁来学习一下wait/notify的底层原理至今仍未露面的WaitSet终于要登场了学完wait/notify整个synchronized也就 “证据链闭环” 了。
从趁热打铁的角度趁你还对加解锁和Monitor结构足够熟悉我非常推荐直接跳到「wait/notify底层原理」看当然在此之前请确保你对wait/notify的基础知识足够了解
等待通知机制wait/notify wait/notify必备的基础知识 wait/notify只能用在synchronized代码块内部且必须是重量级锁。
只有持有锁的线程能够调用wait/notify方法
调用wait会使当前线程释放锁并陷入阻塞状态
从wait()方法返回的前提是获得了调用对象的锁
可以唤醒一个notify或多个notifyAll
调用notify无法保证被唤醒的线程一定拿到锁
当调用一个锁对象的wait或notify方法时如当前锁的状态是偏向锁或轻量级锁则会先膨胀成重量级锁。
wait/notify基本使用 等待通知基本模型 等待者 synchronized(对象) {while(条件不满足) {对象.wait();}对应的处理逻辑
}通知者
synchronized(对象) {改变条件对象.notifyAll();
}等待超时模型 这样一个熟悉的场景调用一个方法时等待一段时间一般来说是给定一个时间段如果该方法能够在给定的时间段之内得到结果那么将结果立刻返回反之超时返回默认结果。
public synchronized Object get(long mills) throws InterruptedException {long future System.currentTimeMillis() mills;long remaining mills;// 当超时大于0并且result返回值不满足要求while ((result null) remaining 0) {wait(remaining);remaining future - System.currentTimeMillis();}return result;
}wait/notify底层原理 wait方法 将当前线程包装成ObjectWaiter对象放入WaitSet中并调用park挂起
执行「释放锁」的逻辑。
只有notify方法有可能将线程从WaitSet拯救出来处于WaitSet的线程永远是阻塞状态不可能参与锁竞争
notify方法 从WaitSet中取出第一个线程根据Policy的不同将这个线程放入EntryList或者cxq队列中的起始或末尾位置
默认Policy为2即
EntryList队列为空将线程放入EntryList
EntryList队列非空将线程放入cxq队列的头部位置栈顶
强调一下notify方法只是将线程从WaitSet移动到EntryList或者cxq不是直接让它开始自旋CAS。
wait/notify理解实战 看下面这段代码在不修改 HotSpot VM源码的情况下考虑几个问题
输出唯一确定吗
如果确定会输出什么
public class NotifyDemo {
private static void log(String desc){System.out.println(Thread.currentThread().getName() : desc);
}Object lock new Object();public void startThreadA(){new Thread(() - {synchronized (lock){log(get lock);startThreadB();log(start wait);try {lock.wait();}catch(InterruptedException e){e.printStackTrace();}log(get lock after wait);log(release lock);}}, thread-A).start();
}public void startThreadB(){new Thread(()-{synchronized (lock){log(get lock);startThreadC();sleep(100);log(start notify);lock.notify();log(release lock);}},thread-B).start();
}public void startThreadC(){new Thread(() - {synchronized (lock){log(get lock);log(release lock);}}, thread-C).start();
}public static void main(String[] args){new NotifyDemo().startThreadA();}
}输出唯一确定为
thread-A : get lock
thread-A : start wait
thread-B : get lock
thread-B : start notify
thread-B : release lock
thread-A : get lock after wait
thread-A : release lock
thread-C : get lock
thread-C : release lock为什么最后四行A一定先于C发生
线程C获取锁失败直接放入cxq首部线程A被notify会被放入EntryList。之后B释放锁发现EntryList内有线程A就直接把A唤醒。
自定义抢锁逻辑修改JVM参数 有两个参数会影响synchronized的行为逻辑
Policy参数唤醒线程 Policy参数决定如何唤醒线程
Policy 0放入EntryList队列的排头位置
Policy 1放入EntryList队列的末尾位置
Policy 2EntryList队列为空就放入EntryList否则放入cxq队列的排头位置
Policy 3放入cxq队列中末尾位置
QMode参数释放锁 QMode参数决定如何释放锁
QMode 2并且cxq非空取cxq队列排头位置的ObjectWaiter对象唤醒该线程结束
QMode 3把cxq队列的全部元素放入EntryList尾部然后执行步骤四
QMode 4把cxq队列的全部元素放入EntryList头部然后执行步骤四
QMode 0不做什么执行步骤4默认为0
如果EntryList非空就取首元素唤醒否则整个cxq放到EntryList再唤醒EntryList首元素
通过修改这两个参数就可以自定义notify和释放锁的逻辑。还是上面那个例子只需要修改QMode为4就可以确保最后四行C先于A执行。
阶段性小结二 其实wait/notify原理并不难懂甚至可以说是非常好理解就不再重复了。
到此为止与synchronized的原理基本就讲解完毕了接下来我们重新审视一下一些比较笼统而泛泛的问题不仅能帮助你更好地理解synchronized的原理也能对synchronized有一个更全面的认知。算是一些补充说明吧。
synchronized的特点 非公平锁 非公平锁完全可以从前文的原理体现出来
新来的线程不断自旋不会阻塞因此比起阻塞中的线程更容易抢占锁
cxq先入后出先陷入阻塞的线程反而更晚执行
notify唤醒的线程如果EntrySet为空直接放入EntrySet先于cxq被执行
可重入性 synchronized是可重入的
monitor有个计数器recursions起初为0Monitorenter 1Monitorexit - 1减为0会释放锁。
乐观 or 悲观 什么是悲观锁什么是乐观锁
看似简单的概念很多人第一次学习时都会顾名思义但现在网络上主流的观点有两种
乐观锁只是一种思想认为不会竞争锁仅此而已
乐观锁是线程先执行锁区域的内容执行过程中检查是否出现竞争
核心的矛盾点在于乐观锁到底是纯思想还是对实现做了一些行为规范的定义比如必须什么都不操作直接执行同步代码块的内容
如果读者有关于「乐观锁」较为官方的定义请在评论区告诉我感激不尽
但如果「乐观锁」仅仅是一种思想那可以说synchronized的所有线程只要没有被阻塞那就是乐观的只有重量级锁中那些在cxq和EntryList的阻塞的线程是悲观的WaitSet是自愿阻塞不算在内。因为如果足够悲观早就阻塞等待去了为啥还要自旋CAS呢
编译器对synchronized的优化 锁消除 如果编译器发现不会发生线程安全问题就会无视了你的锁。
锁粗化 比如执行插入数据商品时是对店铺加锁。那么批量执行的时候只需要加一次锁。而不是每插入一次就加/释放一次锁。 StringBuffer sb new StringBuffer();sb.append(s1);sb.append(s2);sb.append(s3);// 线程安全的buffer类append会加锁但显然这是可以锁粗话的会优化成只获得/释放一次锁synchronized与包装类的坑
Integer并不适合当作锁对象。因为有缓存机制-128~127有缓存。容易导致锁失效。
volatile static Integer ticket 10
比如两个线程抢票不能锁住 ticket。抢完票以后ticket–一个线程A锁的是ticket 10的对象另一个线程B执行完ticket 10的临界区代码ticket–再走临界区他的锁变成了9与A竞争的都不是一把锁因此两者都会抢到锁。
因此
锁住的对象尽量是静态的不变的比如class类
不能是各种有缓存的包装类
在idea中 没有声明final的对象加synchronized会提示不安全