本章所需基础知识:
如果没基础建议别看
推荐视频:
B站马士兵老师的视频:无锁、偏向锁、轻量级锁、重量级锁的锁升级
本章部分图片照旧用网课的图,太懒,侵权请通知我
重量级锁和非重量级锁的区别:
重量级锁需要申请资源时 ,必须通过内核(kernel),由操作系统来调用
用户态与内核态简单概念:
用户态:一般的普通程序只在JVM中运行,不经过操作系统的内核。
内核态:使用到重量级锁、映射、网络等,经过了操作系统的内核。
(后面简称操作系统OS)
synchronized在JDK1.6以前是重量级锁,在1.6以后有了优化,现在是自动的锁升级,现在则是看JVM的两个参数决定最初是偏向锁或轻量级锁
现在先来理清几个细节:
————要了解锁的底层实现,那必须先了解对象在内存中到底是什么样的。
上个内存图先:
对象在内存中的存储布局就是上面那样。
解释一波:
普通对象分四个部分:
所以一个普通对象的最小的字节是16个字节:8+4+0+4(补齐)
数组对象分五个部分:(比普通对象就了多一个数组长度)
工具JOL:
学习本章需要的工具JOL(一个jar包),查看对象的内存数据用的,不然你怎么知道它是偏向锁还是其他锁,它又不会长嘴告诉你。
有maven的:往你的项目pom.xml中的
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>
没有maven的,jar包资源(我的百度网盘,放心无毒免费永久):
jol的jar包,点击直达
链接:https://pan.baidu.com/s/1Ps0aZi4awDh_Pvy1Rkhl3A
提取码:h4j8
有没有maven的,也都可以自己去中央仓库这里挑版本:
maven中央仓库点击直达jol
链接:https://mvnrepository.com/artifact/org.openjdk.jol/jol-core
谈到锁就先简单看下这张图:
这图是对象的对象头(markword)表示意义图
先简单知道对象头最低位字节的锁标志位:(不用背,方便学习而已)
概念:偏向某一个线程(首个使用它)的锁,给线程只加一次锁,该线程重复调用加锁对象时只对比线程ID号,一致则直接通过,不用重复加锁。 不经过OS的调度。
简单理解:只为一个线程服务的锁(如果没竞争),不经过OS内核
底层实现:
将该线程的ID直接写进要上锁对象的markword(对象头)中的最低位字节里第二部分(31个字节那里),锁标志位改为101,就完事了。
偏向锁出现的原因:
JDK团队统计时发现一种现象:OS在同一个任务里,在没有其他线程竞争的时候,重复给同一个线程上锁解锁。这不瞎搞么,浪费资源。
例如:StringBuffer的多次append,加来加去都是同个线程在执行append命令(一般情况下),OS在每次append时都要给该线程重复加锁解锁,这就很浪费了。
4.1概念
概念:线程在尝试获取锁失败后不会立即阻塞,而是执行空循环一段时间,然后会再次尝试获取锁的,一直重复这两个步骤(不断自旋),直到 达到某个条件线程才会挂起(wait) 或者 获取到锁。
小细节:使用重量级锁时,没抢到锁的所有线程都会立马wait
简单说:其他线程没抢到锁后,不放手CPU资源,休息一会后,继续抢好多次锁,不会立马睡觉(wait)
自旋锁是偏向锁的升级,偏向锁是一个线程的,自旋锁是多个线程的,它不偏向任何一个线程,也不经过内核。靠CAS来实现的。
优点:减少线程切换的消耗,不经过OS,不用多次wait
缺点:每个线程抢锁失败一次就空循环一次,消耗CPU.
适用场景:
流程图:
多线程操作共享变量时,首先得抢锁,那么:
问题1:怎么判断该线程抢到了锁?
问题2:怎么保证同一时间只有一个线程抢到锁(多CPU)?
问题1,CAS是这样实现的:
3步骤对比的意义:看看其他线程有没有在自己线程执行计算的过程里就抢锁成功,修改过E了,避免脏读
ABA问题:E和E2对比相同是不能保证百分百保证,其他线程没有在自己线程执行计算的过程里抢锁成功过。有可能其他线程操作后新E值和旧E值一样!
ABA问题解决:在E对象里加个操作次数变量就行,每次判断时对比两个,E和操作次数就OK了,因为ABA问题中就算E相同操作次数也绝不相同
问题2:怎么保证同一时间只有一个线程抢到锁(多CPU)?
你可能疑惑都有两个对比变量E和操作次数了,不只能一个抢到锁么,怎么还有这种问题?
出现这种问题的原因在于两个:
CAS是这样解决问题2的:底层加锁
汇编语言的lock cmpxchg这条极其极其重要的命令
lock命令:
如果是单CPU,lock不加锁
如果是多CPU,lock加锁
多CPU时 lock命令锁住了一个北桥信号保证了单个线程执行这个cmpxchg命令(即对比和写入)
不采用锁总线的方式
注意点:这时的底层加锁并没有经过操作系统内核!
lock cmpxchg底层实现是跟系统和版本都有关的,略有差别。
4.3 自旋锁的分类
1.按膨胀条件(即升级条件)分为:简单自旋锁和自适应自旋锁:
简单自旋锁:就上面那样
自适应自旋锁:比简单自旋锁多了个功能,能够自动调整自旋次数这个参数,即升级重量级锁的条件。 后面再详写。
自适应自旋锁解决的是“锁竞争时间不确定”的问题。即根据实际情况去决定是多自旋几次再wait,还是少自旋几次再wait
简单说就是更灵活点,别所有没抢到的线程傻乎乎的自旋同样次数,给线程分了“天赋”:抢成功锁的几率大不大,大就多自旋几次,小就扣它生命值(自旋次数),趁早让它挂掉(wait),别浪费老子CPU资源。
2.按能否重入分类:可重入自旋锁和不可重入自旋锁
可重入是什么意思呢?
可重入锁,也叫做递归锁,指的是可以多次对同一个锁进行加锁操作,而不会被阻塞。即持有锁的线程在未释放掉锁的情况下,能否继续多次获取这把锁的意思。
举个例子:现在有三个方法,a()、b()、c(),我把这三个方法都用synchronized(“k”)锁住,这就是同一把锁,然后用线程 T 抢到 k 锁,进去 a方法,然后 T 在 a 里面又去调用 b 方法和 c 方法,能调用成功的就是可重入,不能成功的是不可重入。
简单说:就是你在店里办了会员卡,在他家的其他连锁店能不能用,能用是可重入(麦当劳的会员卡),不能用是不可重入(大部分健身房会员卡)。
可重入锁与不可重入锁这块下面的博主讲的不错,还有代码例子:
深山猿的博客,点击直达
https://blog.csdn.net/h2604396739/article/details/81255028
重量级锁(互斥锁)也称为阻塞同步、悲观锁,在JVM中又叫对象监视器(Monitor),它很像C中的Mutex。它至少包含一个竞争锁的队列(entityset),和一个信号阻塞队列(waitset),前者负责做互斥,后一个用于做线程同步。
重量级锁是依赖对象内部的monitor锁来实现的,而monitor又依赖操作系统的MutexLock(互斥锁)来实现的,所以重量级锁也称为互斥锁
重量级锁开销很大的原因:
当系统检查到锁是重量级锁之后,在每次抢锁的流程中,都会把所有没抢到锁的线程进行阻塞,等锁被释放后又会唤醒所有线程,重新抢锁。
虽然被阻塞的线程不会消耗cpu,但线程的切换是需要从用户态转换到内核态,而转换状态是需要消耗很多CPU时间的(微观的很多,相对而言)。
上面重量锁很长的概念是引用的下面两个博主的话(别问,问就是虎):
三名狂客,点击直达
不羁朔风,点击直达
简单说就是:重量级锁是由OS调度的,有竞争锁队列和等待队列,每次抢锁,失败的线程全部立即wait,等锁释放后全部唤醒,这消耗可想而知是很大的。
重量级锁的底层实现是objectMonitor.hpp(C++层面的) 我也没多少了解,这里就不写了,有兴趣的自己去扒扒。
这里知道重量级锁的锁标志位是11,由OS调度,有两个队列,线程切换频繁,消耗很大就好
本章的锁升级其实就是在讲JDK团队对synchronize锁的优化成果:synchronize锁的自动升级
首先讲条synchronize的升级主线(一般情况下):
new ——> 普通对象——> 偏向锁 ——> 自旋锁(轻量级锁 ) ——> 重量级锁
主线:
问题3:一开始给普通对象加锁为什么有可能不是偏向锁?
JVM对于偏向锁有六个参数,这里涉及其中两个:
第一个参数就不用继续解释了,最开始肯定默认使用最省CPU资源的锁
第二个参数BiasedLockingStartupDelay(偏向锁启动延时参数) 为什么要存在?换句话说为什么不直接启动偏向锁,而多此一举的延时4秒?
原因是这样的:
如果有某些加锁对象是JVM(或者说是你)非常确信且肯定它不会只有一个线程去使用,而是一定会被很多线程去竞争! (例如运行空间的内存分配,或者你直接设定了多线程竞争)
那么你还有给它上偏向锁的需求吗?加了反而还要去进行锁撤销、锁升级的浪费资源操作,这就是延时四秒的意义。
在JVM启动后的四秒内,如果有线程竞争你想上锁的对象,那么该对象直接上自旋锁(轻量级锁)!没有竞争才去给它上偏向锁。( 这就是上图的普通对象——> 自旋锁(轻量级锁 )的路线)
那么来讲讲上图的new——>匿名偏向是怎么一回事:
当你new了一个对象然后给它加了个synchronize锁(默认最初偏向锁),然后你又没有用线程去使用这把锁(起码刚开始没用到它),那么在JVM启动四秒后,JVM得给它加偏向锁啊,可又没有那个线程去使用它,这时JVM就给它加匿名偏向锁。
简单说你给对象加了synchronize锁,却没有去使用它(起码四秒内没用到它这把锁),那么就是匿名偏向锁。
匿名偏向锁很简单,就是没有把线程ID写进对象锁的对象头(markword)里的偏向锁。 当首个用到它的线程时,就把该线程的ID写进去。匿名偏向锁是可重偏向的偏向锁。
题外话——————————
锁从宏观上分类,分为悲观锁与乐观锁。
乐观锁
乐观锁是一种乐观思想,认为读多写少,遇到并发写的可能性低。
每次去拿数据的时候都认为别人没修改过,所以不会提前上锁。
在执行完线程操作后,更新的时候再去判断一下在此期间别人有没有去更新这个数据。
采取在写时先读出当前值,然后加锁操作,再去对比(比较跟上一次的版本号,如果一样则更新),如果失败则要重复读-比较-写的操作。
java中的乐观锁基本都是通过CAS操作实现的。
悲观锁
悲观锁是就是悲观思想,即认为写多,遇到并发写的可能性高。 每次去拿数据的时候都认为别人修改过了,所以每次在读写数据的时候都会上锁,这样别人想读写这个数据就会阻塞,直到拿到锁。
简单说:
乐观锁则是大家一起随便读取共享变量,等到我执行完所有操作后,再给共享变量加锁,我再开始对比和写入(或者只读不写)。(适用于读多的情况,读多写少)
悲观锁在最开始读取共享变量的时候,就给共享变量加锁了,然后执行完全部操作再解锁。(适用于写多的情况)
乐观锁:大家都是好朋友,信你们比我慢,不慌
悲观锁:大家都是单身二十年的手速,苟来的,不加锁还写不了了
差别就是加锁的时机。 乐观锁的优点是阻塞比悲观锁少,缺点是没抢到锁的线程会白白执行一遍你想要的操作;悲观锁的缺点则是一个线程读取了共享变量,其他线程全部歇菜,没得读,但是却不会出现执行了一遍线程操作却拿不到锁的浪费资源情况。
import org.openjdk.jol.info.ClassLayout;
import java.util.concurrent.TimeUnit;
public class HelloJOL {
public static void main(String[] args) {
new HelloJOL();
}
public HelloJOL(){
Object o = new Object();
String s = ClassLayout.parseInstance(o).toPrintable();
System.out.println(s);
//默认四秒后启动偏向锁例子
// BiasedLockingDelay();
//四秒内启动,轻量级锁例子
// LightweightLocking();
}
/*笔记
new 到 未启动偏向锁的普通对象: 线程启动后 四秒内 创建的普通对象
new 到 匿名偏向锁的对象:线程启动后 四秒后 创建的对象,且没有指定线程去使用这个对象(即默认上偏向锁了,却不知上给谁)
*/
//默认四秒后启动偏向锁例子
public void BiasedLockingDelay(){
try {
Object o = new Object();
String s = ClassLayout.parseInstance(o).toPrintable();
System.out.println(s);
//默认四秒后启动偏向锁例子,查看对象头最低位字节(即下面打印最前面的字节)的后三位
// 是不是从上面的(无锁态)001 变成 (偏向锁)101
TimeUnit.SECONDS.sleep(5); //多睡一秒
Object o2 = new Object();
String s2 = ClassLayout.parseInstance(o2).toPrintable();
System.out.println(s2);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
//jvm启动四秒内加锁,直接加轻量级锁例子
public void LightweightLocking(){
Object o = new Object();
String s = ClassLayout.parseInstance(o).toPrintable();
System.out.println(s);
//默认四秒后启动偏向锁例子,查看对象头最低位字节(即下面打印最前面的字节)的后三位
// 是不是从上面的(无锁态)001 变成 后两位的(轻量级锁)00
Object o2 = new Object();
synchronized (o2){
System.out.println("jvm启动四秒内加锁,直接加轻量级锁");
String s2 = ClassLayout.parseInstance(o2).toPrintable();
System.out.println(s2);
}
//如果结果是101(偏向锁),那是你机器太慢了,超过四秒,就只打印加锁的,删了对比那些代码
//不能在同步代码外打印,因为在外面打印时线程已经结束了,o2解锁,变成无锁态了(001)
// String s2 = ClassLayout.parseInstance(o2).toPrintable();
// System.out.println(s2);
}
}
偏向锁和自旋锁启动例子,点击直达
就上面那段,一样的
链接:https://pan.baidu.com/s/17QS6zG2oDzppaKbt8RiOrA
提取码:m1qh
其他的就不演示了,别问,问就是lan
如有错误,请不吝指正,学这个真是脑阔热,不如吃鸡