synchronized运行原理以及优化

线程安全问题

  • 线程不安全:
    • 当多线程并发访问临界资源时(可共享的对象),如果破坏原子操作,可能会造成数据不一致。
    • 临界资源:共享资源(同一对象),一次仅允许一个线程使用,才可以保证其正确性。
    • 原子操作:不可分割的多步操作,被视作一个整体,其顺序和步骤不可打乱或缺省。

同步方式(1)

  • 同步代码块:
synchronized(临界资源对象){ //对临界资源加锁
	//代码(原子操作)
}

​ 注:

每个对象都有一个互斥锁标记,用来分配给线程的。

​ 只有拥有对象互斥锁标记的线程,才能进入对该对象加锁的同步代码块。

​ 线程退出同步代码块时,会释放响应的互斥锁标记。

线程的状态(阻塞)

同步方式(2)

  • 同步方法:
synchronized 返回值类型 方法名称(形参列表0){ //对当前对象(this)加锁
	//代码(原子操作)
}

注:只有拥有对象互斥锁标记的线程,才能进入该对象加锁的同步方法中。

​ 线程退出同步方法时,会释放相应的互斥锁标记。

特殊情况1

这种情况下方法由static修饰,因此synchronized锁的对象是类对象.(所谓类对象就是Java虚拟机中的一个模板对象,全局只有一个)

class Number{
    public static synchronized void a(){
        sleep(1);
        System.out.Println("1");
    }
    public static synchronized void b(){
        System.out.Println("2");
    }
}

public static void main(String[] args){
    Number n1 = new Number();
    new Thread(() -> {n1.a();}).start();
    new Thread(() -> {n1.b();}).start();
}

特殊情况2

这个时候b方法没有被static修饰,因此synchronized锁的对象是 this,也就是创建的实例对象,因此两个线程是同时执行

class Number{
    public static synchronized void a(){
        sleep(1);
        System.out.Println("1");
    }
    public synchronized void b(){
        System.out.Println("2");
    }
}

public static void main(String[] args){
    Number n1 = new Number();
    new Thread(() -> {n1.a();}).start();
    new Thread(() -> {n1.b();}).start();
}

特殊情况3

与上面情况相似,a方法被static修饰,锁的是类对象,b方法锁的是 this 实例对象,这个时候创建两个实例对象,会同时执行 a,b方法

class Number{
    public static synchronized void a(){
        sleep(1);
        System.out.Println("1");
    }
    public synchronized void b(){
        System.out.Println("2");
    }
}

public static void main(String[] args){
    Number n1 = new Number();
    Number n2 = new Number();
    new Thread(() -> {n1.a();}).start();
    new Thread(() -> {n2.b();}).start();
}

特殊情况4

这时候 a,b方法都被static修饰,锁的是类对象,因此即使创建了两个实例对象,方法访问的依然是同一个对象即全局唯一的类对象,这时候会先执行a或先执行b

class Number{
    public static synchronized void a(){
        sleep(1);
        System.out.Println("1");
    }
    public static synchronized void b(){
        System.out.Println("2");
    }
}

public static void main(String[] args){
    Number n1 = new Number();
    Number n2 = new Number();
    new Thread(() -> {n1.a();}).start();
    new Thread(() -> {n2.b();}).start();
}

同步规则

  • 注意:
    • 只有在调用包含同步代码块的方法,或者同步方法时,才需要对象的锁标记。
    • 如果调用不包含同步代码块的方法,或者普通方法时,则不需要锁标记,可直接调用。
  • 已知JDK中线程安全的类:
    • StringBuffer
    • Vector
    • Hashtable
    • 以上类中的公开方法,均为synchronize修饰的同步方法。

Java对象头

以32位虚拟机为例

普通对象

  • Klass Word包含了对象的类元信息
image-20210302172128304

数组对象

image-20210302172226704

其中Mark Word结构为

image-20210302172254457

synchronized运行原理

Monitor

下图的Monitor被翻译为监视器或管程(由操作系统提供)

每个Java对象都可以关联一个Monitor对象,如果使用synchronized给对象上锁(重量级)之后,该对象头的Mark Word中就被设置指向Monitor对象的指针(即上图中的Heavyweight Locked指向monitor)

  • 刚开始Monitor中Owner为null
  • 当Thread-2执行到synchronized(obj)就会将Monitor的所有者Owner置为Thread-2,Monitor中只能有一个Owner
  • 在Thread-2上锁的过程中,如果Thread-3,Thread-4,Thread-5也来执行synchronized(obj),就会进入EntryList BLOCKED
  • Thread-2执行完同步代码块的内容,然后唤醒EntryList中等待的线程来竞争锁,竞争是非公平的
  • 图中WaitSet中的Thread-0,Thread-1是之前获得过锁,但条件不满足进入WAITING状态的线程

注意:

  • Synchronized必须是进入同一个对象的monitor才会有上述效果
  • 不加synchronized的对象不会关联监视器,不遵从上述规则

为什么说重量级锁开销大呢

主要是,当系统检查到锁是重量级锁之后,会把等待想要获得锁的线程进行阻塞,被阻塞的线程不会消耗cup。但是阻塞或者唤醒一个线程时,都需要操作系统来帮忙,这就需要从用户态转换到内核态,而转换状态是需要消耗很多时间的,有可能比用户执行代码的时间还要长。

这就是说为什么重量级线程开销很大的。


synchronized锁优化

轻量级锁

轻量级锁的使用场景:如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(无竞争),那么可以使用轻量级锁来优化

轻量级锁对使用者是透明的,即语法仍然是synchronized

例如:

假设有两个方法同步块,利用同一个对象加锁

static final Object obj = new Object();
public static void method1(){
    synchronized(obj){
        //同步块A
        method2();
    }
    public static void method1(){
    synchronized(obj){
        //同步块B
    }
}
  • 当执行到method1()时,会创建一个锁记录(Lock Record)对象,每个线程的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的Mark Word
image-20210119232123252

MarkWord的结构:

image-20210119232300197
  • 接下来会让锁记录中的Object reference指向锁对象,并尝试用cas替换Object的Mark word, 将 Mark Word的值存入锁记录
image-20210119232455580
  • 如果cas替换成功,对象头中存储了锁记录地址和状态 00,表示由该线程给对象加锁,将来解锁的时候再将Mark word 恢复回去
image-20210119232649924
  • 如果cas失败,有两种情况
    • 如果是其他线程已经持有了该Object的轻量级锁,这时表明有竞争,进入锁膨胀过程
    • 如果是自己执行了synchronized锁重入,那么再添加一条Lock Record作为重入的计数
image-20210119233347096

发现object已经加锁了,但是锁的地址是自己,那么再添加一条Lock Record作为重入的计数

  • 当退出synchronized代码块(解锁)时,如果有取值为null的锁记录,表示有重入,这时重置锁记录,表示重入计数-1
image-20210119233642587
  • 当退出synchronized代码块(解锁)时,锁记录的值不为null,这时使用cas将Mark Word的值恢复给对象头
    • 成功,则解锁成功
    • 失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程

锁膨胀

如果在尝试加轻量级锁的过程中,CAS操作无法成功,这时一种情况就是有其他线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁

static Object obj = new Object();

public static void method1(){
    
    synchronized(obj){
        
        //同步代码块
    }
}
  • 当Thread-1进行轻量级加锁时,Thread-0已经对该对象加了轻量级锁
image-20210119234328305
  • 这时Thread-1加轻量级锁失败,进入锁膨胀流程
    • 即为Object对象申请Monitor锁,让Object指向重量级锁地址
    • 然后自己进入Monitor的EntryList BLOCKED
image-20210119234642924
  • 当Thread-0退出同步代码块解锁时,使用cas将Mark Word的值恢复给对象头,失败
    • 这时会进入重量级解锁流程,即按照Monitor地址找到Minitor对象,设置Owner为null,唤醒EntryList中BLOCKED线程

自旋优化

重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经推出了同步块,释放了锁),这时当前线程就可以避免阻塞.

自旋重试成功的情况:

image-20210119235527342

注意:

​ 只有多核cpu下,自旋优化才有意义,因为单核下没有多余cpu可以给其他线程用来自旋了

自旋重试失败的情况:

  • 在Java6之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋
  • Java7之后不能控制是否开启自旋功能

偏向锁

轻量级锁在没有竞争时,每次重入仍然需要执行CAS操作

Java6中引入了偏向锁来进一步优化:只有第一次使用CAS将线程ID设置到对象的Mark Word头,之后发现这个线程ID是自己的就表示没有竞争,不用重新CAS。以后只要不发生竞争,这个对象就归线程所有

例如:

static final Object obj = new Object();
public static void method1(){
    synchronized(obj){
        //同步块A
        method2();
    }
public static void method1(){
    synchronized(obj){
        //同步块B
        method3();
    }
public static void method3(){
    synchronized(obj){
        //同步块C
    }
}
image-20210120150914828 image-20210120151146037

偏向状态

image-20210120151540064

一个对象创建时:

  • 如果开启了偏向锁(默认开启),那么对象创建后,Mark Word值为0x05即最后3位为101,这时它的thread,epoch,age都为0
  • 偏向锁是默认延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加VM参数-XX:BiasedLockingStartupDelay=0来禁用延迟
  • 如果没有开启偏向锁,那么对象创建后,Mark Word值为0x01即最后3位位001,这时它的hashcode,age都为0,第一次用到hashcode时才会赋值
  • 同时可以通过添加VM参数:-XX:-UseBiasedLocking来禁用偏向锁
  • 如果说在加锁之前调用了对象的hashCode()方法,此时会禁用这个对象的偏向锁
    • 因为哈希码用的时候才产生,默认是0;只有第一次调用对象的hashcode,对象的哈希码才会产生,才在对象头的Mark Word里填充哈希码,只有当Mark Word处于正常状态时才能填充hashCode,因此会禁用掉Biased(偏向锁)
  • 当有其他线程使用偏向锁对象时,会将偏向锁升级为轻量级锁

批量重偏向

如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程T1的对象仍有机会重新偏内T2,重偏向会重置对象的Thread ID

当撤销偏向锁阈值超过20次后,JVM会认为自己是不是偏向错了,于是会再给这些对象加锁时重新偏向至加锁线程

批量撤销

当撤销偏向锁阈值超过40次之后,JVM就会觉得自己确实偏向错了,根本就不该偏向.于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的

锁清除

我们知道Java程序是一边执行一边编译的,在JVM即时编译过程中会去扫描这些代码,做逃逸分析,看看那些对象是线程安全的,那么对于这些对象的访问就不用做同步操作,减少锁带来的性能损失。

逃逸分析具体有对线程的逃逸分析和对方法的逃逸分析

拿方法逃逸举例,JVM检测某个对象在方法中被定义之后,会不会作为参数被外部方法引用

​ 如果不会,表示该对象是线程安全的,那对象不会逃逸到方法之外,那么对该变量的访问就可以不做同步操作。

事实上JVM就是这么做,在程序中对于一些不存在竞争的代码段,减少同步操作可以提升执行速度,用逃逸分析来得出那些不存在竞争的代码段,然后消除一些锁操作。

逃逸分析可以用参数-XX:+EscapeAnalysis来开启,然后参数-XX:+EliminateLocks来开启锁消除

锁粗化

大部分情况下,我们对一段程序加锁需要尽量缩小范围,因为如果范围太大,执行时间也会增加。但是也有特殊情况,我们看看下面这种情况。

public class LockDemo {
	public void fun() {
		for (int i = 0; i < 10000; ++i) {
			synchronized(LockDemo.class) {
				System.out.println("test...");
			}
		}
	}
}

上面的for循环整个过程都在频繁加锁和释放锁,非常耗费性能,因此JIT将会把加锁的过程优化到for循环外面,如下面的代码所示,这就是锁粗化的过程。

public class LockDemo {
	public void fun() {
		synchronized(LockDemo.class) {
			for (int i = 0; i < 10000; ++i) {
				System.out.println("test...");
			}
		}
	}
}

posted @ 2021-03-02 18:29  longda666  阅读(182)  评论(0)    收藏  举报