synchronized

277 阅读12分钟

微信公众号:既然遇见不如同行

前言

本来计划将ConcurrentHashMap和HashMap对比着来说下,奈何看的源码有点懵逼,我在思考思考,等等有个清晰的思路在搞起来,我们先来谈一下synchronized,主要从用法,JVM两个方面来说一下;

用法

要谈用法,首先要明白什么时候我们需要使用,在并发编程中照成线程安全问题主要有2点原因:1.共享资源;2.同时操作;这个时候我们就需要保证在同一时刻只能允许一个线程访问或者操作共享资源,Java中给我们提供锁,这种方式来实现同一时刻只有一个线程可以操作共享资源,另外同一时刻访问改资源的其他线程处于等待状态,这个锁也叫做互斥锁,保证同一时刻只有一个线程访问,同时保证了内存可见性;

接下来也引入我们的重点 synchronized:

1.修饰静态方法

synchronized修饰静态方法的时候,锁的是当前类的class对象锁,看如下代码:

public class SyncClass implements Runnable {
static int i=0;
//锁的是当前类
public static synchronized void test(){
i++;
}
public void run() {
for (int j=0;j<1000000;j++){
test();
}
}
public static void main(String[] args) throws InterruptedException {
Thread thread1=new Thread(new SyncClass());
Thread thread2=new Thread(new SyncClass());
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(i);
}
}
public class SyncClass implements Runnable {
static int i=0;
//当前实例
public synchronized void test(){
i++;
}
public void run() {
for (int j=0;j<1000000;j++){
test();
}
}
public static void main(String[] args) throws InterruptedException {
Thread thread1=new Thread(new SyncClass());
Thread thread2=new Thread(new SyncClass());
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(i);
}
}

上面的方法,执行一下会发现有很大的不同,这里我们进行分析,在SyncClass中,i变量属于共享变量,当在多线程情况下调用test()静态方法修饰的同步方法的时候,没有发生因为资源共享而导致的i输出的时候小于2000000,而当调用非静态的修饰的同步方法的时候,发生因为线程共享资源导致和自己预期不一样的值,所以这个时候我们就会发现,当synchronized修饰静态方法时候是锁的当前类,修饰非静态的类方法的时候是修饰的当前实例,当然这个还需要下面在证明一下。

2.修饰非静态的方法

synchronized修饰非静态方法的时候,锁的是当对象的实例,看如下代码:

public class SyncClass implements Runnable {
static int i=0;
public synchronized void test(){
i++;
}
public void run() {
for (int j=0;j<1000000;j++){
test();
}
}
public static void main(String[] args) throws InterruptedException {
SyncClass insatance=new SyncClass();
Thread thread1=new Thread(insatance);
Thread thread2=new Thread(insatance);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(i);
}
}

上面的方法,当我们传入instance的时候,没有发生因为共享资源而导致i输出的时候小于2000000,与上面另外一种传入类的修饰非静态的方法做比较,这个时候我们可以得出,当synchronized修饰非静态的方法的时候锁的对象是当前类的实例;

3.修饰代码块

这个就是提升锁的效率,没必要每次对方法进行同步操作,看如下代码:

public class SyncClass implements Runnable {
static SyncClass instance=new SyncClass();
static int i=0;
public void test(){
i++;
}
public void run() {
//给定的实例
synchronized (instance){
for (int j=0;j<1000000;j++){
test();
}
}
//当前实例
// synchronized (this){
// for (int j=0;j<1000000;j++){
// test();
// }
// }
//当前类
// synchronized (SyncClass.class){
// for (int j=0;j<1000000;j++){
// test();
// }
// }
}
public static void main(String[] args) throws InterruptedException {
SyncClass insatance=new SyncClass();
Thread thread1=new Thread(insatance);
Thread thread2=new Thread(insatance);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(i);
}
}

用法如上,主要有3种情况,我上面进行了展示,1.锁住的是特定的对象,2.锁住的是当前实例,3.锁住的当前类;

推敲原理

synchronized是通过互斥来保证并发的正确性的问题,synchronized经过编译后,会在同步块前后形成monitorenter和monitorexit这两个字节码,其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置,当执行monitorenter指令的时,首先尝试获取对象的锁,如果当前对象没有被锁定,或者当前对象已经拥有对象的锁,那么就把锁的计数器加1,相应的在执行monitorexit的时候会将锁的计数器减1,当计数器为0的时候,锁就被释放,如果获取锁的对象失败,则当前线程就要阻塞等待,直到对象锁被另外一个线程锁释放为止----以上来自深入Java虚拟机一书;

这里我们思考下当我们拿到这个场景的时候如何去做,我们要搞清3个问题也就能实现上面场景了:

1.计数器问题;2.对象状态问题

这2个问题处理起来比较简单,就是在对象里面增加一个count属性去记录加锁和解锁以后的数量就可以,另外状态问题也随之处理完成,个数数量为0的时候处于未锁定,大于0的时候处于锁定状态;

3.线程问题

首先线程问题有两种状态,一种处于正在等待锁的状态,另外一种处于等待状态,分析清楚就很简单了,我们可以用2个队列来处理这个问题,队列里只要能记录线程Id就可以,保证能知道我们要唤醒那个线程就可以,当等待状态的队列为空的时候对象也处与为加锁状态,线程计数器也为0;

分析到这里我相信大家也比较清晰了,实现我不写了,这种问题考虑下就好了,思路为王,Java这个是通过C++去实现的,基本思路也是如此,我们重点主要来看下Java对象头包括那些,这个地方关系锁优化等等方面吧,只要明白这块的东西我相信很容易彻底掌握好synchronized;

Java对象头

synchronized用的锁是存在Java对象头里的,什么是Java的对象头,HotSpot虚拟机的对象的头分为两个部分,第一部分用于存储对象自身运行时的数据,包括哈希码,GC分代年龄等,官方称为Mark Word;另外一部分用于存储指向方法区的对象类型的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,这块在之前在介绍虚拟机的时候有说过;

Mark Word

Mark Word在32位的HotSpot虚拟机种对象处理未锁定状态下的默认存储结构

Mark Word结构
Mark Word结构

Mark Word 被设计成为一个非固定的数据结构,以便存储更多有效的数据,它会根据对象本身的状态复用自己的存储空间,如32位JVM下,除了上述列出的Mark Word默认存储结构外,还有如下可能变化的结构:
Mark Word变化的结构
Mark Word变化的结构

分析完对象头以后,我们返回到上面考虑那个场景,不要去管轻量级锁和偏向锁,这些都是JVM做的优化,这个等等再谈,将重点放到重量级锁上面来,其实我们刚刚考虑对象的设计也就是重量级锁指向的指针monitor对象设计,当然我们考虑可能没有那全面,但是该有的重点都有了,当我们创建对象的时候都会创建与monitor的关联关系,当monitor创建以后生命周期与对象创建的生命周期是一样的,同生共死,相信到这里你已经能彻底明白重量级锁的实现原理了,要是在不明白去看下反编译的源码,在考虑考虑我设计时候考虑的3个问题,我想必然会融汇贯通;

锁优化

jdk1.6对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。

锁主要存在4种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。

1.自旋锁

线程之间的切换主要是靠CPU进行处理的,来回切换肯定会给服务器照成很大压力。可是我们有些时候锁的操作并不会持续很久,这个时候就没有必要进行线程的来回切换,针对于这种状况就引入了自旋锁;什么是自旋?就是让线程循环等待,看在一定时间内持有锁的线程是否释放,当然这个是建立在多核的基础上的,一个需要执行当前线程的操作,另外一个需要判断线程是否执行完成,自旋避免的线程之间切换带来的性能消耗,但是他需要占用处理器的时间,这个时候如果持有锁的线程执行时间很长的话,在性能则是一种浪费,所以自旋等待必须要有一个度;自旋锁在JDK 1.4.2中引入,默认关闭,但是可以使用-XX:+UseSpinning开启,在JDK1.6中默认开启。同时自旋的默认次数为10次,可以通过参数-XX:PreBlockSpin来调整;JDK1.6以后引入自适应自旋锁,变得智能化,可以根据运行状况自行进行判断;

2.锁消除

这个也是虚拟机自行判断的,主要是针对不存在竞争的资源进行的优化,判断的依据主要是依据是逃逸分析的数据支持,这里简单介绍一下,逃逸分析就是分析对象作用域,这个我想用点大白话说,就是不是在本类内部使用的对象,虚拟机做的就是如果一个变量无法被其他线程所访问到,那么这个变量就不会存在竞争,就可以对这个变量修饰的同步进行锁的消除;

3.锁粗化

这个也是虚拟机自行判断的,多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁;

4.轻量级锁

轻量级锁也还是为了解决重量级锁线程切换照成性能的问题,主要是通过CAS的操作实现,接下来主要分析执行流程:

加锁流程:

1).判断当前对象是否处于无锁状态,如果是无锁状态,JVM会在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word);

2).JVM利用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指正,如果成功表示竞争到锁,则将锁标志位变成00(表示此对象处于轻量级锁状态),执行同步操作;

3).判断当前对象的Mark Word是否指向当前线程的栈帧,如果是则表示当前线程已经持有当前对象的锁,则直接执行同步代码块;否则只能说明该锁对象已经被其他线程抢占了,这时轻量级锁需要膨胀为重量级锁,锁标志位变成10,后面等待的线程将会进入阻塞状态;

释放锁流程:

1).如果对象的Mark Word还是指向的线程的锁,那么就取出在获取轻量级锁保存在Displaced Mark Word中的数据;

2).用CAS操作将取出的数据替换当前对象的Mark Word中,如果成功,则说明释放锁成功;

3). 如果CAS操作替换失败,说明有其他线程尝试获取该锁,则需要在释放锁的同时需要唤醒被挂起的线程;

这样子介绍我感觉大家还是会有疑惑,现在我们A和B线程为例,再来说一下这个流程:

1).当A和B线程同时进入无所状态的时候,这个时候都会进行复制Mark Word操作;

2).当进行CAS操作的时候只能保证有一个线程能执行成功,假设A线程执行成功这个时候A线程就会执行同步方法体,当B线程执行CAS操作的时候就会发现指向的已经不同,Mark Word变为轻量级锁的状态,这个时候CAS操作失败,B线程进入自旋获取锁的状态;

3).B线程获取自旋锁失败,这个时候Mark Word变为重量级锁,线程阻塞;当A线程执行完成同步方法体,然后在执行CAS操作的时候也是执行失败,这个时候A线程就进入等待状态;这个时候大家就回归重量级锁的状态;

5.偏向锁

偏向锁主要是为了处理在没有线程竞争的时候没必要走向轻量级锁,为了减少轻量级锁的CAS操作,接下来看下具体的处理流程:

获取锁

1).检测Mark Word是否为可偏向状态,如果为否则锁标记为01;

2).如果为偏向锁,则检查线程ID是否为当前ID,如果是则执行同步代码;

3).如果不是则进行轻量级锁的流程;

释放锁

偏向锁只有在竞争状态的情况下才会释放锁;

结束语

上面文章主要参考深入理解Java虚拟机,如果有不明白的地方可以找我,QQ群:438836709 ;下一篇预告:volatile;

欢迎大家关注
欢迎大家关注