1. synchronized实操
关键字synchronized可用来保证原子性、可见性、有序性。
局部变量线程安全。线程不安全问题只存在于实例变量中。多个线程同时操作同一个对象的实例变量可能存在线程安全问题,因此需要加锁,加锁后就变成了线程安全的了。多线程情况下,当某个线程进入用synchronized声明的方法时就上锁,方法执行完毕后自动解锁,解锁之后在外面等待的线程中的某一个才能进入synchronized声明的方法,不解锁的话其他线程只能在外面等待。类比蹲坑,我进去以后把门一锁,其他人只能在外面等,我蹲完以后才把锁打开开门,然后外面排队等的人才能进去一个。(此处很明显是公平的,讲究先来后到,而synchronized是非公平锁,不能为了上厕所打得头破血流,都是文明人,要讲究公平。公平锁可以使用Lock接口实现,后面会讲。)
synchronized不仅可以用在同步方法,还可以用在同步代码块。对于同步代码块包裹的代码区域是线程安全的,包裹以外区域是线程不全的。而同步方法整个方法体内都是线程安全的,同步方法又可以分为静态同步方法和非静态同步方法。
public class SynchronizedTest {
public synchronized static void test1() {
}
public synchronized void test2() {
}
public void test3() {
synchronized (SynchronizedTest.class) {
}
}
public void test4() {
synchronized (this) {
}
}
public void test5() {
synchronized ("a") {
}
}
}
est1与test3持有的是同一把锁,即SynchronizedTest.Class对象。t因为类只会被加载一次,所以内存中只有一个SynchronizedTest.Class对象,所以无论有多少个实例对象来调用,锁都是同一把。test1与test2、test4、test5持有的锁都不同。
test2与test4持有的是同一把锁,即SynchronizedTest的实例对象。实例不同,锁就不同。所以当有多个实例对象来调用时,锁就会有多把。test2与test1、test3、test5持有的锁都不同。
test5持有的锁是字符串a。
一个类里面如果有多个synchronized非静态方法,在使用同一个对象调用的前提下,某一个时刻内,只要一个线程去调用其中的一个synchronized非静态方法了,其他的线程都只能等待,换句话说,某一时刻内,只能有唯一一个线程去访问这些synchronized非静态方法。锁的是当前对象this,被锁定后,其他线程都不能进入到当前对象的其他的synchronized非静态方法。
加个普通非同步方法后与同步锁无关。
换成静态同步方法后,情况有变化。
所有的非静态同步方法用的都是同一把锁:实例对象本身。
也就是说如果一个对象的非静态同步方法获取锁后,该对象的其他非静态同步方法必须等待获取锁的方法释放锁后才能获取锁,可是其他对象的非静态同步方法因为跟该对象的非静态同步方法用的是不同的锁,所以毋须等待该对象的非静态同步方法释放锁就可以获取他们自己的锁。
所有的静态同步方法用的也是同一把锁:类对象本身。
静态方法与非静态方法是两把锁,这两把锁是两个不同的对象,所以静态同步方法与非静态同步方法之间不会有竞争条件。但是一旦一个静态同步方法获取锁后,其他的静态同步方法都必须等待该方法释放锁后才能获取锁,而不管是同一个对象的静态同步方法,还是其他对象的静态同步方法,只要它们属于同一个类的对象,那么就需要等待当前正在执行的静态同步方法释放锁。
2. synchronized原理
在JavaSE1.6以前,synchronized都被称为重量级锁。但是在JavaSE1.6的时候,对synchronized进行了优化,引入了偏向锁和轻量级锁,以及锁的存储结构和升级过程,减少了获取锁和释放锁的性能消耗,有些情况下它也就不那么重了。
2.1synchronized在字节码指令中的原理
2.1.1 同步方法
在同步方法中,使用了flag标记ACC_SYNCHRONIZED,当调用方法时,调用指令会检查方法的ACC_SYNCHRONIZED访问标志是否设置。如果设置了,执行线程先持有同步锁,然后执行方法,最后在方法执行完毕释放锁。
在cmd窗口中用javap将class文件转换成字节码指令,-v用于输出附加信息,-c用于对代码进行反汇编。
javap -c -v SynchronizedTest.class
2.1.2 同步代码块
同步代码块是使用monitorenter和monitorexit指令进行同步的。monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处,JVM要保证每个monitorenter必须有对应的monitorexit与之配对。任何对象都有一个monitor与之关联,并且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象的monitor所有权,即尝试获取对象的锁。
3. synchronized锁升级
本部分内容需要如下内容为基础:大厂面试题Object object = new Object()
synchronized锁优化的背景:
用锁能实现数据的安全性,但是会带来性能下降。
无锁能够基于线程并行提升程序性能,但是会带来线程安全性下降。
synchronized锁:根据对象头的mark word 锁标志位来确定当前属于哪一种锁。
为什么会存在锁升级现象?
在java5及其以前,只有synchronized,这个是重量级锁,是操作系统级别的重量级操作。假如锁的竞争比较激烈,性能下降。因为存在用户态和内核态之间的转换。
为了减少用户态和内核态的转换,引入了轻量级锁和偏向锁
为什么每个对象都可以成为一个锁?
java6开始优化synchronized。为了减少获得锁和释放锁带来的性能消耗,引入了偏向锁和轻量级锁。锁一开始有一个升级过程,不会一开始就是重量级锁。
synchronized用的锁是存在Java对象头里的Mark Word中
锁升级功能主要依赖MarkWord中锁标志位和释放偏向锁标志位
无锁状态
字节内部是正向看,字节之间是反向看
public class SynchronizedTest2 {
public static void main(String[] args) {
Cat object = new Cat();
System.out.println("十进制:"+object.hashCode());
System.out.println("十六进制:"+Integer.toHexString(object.hashCode()));
System.out.println("十进制:"+Integer.toBinaryString(object.hashCode()));
System.out.println(ClassLayout.parseInstance(object).toPrintable());
}
private static class Cat{
int i;
long j;
char c;
boolean flag;
String str ="123";
}
}
hashCode方法不调用的话,途中绿色框全是0。
偏向锁
当一段同步代码一直被同一个线程多次访问,由于只有一个线程那么该线程在后续访问时便会自动获得锁
持有偏向锁
java -XX:+PrintFlagsInitial | grep BiasedLock*
-XX:BiasedLockingStartupDelay=0
验证偏向锁,默认延时4秒,所以需要关闭延时。如果不关闭延时,则会直接进入轻量级锁。
public class SynchronizedTest2 {
public static void main(String[] args) {
Cat object = new Cat();
synchronized (object){
System.out.println(ClassLayout.parseInstance(object).toPrintable());
}
}
private static class Cat{
int i;
long j;
char c;
boolean flag;
String str ="123";
}
}
虽然默认开启了偏向锁,但是开启有延迟4s。据说JVM内部的代码有很多地方用到了synchronized,如果直接开启偏向,产生竞争就要有锁升级,会带来额外的性能损耗,所以就有了延迟策略。虽然可以通过参数 -XX:BiasedLockingStartupDelay=0 将延迟改为0,但是不推荐。
开启延迟4秒
public class SynchronizedTest2 {
public static void main(String[] args) {
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
Cat object = new Cat();
System.out.println((ClassLayout.parseInstance(object).toPrintable()));
System.out.println("===============================================");
synchronized (object){
System.out.println((ClassLayout.parseInstance(object).toPrintable()));
}
}
private static class Cat{
}
睡五秒后,未加锁前,也是101,但是不存在ThreadID。默认前4秒无锁,4秒后变为偏向锁。此时处于biasable 状态,在 MarkWord 表格中并不存在,其实这是一种匿名偏向状态,是对象初始化中,JVM 帮我们做的。加锁后,存在ThreadID,此时处于biased状态
这样当有线程进入同步块:
可偏向状态:直接就 CAS 替换 ThreadID,如果成功,就可以获取偏向锁了
不可偏向状态:就会发生竞争,竞争失败就会变成轻量级锁
偏向锁撤销
锁升级图解
图片来源于百度
在jdk15废除了偏向锁,废除了也得学,工程大量应用啥时能升到jdk15也未可知。
轻量级锁
验证轻量级锁
关闭偏向锁,让锁竞争时直接进入轻量级锁
public class SynchronizedTest2 {
public static void main(String[] args) {
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
Cat object = new Cat();
System.out.println((ClassLayout.parseInstance(object).toPrintable()));
System.out.println("===============================================");
synchronized (object){
System.out.println((ClassLayout.parseInstance(object).toPrintable()));
}
}
private static class Cat{
}
}
在关闭偏向锁后,即使睡眠了5秒以后,当前也是无锁状态。
然后进入同步代码块,升级为轻量级锁。
自旋锁
线程的阻塞和唤醒需要CPU从用户态转为内核态,频繁的阻塞和唤醒很耗费性能。研究发现,共享数据的锁定状态大多只会持续很短的一段时间,为了这段时间阻塞和唤醒线程并不值得。如果物理机器有多个处理器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程等待一下看持有锁的线程是否很快就会释放锁,但不放弃处理器的执行时间。为了让线程等待,我们只需让线程进行自旋,这就是所谓的自旋锁。
自旋锁在JDK 1.4中就已经引入了,默认是关闭的,可以使用-XX:+UseSpinning参数来开启,在JDK 6中默认开启了。自旋等待不能代替阻塞,自旋虽然避免了线程切换的开销,但还是要占用处理器时间的,并且对处理器数量也有要求。如果锁被占用的时间很短,自旋等待的效果就会很好,但是,如果锁被占用的时间很长,那么自旋的线程只会白白消耗处理器资源,且没有做任何有用的工作。故而,自旋等待的时间必须要有一定的限制,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程了。在jdk6以前,自旋次数的默认值是10次,或者自旋线程超过cpu核数的一半,则升级为重量级锁。可以使用参数**-XX : PreBlockSpin**来更改。
轻量级锁和自旋锁的区别
重量级锁
代码演示
public class SynchronizedTest2 {
public static void main(String[] args) {
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
Cat object = new Cat();
new Thread(()->{
synchronized (object){
System.out.println((ClassLayout.parseInstance(object).toPrintable()));
}
}).start();
new Thread(()->{
synchronized (object){
System.out.println((ClassLayout.parseInstance(object).toPrintable()));
}
}).start();
}
private static class Cat{
}
}
锁升级为轻量级或重量级锁后,MarkWord中保存的分别是线程栈帧里的锁记录指针和重量级锁指针,已经没有位置再保存哈希码,
GC年龄了,那么这些信息被移动到哪里去了呢?
当一个对象已经计算过identity hashcode,它就无法进入偏向锁状态,跳过偏向锁,直接升级轻量级锁
public class SynchronizedTest2 {
public static void main(String[] args) {
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
Cat object = new Cat();
synchronized (object){
System.out.println((ClassLayout.parseInstance(object).toPrintable()));
}
object.hashCode();
synchronized (object){
System.out.println((ClassLayout.parseInstance(object).toPrintable()));
}
}
private static class Cat{
}
}
没调用hashcode方法前,可以进入偏向锁。执行了hashcode方法后,就直接进入轻量级锁了。
偏向锁过程中遇到一致性哈希计算请求,立马撤销偏向模式,膨胀为重量级锁
public class SynchronizedTest2 {
public static void main(String[] args) {
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
Cat object = new Cat();
synchronized (object){
System.out.println((ClassLayout.parseInstance(object).toPrintable()));
object.hashCode();
System.out.println((ClassLayout.parseInstance(object).toPrintable()));
}
}
private static class Cat{
}
}
在偏向锁中调用hashcode,会直接膨胀为重量级锁
synchronized锁升级小总结
锁消除
锁消除就是白加锁,属于代码bug,代码虽然加锁了,但是锁不起作用了,会J被IT(Just In Time Compiler,即时编译器)优化掉
public class SynchronizedTest2 {
public static void main(String[] args) {
Cat cat = new Cat();
for (int i=0;i<5;i++){
new Thread(()->{
cat.test();
}).start();
}
}
private static class Cat{
static Object object = new Object();
public void test(){
// 每个线程进来都新new一个对象,所以每个线程的锁都不一样,相当于无锁
Object o = new Object();
synchronized (o){
System.out.println(object.hashCode()+"===="+o.hashCode());
}
}
}
}
从JIT角度看相当于无视它,synchronized (o)不存在了,这个锁对象并没有被共用扩散到其它线程使用,极端的说就是根本没有加这个锁对象的底层机器码,消除了锁的使用
锁粗化
假如方法中首尾相接,前后相邻的都是同一个锁对象, 那JIT编译器就会把这几个synchronized块合并成一个大块,加粗加大范围,一次申请锁 使用即可,避免次次的申请和释放锁,提升了性能
注:本文是学习B站周阳老师《尚硅谷2022版JUC并发编程》课程所做学习笔记。