并发编程之—synchronized
1、Java中synchronized关键字的使用
synchronized关键字主要用于对java程序对共享资源的访问控制,可以在同一时刻,只有一个线程可以执行某个方法或某个代码块,同时synchronized还可以保证一个线程对共享资源的修改被其他线程看到,完全可以替换volatile。
synchronized关键字在Java程序中主要有以下三种应用方式:
(1) 修饰静态方法,相当于对类的class对象加锁。由于静态方法不专属于任何一个实例对象,是类的成员,当线程A访问加锁的静态方法时,线程B就只能等待,具体示例代码如下:
public class SynchronizedForClass {
static int i = 0;
/*
* 此方法是加了synchronized关键字的静态方法
*/
public static synchronized void synStatic() {
i++;
}
/*
* 此方法是加了synchronized关键字的普通方法
*/
public synchronized void synNoStatic() {
i++;
}
public static void main(String[] args) throws InterruptedException {
// 创建2个线程t1、t2
Thread t1 = new SynClass(new SynchronizedForClass());
Thread t2 = new SynClass(new SynchronizedForClass());
// 启动线程t1、t2
t1.start();
t2.start();
// 主线程等待t1、t2执行完成
t1.join();
t2.join();
// 调用的是静态加锁的方法,因此t1和t2执行完成后,i的值是20000
System.out.println(i);
}
}
class SynClass extends Thread {
private SynchronizedForClass synchronizedForClass;
public SynClass(SynchronizedForClass synchronizedForClass) {
this.synchronizedForClass = synchronizedForClass;
}
@Override
public void run() {
for (int j = 0; j < 10000; j++) {
synchronizedForClass.synStatic();
}
}
}
上述代码执行完结果是i=20000,这便是synchronized对静态方法加锁的作用,加锁的对象是SynchronizedForClass 类对象。线程t1和线程t2对synStatic()方法访问是互斥的,t1在执行synStatic()方法时,t2只能等待t1执行完,因为t2需要竞争SynchronizedForClass 类对象锁,但t2可以在t1执行synStatic()方法时去执行synNoStatic()方法,因为两个方法执行时加锁的对象不同,但是对共享资源i的执行结果就不会再是绝对的i=20000了,而是10000<i<=20000。代码如下:
public class SynchronizedForClass {
static int i = 0;
/*
* 此方法是加了synchronized关键字的静态方法
*/
public static synchronized void synStatic(){
i++;
}
/*
* 此方法是加了synchronized关键字的普通方法
*/
public synchronized void synNoStatic(){
i++;
}
public static void main(String[] args) throws InterruptedException {
// 创建2个线程t1、t2
Thread t1 = new SynClass(new SynchronizedForClass());
Thread t2 = new SynClass(new SynchronizedForClass());
// 启动线程t1、t2
t1.start();
t2.start();
// 主线程等待t1、t2执行完成
t1.join();
t2.join();
// i的值不固定
System.out.println(i);
}
}
class SynClass extends Thread {
private SynchronizedForClass synchronizedForClass;
public SynClass(SynchronizedForClass synchronizedForClass) {
this.synchronizedForClass = synchronizedForClass;
}
@Override
public void run() {
for (int j = 0; j < 10000; j++) {
synchronizedForClass.synNoStatic();
}
}
}
上述代码t1和t2加锁的对象变了,由原来的共享一个SynchronizedForClass 类对象锁,变成了分别拥有各自的SynchronizedForClass 类实例对象锁,因此对共享资源的操作就不再是互斥的,线程安全就无法保证了。
(2) 修饰普通实例方法,相当于对类的实例对象加锁,与类的class对象锁属于不同的锁。
(3) 修饰代码块,对指定对象加锁,进入同步代码块前需要获得指定对象的锁。
Object obj = new Object();
synchronized (obj){
i++;
}
同步代码块加锁方式可以减小锁的粒度,对于方法体比较大,且方法体内有比较耗时的代码,耗时的代码与共享资源操作无关时,我们可以使用同步代码块单独对共享资源的操作加锁,从而提升代码执行效率。
同步代码块加锁的方式可以替换对普通实例方法加锁和对静态方法加锁,具体代码为:
// 使用同步代码块替换对普通实例方法加锁
public void function1(){
synchronized(this){
//执行同步操作
}
}
// 使用同步代码块替换对静态方法加锁
public void function1(){
synchronized(Instance.class){
//执行同步操作
}
}
2、对象头的信息分析
关于对象头的分析可具体参考:《Java对象头详解》https://www.jianshu.com/p/3d38cba67f8b这篇博客。
java的对象头在对象的不同状态下会有不同的表现形式,主要有三种状态,无锁状态、加锁状态、gc标记状态。那么我们可以理解java当中的取锁其实可以理解是给对象上锁,也就是改变对象头的状态,如果上锁成功则进入同步代码块。
3、synchronized原理
Synchronized 在 JVM 里的实现都是基于进入和退出 Monitor 对象来实现方法同步和代码块同步,虽然具体实现细节不一样,但是都可以通过成对的MonitorEnter 和 MonitorExit 指令来实现。
对同步块,MonitorEnter 指令插入在同步代码块的开始位置,当代码执行到该指令时,将会尝试获取该对象 Monitor 的所有权,即尝试获得该对象的锁,而monitorExit 指令则插入在方法结束处和异常处,JVM 保证每个 MonitorEnter 必须有对应的 MonitorExit。
对同步方法,从同步方法反编译的结果来看,方法的同步并没有通过指令monitorenter 和 monitorexit 来实现,相对于普通方法,其常量池中多了ACC_SYNCHRONIZED 标示符。JVM 就是根据该标示符来实现方法的同步的:当方法被调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取 monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个 monitor 对象。
4、偏向锁膨胀过程
4.1 锁的状态
通过对象头的分析,我们可知锁一共有四种状态,无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态,它会随着竞争情况逐渐升级。一般锁可以升级但不能降级,目的是为了提高获得锁和释放锁的效率。
1、什么叫做无锁?无锁分为两种情况 ①:无锁可偏向(101)②:无锁不可偏向(001)在无锁不可偏向的情况下第一个0标识偏向标识不可偏向;但是还有一种情况也是101这种情况是有锁而且是已经偏向了线程;所以看一把锁(对象)是否有锁不能单纯的看后三位;比如后三位等于101;他可能是有锁;也有可能是无锁,需要看对象头中的线程id,确定已经偏向了哪个线程;但是后三位如果是001那么肯定是无锁;下文中说的无锁基本指的是001;
2、轻量锁:后两位是00(一共64位前面62位都是一个指针;所以轻量锁只需要看后两位00);
3、重量锁:后两位是10(一共64位前面62位都是一个指针;所以重量锁只需要看后两位10);
4、比如一个对象锁obj(在没有加锁的情况下;偏向没有延迟的情况下),new出来的时候是无锁可偏向也就是后三位是101;对象头的结构如下图;其中bl=1;lock=01
5、假设现在的场景是t1线程使用synchronized来对obj加锁,那么它必然是一个偏向锁;后三位同样还是101;但是前面的值改了;存了线程id和epoch等等信息;如下图;其中的bl=1;lock=01(注意和3不同的是前面的东西改了;后三位还是101)
6、然后t1把锁释放了(现在的场景是只有t1来加锁);由于锁是偏向锁;故而就算释放了还是101;对象头还是和4一样;
4.2 偏向锁加锁
1、线程t1使用synchronized对obj对象加锁,会走匿名偏向锁的流程;产生一个偏向自己的mark;然后cas替换对象头;成功则加锁;失败则撤销偏向并且升级轻量;
2、现在t1线程执行完了,怎么释放的锁呢?就是从栈当中把那个锁记录lr给释放,所以偏向锁的锁释放也是很简单的。
3、t1把锁释放之后,t1又来了加锁。首先会有一个锁对象,比如 obj,然后会在当前线程栈当中创建一个lockrecord(以下简称lr),然后代码接着往下执行,判断当前线程是否自己,是否过期,如果不过期那么线程直接拿到锁,执行同步代码块,什么都不用改也不用CAS,性能是最好的,这就是最简单的偏向锁获取过程。偏向锁第二次之后获取锁的流程与第二次相同。
4.3 偏向锁升级为轻量级锁
1、假设一种场景就是现在t1释放锁了(偏向锁),然后线程t2来了,根据我们的经验可知t2来加锁需要升级成为轻量锁(也有不升级的情况,这时候要考虑线程id复用的情况),这里需要注意t2升级的时候需要做偏向撤销,t2加锁成功后,对象的markword中锁状态就变成了00。
2、线程t2执行完后,把锁记录释放并且恢复锁对象里面的markword,t2执行完同步块后的内存如下图,轻量锁撤销的过程需要将锁的状态重置为无锁状态,因此可以认为轻量锁加锁解锁过程比偏向锁还是性能会差一些。
3、假如现在t2已经将轻量锁释放了,这个时候线程t3来了,那么t3首先生成一个无锁的markword 也就是001;内存如下图
4、然后把锁记录里面的displaced word设置成为这个无锁的红色箭头表示的;内存如下图
5、接着CAS 判断当前对象头当中的markword 是不是和t3在CPU内存当中产生的markword相等,如果相等则把对象头当中的markword修改成为一根指针指向锁记录,然后再把对象头当中的markword的后两位改成00。内存如下图
4.4 轻量级锁升级为重量级锁
如果t3在加锁的过程中cas失败,就是另外一种场景了。假如线程t4在线程t3还没有执行cas的时候,t4已经把锁持有了改成了00(把t3的事情做完了),由于t3产生的是一个无锁的001,而对象头现在被t4改成了轻量(000),那么t3 就会cas失败,因为cas是判断当前对象头当中的markword 是不是和t3在CPU内存当中产生的markword相等。当t3线程cas失败一定次数后,就会升级为重量级锁。
4.5 批量重偏向
如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程 T1 的对象仍有机会重新偏向 T2,重偏向会重置对象的 Thread ID,当撤销偏向锁阈值超过 20 次后,jvm 会这样觉得,我是不是偏向错了呢,于是会在给这些对象加锁时重新偏向至T2。
@Slf4j
public class TestInflate {
static Thread t1;
static Thread t2;
static int loopFlag = 20;
public static void main(String[] args) throws InterruptedException {
List<A> list = new ArrayList<>();
t1 = new Thread() {
@Override
public void run() {
for (int i = 0; i < loopFlag; i++) {
A a = new A();
list.add(a);
synchronized (a) {
log.debug(i + " " + ClassLayout.parseInstance(a).toPrintableTest(a));
}
}
log.debug("============t1 都是偏向锁=============");
//防止竞争 执行完后叫醒t2
LockSupport.unpark(t2);
}
};
t2 = new Thread() {
@Override
public void run() {
//防止竞争 先睡眠t2
LockSupport.park();
for (int i = 0; i < 30; i++) {
A a = list.get(i);
//因为从list当中拿出都是偏向t1
log.debug("101首先before " + i + " " + ClassLayout.parseInstance(a).toPrintableTest(a));
synchronized (a) {
//前20撤销偏向t1;然后升级轻量指向t2线程栈当中的锁记录 //后面的发送批量偏向t2
log.debug("前20都是000 ing " + i + " " + ClassLayout.parseInstance(a).toPrintableTest(a));
}
//因为前20是轻量,释放之后为无锁不可偏向
// 但是后面的是偏向t2 释放之后依然是偏向t2
log.debug("前20都是001 after " + i + " " + ClassLayout.parseInstance(a).toPrintableTest(a));
}
log.debug("======t2 执行完后 newA=====================================");
//默认的A的对象应该是101
log.debug("新产生的对象" + ClassLayout.parseInstance(new A()).toPrintableTest(new A()));
}
};
t1.start();
t2.start();
}
}
class A {
boolean f;
byte[] aByte = new byte[1024];
}
4.6 批量撤销
当一个偏向锁如果撤销次数到达40的时候就认为这个对象设计的有问题,那么JVM会把这个对象所对应的类所有的对象都撤销偏向锁,并且新实例化的对象也是不可偏向的。
本文地址:https://blog.csdn.net/xi_v_yu/article/details/109826365