synchronized关键字详解
synchronized翻译成中文就是同步的意思,属于重量级锁
在Java早期版本效率十分低下,因为Java线程是映射到操作系统的原生线程之上的,如果要切换线程,都要操作系统的帮忙,而在线程切换的时候,要从用户态切换到内核态,耗时长 如何解决?
在Java6之后,sychronized引入了大量的优化自旋锁,适应性自旋锁,锁消除,锁粗化,偏向锁,轻量级锁来减小锁的开销,
偏向锁增加了JVM的复杂性,也没有为所有应用带来性能提升,在JDK18后就被废弃了
synchronized的使用
主要有三种方法:
1.修饰实例方法
2.修饰静态方法
3.修饰代码块
1.修饰实例方法(锁当前对象实例)
synchronized void method() {
//业务代码
}
2.修饰静态方法(锁当前类,更具体的是锁定当前类的Class对象,以达到其他线程不能同时访问该类的synchronized修饰的静态方法)
synchronized static void method() {
//业务代码
}
3.修饰代码块(锁指定的类或者对象)
synchronized(this) {
//业务代码
}
构造方法能用synchronized修饰?
不能,对象的创建本来就是单线程的
synchronized的底层原理
在解释底层原理之前,我们先了解Java对象头是什么
对象头
如图:
在JVM中对象的储存一般由三部分组成,对象头,实例变量,填充数据,我们重点了解对象头
普通数组的对象头占64bits,由Mark Word(32 bits)和Klass Word(32 bits)组成
数组对象的对象头占96bits,由Mark Word(32 bits)和Klass Word(32bits)和array length(32bits)组成
**Klass Word:**简单理解为指针,JVM通过这个指针确定这个对象属于那个类
Mark Word(32bits):结构图如下
分为无锁(Normal),偏向锁(Biased),轻量级锁(Lightweight Locked),重量级锁(Heavyweight Locked),最后被GC回收
注意参数:
001是无锁,101是偏向锁,00是轻量级锁,10是重量级锁
Monitor
接下来我们讲讲Monitor,Monitor被翻译成监视器,他的底层实现是C++,(主要是实现重量级锁的)他的原理是这样的:
**1.**一开始他是由三个部分组成,两个队列(WaitSet和EntryList),还有一个Owner部分
**2.**每个对象都关联一个自己的Monitor,当第一个线程进入锁之后,他就会指向Owner,这时候这个Monitor就监视着这个线程(Thread-2)
**3.**当再来一个线程的时候,他就会看到Monitor的Owner部分已经有了线程,就会排在EntryList队列后面等待,直到Thread-2使用完,他才会指向Owner
那WaitSet队列呢就是,调用了wait()方法,线程进入等待
但是,因为获取Monitor涉及到了操作系统,加锁就会太消耗性能。
所以轻量级锁出现了
轻量级锁:
他的原理是这样的:
**1.**这里的对象都一样,最主要看Mark word(也就是这个对象的Hashcode Age Bias 01代表无锁),每个线程要获取锁的时候都会创建一个栈帧,包含锁记录结构
**2.**然后让线程的Object reference指向对象Object,通过cas交换栈帧的Mark Word
举个例子:(我用了jol的一个工具类来打印出对象头信息)
public class SynchronizedTest {
public static void main(String[] args) {
SynchronizedTest obj=new SynchronizedTest();
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
synchronized(obj){
System.out.println("成功获得synchronized锁");
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}
}
}
这里锁住的是实例对象,下图是打印信息:
这里对应上面代码的第一次打印,二进制结尾一看就是01,说明是无锁状态(non-biasable)
这个是16进制,转化成二进制是
结尾是00说明是轻量锁(thin lock),所有当线程进入同步代码块的时候,他是先变成轻量级锁
这里反编译可以看到,进入了一次同步代码块,退出命令有两次,第二次退出保证了即使有异常锁也能被正确释放
这里可以看到异常的话会跳到25:astore_3,然后接着会释放锁
但是轻量级锁没有用到monitor,所以没有EntrySet队列来让其他线程等待,第二次线程访问就只能有两种情况:
1.还是这个线程请求拿这个对象的锁
2.其他线程
可重入锁
还是这个线程请求拿这个对象的锁
他的原理是:
当我们再次请求访问这个对象的锁的时候,他的栈帧的锁记录就会是null,表示可重入锁,就不需要交换
public class SynchronizedReentrantExample {
public synchronized void outerMethod() {
System.out.println("Outer method");
innerMethod(); // 再次进入synchronized方法
}
public synchronized void innerMethod() {
System.out.println("Inner method");
// 这里可以继续重入其他synchronized方法
}
public static void main(String[] args) {
SynchronizedReentrantExample example = new SynchronizedReentrantExample();
example.outerMethod();
}
}
自旋锁
其他线程
原理:
当另一个线程请求这个对象锁的时候,对比锁记录,已经被其他线程拿到了轻量级锁,这时候就会不断重试去获取这个锁,直到前一个线程释放了轻量级锁,然后我才能获取,这就是自旋锁
自旋锁会智能的调试重试次数,如果一直得不到就会升级成重量级锁(重试多了不如重量级的性能好)
锁膨胀和重量级锁
轻量级锁升级重量级锁原理:
它会为Object对象申请Monitor对象,让Object对象指向Monitor重量级锁的地址,然后它自己在Monitor的EntryList处等待,Monitor的Owner拥有指向前一个线程
当线程Thread0退出同步块的时候,就会唤醒Thread1来获得锁
偏向锁
偏向锁其实是在轻量级线程之前加的,就是线程创建应该一开始就创建线程,但是太鸡肋,在多线程下,并没有得到实质性的性能提升
原理是:
我们上文就看到Mark Word是由32位字节组成,当一个线程请求对象锁的时候,偏向锁就会得到这个线程的id,然后下次看如果还是这个线程就无需做任何同步操作,失败后就会申请轻量级锁
锁消除
虚拟机在JIT编译的时候(简单理解为第一次编译的时候),扫描上下文,去除了非共享资源的锁
举个例子就明白了
public class LockEliminationExample {
private void method() {
Object lock = new Object();
synchronized (lock) {
// 假设这里有复杂的计算或逻辑处理
System.out.println("在同步块内部");
}
}
public static void main(String[] args) {
LockEliminationExample example = new LockEliminationExample();
for (int i = 0; i < 1000; i++) {
example.method();
}
}
}
这个lock对象永远不可能成为共享资源,都是线程私有的
参考:
《深入理解JVM虚拟机》
javaguide:https://javaguide.cn/java/concurrent/java-concurrent-questions-02.html#synchronized-%E5%BA%95%E5%B1%82%E5%8E%9F%E7%90%86%E4%BA%86%E8%A7%A3%E5%90%97
黑马程序员JUC视频:https://www.bilibili.com/video/BV16J411h7Rd/?spm_id_from=333.337.search-card.all.click