Java并发-关键字: synchronized详解

关键字: synchronized详解

Synchronized的使用

在应用Sychronized关键字时需要把握如下注意点:

  • 一把锁只能同时被一个线程获取,没有获得锁的线程只能等待;
  • 每个实例都对应有自己的一把锁(this),不同实例之间互不影响;例外:锁对象是*.class以及synchronized修饰的是static方法的时候,所有对象公用同一把锁
  • synchronized修饰的方法,无论方法正常执行完毕还是抛出异常,都会释放锁

对象锁

包括方法锁(默认锁对象为this,当前实例对象)和同步代码块锁(自己指定锁对象)

代码块形式:手动指定锁定对象,也可是是this,也可以是自定义的锁
  • 示例1
public class SynchronizedObjectLock implements Runnable {
    static SynchronizedObjectLock instance = new SynchronizedObjectLock();

    @Override
    public void run() {
        // 同步代码块形式——锁为this,两个线程使用的锁是一样的,线程1必须要等到线程0释放了该锁后,才能执行
        synchronized (this) {
            System.out.println("我是线程" + Thread.currentThread().getName());
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + "结束");
        }
    }

    public static void main(String[] args) {
        Thread t1 = new Thread(instance);
        Thread t2 = new Thread(instance);
        t1.start();
        t2.start();
    }
}

输出结果:

我是线程Thread-0
Thread-0结束
我是线程Thread-1
Thread-1结束
  • 示例2
public class SynchronizedObjectLock implements Runnable {
    static SynchronizedObjectLock instance = new SynchronizedObjectLock();
    // 创建2把锁
    Object block1 = new Object();
    Object block2 = new Object();

    @Override
    public void run() {
        // 这个代码块使用的是第一把锁,当他释放后,后面的代码块由于使用的是第二把锁,因此可以马上执行
        synchronized (block1) {
            System.out.println("block1锁,我是线程" + Thread.currentThread().getName());
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("block1锁,"+Thread.currentThread().getName() + "结束");
        }

        synchronized (block2) {
            System.out.println("block2锁,我是线程" + Thread.currentThread().getName());
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("block2锁,"+Thread.currentThread().getName() + "结束");
        }
    }

    public static void main(String[] args) {
        Thread t1 = new Thread(instance);
        Thread t2 = new Thread(instance);
        t1.start();
        t2.start();
    }
}

输出结果:

block1锁,我是线程Thread-0
block1锁,Thread-0结束
block2锁,我是线程Thread-0  // 可以看到当第一个线程在执行完第一段同步代码块之后,第二个同步代码块可以马上得到执行,因为他们使用的锁不是同一把
block1锁,我是线程Thread-1
block2锁,Thread-0结束
block1锁,Thread-1结束
block2锁,我是线程Thread-1
block2锁,Thread-1结束
方法锁形式:synchronized修饰普通方法,锁对象默认为this
public class SynchronizedObjectLock implements Runnable {
    static SynchronizedObjectLock instance = new SynchronizedObjectLock();

    @Override
    public void run() {
        method();
    }

    public synchronized void method() {
        System.out.println("我是线程" + Thread.currentThread().getName());
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + "结束");
    }

    public static void main(String[] args) {
        Thread t1 = new Thread(instance);
        Thread t2 = new Thread(instance);
        t1.start();
        t2.start();
    }
}

输出结果:

我是线程Thread-0
Thread-0结束
我是线程Thread-1
Thread-1结束

类锁

指synchronize修饰静态的方法或指定锁对象为Class对象

synchronize修饰静态方法
  • 示例1
public class SynchronizedObjectLock implements Runnable {
    static SynchronizedObjectLock instance1 = new SynchronizedObjectLock();
    static SynchronizedObjectLock instance2 = new SynchronizedObjectLock();

    @Override
    public void run() {
        method();
    }

    // synchronized用在普通方法上,默认的锁就是this,当前实例
    public synchronized void method() {
        System.out.println("我是线程" + Thread.currentThread().getName());
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + "结束");
    }

    public static void main(String[] args) {
        // t1和t2对应的this是两个不同的实例,所以代码不会串行
        Thread t1 = new Thread(instance1);
        Thread t2 = new Thread(instance2);
        t1.start();
        t2.start();
    }
}

输出结果:

我是线程Thread-0
我是线程Thread-1
Thread-1结束
Thread-0结束
  • 示例2
public class SynchronizedObjectLock implements Runnable {
    static SynchronizedObjectLock instance1 = new SynchronizedObjectLock();
    static SynchronizedObjectLock instance2 = new SynchronizedObjectLock();

    @Override
    public void run() {
        method();
    }

    // synchronized用在静态方法上,默认的锁就是当前所在的Class类,所以无论是哪个线程访问它,需要的锁都只有一把
    public static synchronized void method() {
        System.out.println("我是线程" + Thread.currentThread().getName());
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + "结束");
    }

    public static void main(String[] args) {
        Thread t1 = new Thread(instance1);
        Thread t2 = new Thread(instance2);
        t1.start();
        t2.start();
    }
}

输出结果:

我是线程Thread-0
Thread-0结束
我是线程Thread-1
Thread-1结束
synchronized指定锁对象为Class对象
public class SynchronizedObjectLock implements Runnable {
    static SynchronizedObjectLock instance1 = new SynchronizedObjectLock();
    static SynchronizedObjectLock instance2 = new SynchronizedObjectLock();

    @Override
    public void run() {
        // 所有线程需要的锁都是同一把
        synchronized(SynchronizedObjectLock.class){
            System.out.println("我是线程" + Thread.currentThread().getName());
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + "结束");
        }
    }

    public static void main(String[] args) {
        Thread t1 = new Thread(instance1);
        Thread t2 = new Thread(instance2);
        t1.start();
        t2.start();
    }
}

输出结果:

我是线程Thread-0
Thread-0结束
我是线程Thread-1
Thread-1结束

Synchronized原理分析

加锁和释放锁的原理

现象、时机(内置锁this)、深入JVM看字节码(反编译看monitor指令)

深入JVM看字节码,创建如下的代码:

public class SynchronizedDemo2 {

    Object object = new Object();
    public void method1() {
        synchronized (object) {

        }
        method2();
    }

    private static void method2() {

    }
}

使用javac命令进行编译生成.class文件

>javac SynchronizedDemo2.java

使用javap命令反编译查看.class文件的信息

>javap -verbose SynchronizedDemo2.class

得到如下的信息:

在这里插入图片描述
关注红色方框里的monitorentermonitorexit即可。

MonitorenterMonitorexit指令,会让对象在执行,使其锁计数器加1或者减1。每一个对象在同一时间只与一个monitor(锁)相关联,而一个monitor在同一时间只能被一个线程获得,一个对象在尝试获得与这个对象相关联的Monitor锁的所有权的时候,monitorenter指令会发生如下3中情况之一:

  • monitor计数器为0,意味着目前还没有被获得,那这个线程就会立刻获得然后把锁计数器+1,一旦+1,别的线程再想获取,就需要等待
  • 如果这个monitor已经拿到了这个锁的所有权,又重入了这把锁,那锁计数器就会累加,变成2,并且随着重入的次数,会一直累加
  • 这把锁已经被别的线程获取了,等待锁释放

monitorexit指令:释放对于monitor的所有权,释放过程很简单,就是讲monitor的计数器减1,如果减完以后,计数器不是0,则代表刚才是重入进来的,当前线程还继续持有这把锁的所有权,如果计数器变成0,则代表当前线程不再拥有该monitor的所有权,即释放锁。

下图表现了对象,对象监视器,同步队列以及执行线程状态之间的关系:

在这里插入图片描述
该图可以看出,任意线程对Object的访问,首先要获得Object的监视器,如果获取失败,该线程就进入同步状态,线程状态变为BLOCKED,当Object的监视器占有者释放后,在同步队列中得线程就会有机会重新获取该监视器。

可重入原理:加锁次数计数器

  • 什么是可重入?可重入锁

可重入:(来源于维基百科)若一个程序或子程序可以“在任意时刻被中断然后操作系统调度执行另外一段代码,这段代码又调用了该子程序不会出错”,则称其为可重入(reentrant或re-entrant)的。即当该子程序正在运行时,执行线程可以再次进入并执行它,仍然获得符合设计时预期的结果。与多线程并发执行的线程安全不同,可重入强调对单个线程执行时重新进入同一个子程序仍然是安全的。

可重入锁:又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。

  • 看如下的例子
public class SynchronizedDemo {

    public static void main(String[] args) {
        SynchronizedDemo demo =  new SynchronizedDemo();
        demo.method1();
    }

    private synchronized void method1() {
        System.out.println(Thread.currentThread().getId() + ": method1()");
        method2();
    }

    private synchronized void method2() {
        System.out.println(Thread.currentThread().getId()+ ": method2()");
        method3();
    }

    private synchronized void method3() {
        System.out.println(Thread.currentThread().getId()+ ": method3()");
    }
}

结合前文中加锁和释放锁的原理,不难理解:

  • 执行monitorenter获取锁

    • (monitor计数器=0,可获取锁)
    • 执行method1()方法,monitor计数器+1 -> 1 (获取到锁)
    • 执行method2()方法,monitor计数器+1 -> 2
    • 执行method3()方法,monitor计数器+1 -> 3
  • 执行monitorexit命令

    • method3()方法执行完,monitor计数器-1 -> 2
    • method2()方法执行完,monitor计数器-1 -> 1
    • method2()方法执行完,monitor计数器-1 -> 0 (释放了锁)
    • (monitor计数器=0,锁被释放了)

这就是Synchronized的重入性,即在同一锁程中,每个对象拥有一个monitor计数器,当线程获取该对象锁后,monitor计数器就会加一,释放锁后就会将monitor计数器减一,线程不需要再次获取同一把锁。

保证可见性的原理:内存模型和happens-before规则

Synchronized的happens-before规则,即监视器锁规则:对同一个监视器的解锁,happens-before于对该监视器的加锁。继续来看代码:

public class MonitorDemo {
    private int a = 0;

    public synchronized void writer() {     // 1
        a++;                                // 2
    }                                       // 3

    public synchronized void reader() {    // 4
        int i = a;                         // 5
    }                                      // 6
}

该代码的happens-before关系如图所示:

在这里插入图片描述
在图中每一个箭头连接的两个节点就代表之间的happens-before关系,黑色的是通过程序顺序规则推导出来,红色的为监视器锁规则推导而出:线程A释放锁happens-before线程B加锁,蓝色的则是通过程序顺序规则和监视器锁规则推测出来happens-befor关系,通过传递性规则进一步推导的happens-before关系。现在我们来重点关注2 happens-before 5,通过这个关系我们可以得出什么?

根据happens-before的定义中的一条:如果A happens-before B,则A的执行结果对B可见,并且A的执行顺序先于B。线程A先对共享变量A进行加一,由2 happens-before 5关系可知线程A的执行结果对线程B可见即线程B所读取到的a的值为1。

JVM中锁的优化

简单来说在JVM中monitorenter和monitorexit字节码依赖于底层的操作系统的Mutex Lock来实现的,但是由于使用Mutex Lock需要将当前线程挂起并从用户态切换到内核态来执行,这种切换的代价是非常昂贵的;然而在现实中的大部分情况下,同步方法是运行在单线程环境(无锁竞争环境)如果每次都调用Mutex Lock那么将严重的影响程序的性能。不过在jdk1.6中对锁的实现引入了大量的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等技术来减少锁操作的开销

  • 锁粗化(Lock Coarsening):也就是减少不必要的紧连在一起的unlock,lock操作,将多个连续的锁扩展成一个范围更大的锁。
  • 锁消除(Lock Elimination):通过运行时JIT编译器的逃逸分析来消除一些没有在当前同步块以外被其他线程共享的数据的锁保护,通过逃逸分析也可以在线程本的Stack上进行对象空间的分配(同时还可以减少Heap上的垃圾收集开销)。
  • 偏向锁(Biased Locking):是为了在无锁竞争的情况下避免在锁获取过程中执行不必要的CAS原子指令,因为CAS原子指令虽然相对于重量级锁来说开销比较小但还是存在非常可观的本地延迟。
  • 轻量级锁(Lightweight Locking):这种锁实现的背后基于这样一种假设,即在真实的情况下我们程序中的大部分同步代码一般都处于无锁竞争状态(即单线程执行环境),在无锁竞争的情况下完全可以避免调用操作系统层面的重量级互斥锁,取而代之的是在monitorenter和monitorexit中只需要依靠一条CAS原子指令就可以完成锁的获取及释放。当存在锁竞争的情况下,执行CAS指令失败的线程将调用操作系统互斥锁进入到阻塞状态,当锁被释放的时候被唤醒(具体处理步骤下面详细讨论)。
  • 适应性自旋(Adaptive Spinning):当线程在获取轻量级锁的过程中执行CAS操作失败时,在进入与monitor相关联的操作系统重量级锁(mutex semaphore)前会进入忙等待(Spinning)然后再次尝试,当尝试一定的次数后如果仍然没有成功则调用与该monitor关联的semaphore(即互斥锁)进入到阻塞状态。

下面来详细讲解下,先从Synchronied同步锁开始讲起:

锁的类型

在Java SE 1.6里Synchronied同步锁,一共有四种状态:无锁偏向锁轻量级锁重量级锁,它会随着竞争情况逐渐升级。锁可以升级但是不可以降级,目的是为了提供获取锁和释放锁的效率。

锁膨胀方向: 无锁 → 偏向锁 → 轻量级锁 → 重量级锁 (此过程是不可逆的)

对象头

对象头类型

  1. 普通对象(2个字宽)

    • |--------------------------------------------------------------|
      |                     Object Header (64 bits)                  |
      |------------------------------------|-------------------------|
      |        Mark Word (32 bits)         |    Klass Word (32 bits) |
      |------------------------------------|-------------------------|
      
  2. 数组对象(3个字宽)

    • |---------------------------------------------------------------------------------|
      |                                 Object Header (96 bits)                         |
      |--------------------------------|-----------------------|------------------------|
      |        Mark Word(32bits)       |    Klass Word(32bits) |  array length(32bits)  |
      |--------------------------------|-----------------------|------------------------|
      

对象头的组成

  1. Mark Word:这

    部分主要用来存储对象自身的运行时数据,如hashcode、gc分代年龄等。mark word的位长度为JVM的一个字的大小。为了让一个字大小存储更多的信息,JVM将字的最低两个位设置为标记位,不同标记位下的Mark Word示意如下(64位为例):

    img

  2. Klass Word:是一个指针,指向了对象所从属的class

自旋锁

引入背景:大家都知道,在没有加入锁优化时,Synchronized是一个非常“胖大”的家伙。在多线程竞争锁时,当一个线程获取锁时,它会阻塞所有正在竞争的线程,这样对性能带来了极大的影响。在挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作对系统的并发性能带来了很大的压力。同时HotSpot团队注意到在很多情况下,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和回复阻塞线程并不值得。在如今多处理器环境下,完全可以让另一个没有获取到锁的线程在门外等待一会(自旋),但不放弃CPU的执行时间。等待持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需要让线程执行一个忙循环(自旋),这便是自旋锁由来的原因。

自旋锁早在JDK1.4 中就引入了,只是当时默认时关闭的。在JDK 1.6后默认为开启状态。自旋锁本质上与阻塞并不相同,先不考虑其对多处理器的要求,如果锁占用的时间非常的短,那么自旋锁的性能会非常的好,相反,其会带来更多的性能开销(因为在线程自旋时,始终会占用CPU的时间片,如果锁占用的时间太长,那么自旋的线程会白白消耗掉CPU资源)。因此自旋等待的时间必须要有一定的限度,如果自旋超过了限定的次数仍然没有成功获取到锁,就应该使用传统的方式去挂起线程了,在JDK定义中,自旋锁默认的自旋次数为10次,用户可以使用参数-XX:PreBlockSpin来更改。

可是现在又出现了一个问题:如果线程锁在线程自旋刚结束就释放掉了锁,那么是不是有点得不偿失。所以这时候我们需要更加聪明的锁来实现更加灵活的自旋。来提高并发的性能。(这里则需要自适应自旋锁!)

自适应自旋锁

在JDK 1.6中引入了自适应自旋锁。这就意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋 时间及锁的拥有者的状态来决定的。如果在同一个锁对象上,自旋等待刚刚成功获取过锁,并且持有锁的线程正在运行中,那么JVM会认为该锁自旋获取到锁的可能性很大,会自动增加等待时间。比如增加到100此循环。相反,如果对于某个锁,自旋很少成功获取锁。那再以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,JVM对程序的锁的状态预测会越来越准确,JVM也会越来越聪明。

锁消除

锁消除是指虚拟机即时编译器再运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持。意思就是:JVM会判断再一段程序中的同步明显不会逃逸出去从而被其他线程访问到,那JVM就把它们当作栈上数据对待,认为这些数据是线程独有的,不需要加同步。此时就会进行锁消除。

当然在实际开发中,我们很清楚的知道哪些是线程独有的,不需要加同步锁,但是在Java API中有很多方法都是加了同步的,那么此时JVM会判断这段代码是否需要加锁。如果数据并不会逃逸,则会进行锁消除。比如如下操作:在操作String类型数据时,由于String是一个不可变类,对字符串的连接操作总是通过生成的新的String对象来进行的。因此Javac编译器会对String连接做自动优化。在JDK 1.5之前会使用StringBuffer对象的连续append()操作,在JDK 1.5及以后的版本中,会转化为StringBuidler对象的连续append()操作。

public static String test03(String s1, String s2, String s3) {
    String s = s1 + s2 + s3;
    return s;
}

上述代码使用javap 编译结果

在这里插入图片描述
众所周知,StringBuilder不是安全同步的,但是在上述代码中,JVM判断该段代码并不会逃逸,则将该代码带默认为线程独有的资源,并不需要同步,所以执行了锁消除操作。(还有Vector中的各种操作也可实现锁消除。在没有逃逸出数据安全防卫内)

锁粗化

原则上,我们都知道在加同步锁时,尽可能的将同步块的作用范围限制到尽量小的范围(只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小。在存在锁同步竞争中,也可以使得等待锁的线程尽早的拿到锁)。

大部分上述情况是完美正确的,但是如果存在连串的一系列操作都对同一个对象反复加锁和解锁,甚至加锁操作时出现在循环体中的,那即使没有线程竞争,频繁的进行互斥同步操作也会导致不必要的性能操作。

这里贴上根据上述Javap 编译的情况编写的实例java类

public static String test04(String s1, String s2, String s3) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    sb.append(s3);
    return sb.toString();
}

在上述的连续append()操作中就属于这类情况。JVM会检测到这样一连串的操作都是对同一个对象加锁,那么JVM会将加锁同步的范围扩展(粗化)到整个一系列操作的 外部,使整个一连串的append()操作只需要加锁一次就可以了。

偏向锁

在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低,对象创建时采用的是偏向锁(开启了偏向锁的情况下)

偏向锁的优点:只有第一次使用CAS将线程的ID设置到锁对象的Mark Word头,之后发现这个线程ID是自己的就表示没有竞争,不用重新CAS。以后只要不发生竞争,这个对象就归该线程所有。即线程在进入和退出同步块时只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,才需要使用CAS尝试将对象头的偏向锁指向当前线程

偏向锁的缺点:偏向锁撤销的成本很高,需要在安全点下才能干净地完成。言外之意就是撤销偏向锁时,需要STW(STW的总耗时=开启安全点到所有线程进入安全点进入阻塞状态花费的时间+接触安全点到唤醒所有线程恢复运行花费的时间)。

作用:避免单个线程访问时不必要的加锁和解锁操作。(轻量级锁在没有竞争,就自己一个线程时,每次重入仍然需要执行CAS操作)
一个对象创建时:

  • 如果开启了偏向锁(JDK15之前默认开启,通过-XX:-UseBiasedLocking关闭或者-XX:+UseHeavyMonitors来设置重量级锁),那么对象创建以后,markword的值最后三位为101,这时他的thread,epoch,age都为0
  • 偏向锁默认是延迟的,不会在程序启动时立即生效(因为刚启动时会有很多线程竞争,使用偏向锁需要不断撤销),如果想避免延迟可以添加VM参数-XX:BiasedLockingStartupDelay=0来禁用延迟(延迟偏向之前创建的对象,延迟偏向后,是不会批量修改的。言外之意就是延迟偏向之前创建的对象是无锁,延迟偏向之后还是无锁。被线程访问后也不会变为偏向锁,而是轻量级锁)
  • 如果没有开启偏向锁,那么对象创建后,markword的值最后三位为001,这时他的hashcode、age都为0,第一次用到hashcode时才会赋值

CAS的操作过程和(V,O,N)三个值有关

VON
具体含义内存中地址存放的实际值预期值(旧值)更新后的值

下面就介绍一下操作的具体过程:

CAS在最开始的时候V和O的是相等的,N中的每次线程要进行CAS操作时要新放入的值。当要进行CAS操作时,要先判断一下V和O,若相等,说明没有V中的值还没有被其他线程更改,这时就可以将N中的值替换到V中。若不相等表明N中的值已经被其他的线程所更改,这时直接将N中的值返回即可;

当多个线程同时进行CAS操作时,只有一个线程会成功,并且更新V的值,其余的线程会失败。失败后可以选择不断的进行CAS操作,也可以直接挂起进行等待;

偏向锁的执行逻辑

img

撤销偏向锁

触发情况:

  • 调用对象hashcode:偏向锁的对象MarkWord中存储的时线程id,如果调用hashcode会导致偏向锁被撤销——恢复为无锁状态

    • 轻量级锁会在锁记录中记录hashcode
    • 重量级锁会在Monitor中记录hashcode
  • 其他线程使用对象:当有其他线程使用偏向锁对象时,会将偏向锁升级为轻量级锁

  • 调用wait/notify

偏向锁的撤销(Revoke) 操作并不是将对象恢复到无锁可偏向的状态, 而是在偏向锁的获取过程中, 发现了竞争时, 直接将一个被偏向的对象“升级到” 被加了轻量级锁的状态。

撤销只会出现两种状态:一种是不可偏向的无锁状态,一种是不可偏向的已锁状态(轻量级锁) ,而不会是无锁的可偏向状态

执行流程:

img

  • 通过标记位知道当前是偏向锁状态

  • 发现锁对象记录的线程id不是当前线程id

  • 进行CAS操作,尝试替换MARKWORD的线程ID

    • 替换成功,则线程获得偏向锁(此处成功说明之前线程id为空,该对象没有偏向,这是第一次进行偏向)
    • 替换失败,则说明是有其它线程尝试获取偏向锁,而对象原本已经有偏向
  • 进行锁撤销,待持有偏向锁的线程达到安全点后,暂停原持有偏向锁的线程

    1. 如果偏向的线程没有正在持有待撤销的偏向锁(已经完成了同步代码块),则重新偏向(如果允许重新偏向,批量重定向部分会讲解如何判断是否允许重新偏向)或转换为不可偏向的无锁状态。随后当前线程在栈帧创建出新的锁记录,并将Mark Word复制到锁记录中,官方称为Displaced Mark Word。随后线程尝试使用CAS将对象头中的 Mark Word 替换为指向锁记录的指针。因为当前是处于不可偏向的无锁状态(即指针为null),所以CAS成功(这期间没有第三个线程抢先拿到锁的话),当前线程获得锁。
    2. 如果偏向的线程正在持有该偏向锁,那么则升级为轻量级锁。在原有线程的栈帧中创建锁记录,同样复制MarkWord到锁记录,然后让对象头的MarkWord指向当前锁记录的指针。同时当前线程也在栈帧中创建锁记录,通用Displaced Mark Word,然后进行CAS操作尝试替换MarkWord中的锁记录指针为自己这一个线程的锁记录指针,替换失败则自旋一定次数,仍然失败则锁膨胀为重量级锁,然后当前线程被阻塞。
  • 恢复源持有偏向锁的线程,从安全点继续执行

  • 轻量级锁释放,CAS操作将栈帧中保存的原本的MarkWord恢复到对象头中(失败则说明已经膨胀成重量级锁(markword已经变为monitor的地址),则按照重量级锁的解锁流程)。

如何判断偏向所有者没有正在持有该偏向锁?

遍历线程栈拿到持有偏向锁的线程的所有lock_record,看是否能找到关联该锁的锁记录,如果找到,则正在持有,如果没找到,则没有持有。(遍历过程在一个安全点执行,此时偏向所有者被阻塞)

批量重定向

批量重偏向一个线程创建了大量对象并执行了初始的同步操作,之后在另一个线程中将这些对象作为锁进行之后的操作。这种情况下,会导致大量的偏向锁撤销操作。以class为单位,每个class维护了一个偏向锁撤销计数器,这个类的任何一个对象发生偏向撤销操作时,该计数器都会+1。当这个值达到重偏向阈值(默认20)时,JVM就认为该class的偏向锁有问题,因此会进行批量重偏向。即该类剩下的对象在被新线程调用时,不会撤销偏向锁升级为轻量级锁,而是将偏向锁的线程id指向新的线程。

实现方式:

每个class对象会有一个对应的epoch字段(记录该类重偏向的次数)每个处于偏向锁状态对象的mark word中也有该字段(创建偏向锁对象时将类的epoch字段设置到对象markword的epoch字段),其初始值为创建该对象时class中的epoch的值。每次发生批量重偏向时(上一次偏向失效),就将该值+1。当设置偏向锁时,Java 虚拟机需要将该 epoch 值复制到锁对象的标记字段中。重偏向时Java 虚拟机首先会对类的 epoch 值进行自增,随后遍历所有线程的 Java 栈,找出该类已加锁的实例,并且将它们标记字段中的 epoch 值加 1。对于已经被线程释放的锁对象,再次去申请锁时发现锁对象中的 epoch 和 类中的 epoch 不一样则自动进行重偏向,即不会执行撤销操作,而是直接通过CAS操作将其mark word的Thread Id 改成当前线程Id(CAS失败则膨胀)。

因为偏向锁的撤销大部分情形都是在安全点下执行的,安全点同步会导致系统停顿,整体性能损耗较高,所以当个某一类Klass对应的锁对象oop被累计撤销一定次数后就会触发批量重偏向,这个次数通过BiasedLockingBulkRebiasThreshold参数控制,默认是20。

批量重偏向会先将Klass的prototype_header中的epoch值加1,然后遍历所有JavaThread的所有栈帧,遍历每个栈帧中包含的BasicObjectLock,如果其关联的锁对象oop是该Klass,则增加该oop的对象头中的epoch值,遍历完成后将触发批量重偏向的这个锁对象oop重新偏向给当前线程。注意此时不在栈帧中的即未被实际占用的锁对象的oop的epoch值就不会改变,重新获取该锁对象oop的偏向锁时因为epoch值不一致就可以重新被其他线程抢占,即提高偏向锁oop的使用率。

批量撤销

当撤销偏向锁的阈值超过40次后,jvm会将整个类所有的对象都设置为不可偏向,新建的对象也是不可偏向的。

批量重偏向后撤销次数达到BiasedLockingBulkRevokeThreshold=40次,就会玩完,但时间-XX:BiasedLockingDecayTime=25000ms范围内没有达到40次,撤销次数清为0,重新计时。

轻量级锁

轻量级锁的使用场景:如果一个对象虽然有多个线程访问,但多线程访问时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。(重量级锁是由操作系统管理的锁,加锁的操作是涉及到操作系统进行互斥操作,就是会把当前线程挂起,然后操作系统进行互斥操作修改,由mutexLock来完成,之后才唤醒。使用的是操作系统来判断线程是否加锁,是一个重量级操作)。当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁。

轻量级锁是透明的,即语法仍然是synchronized。

public class LightWeightLock {
    static final Object obj = new Object();
    public static void method1() {
        synchronized (obj) {
            // 同步块1
            method2();
        }
    }
    public static void method2() {
        synchronized (obj) {
            // 同步块2
        }
    }
}
轻量级锁的工作流程
  1. 创建锁记录(Lock Record)对象,每个线程的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的Mark Word(锁记录LockRecord是线程私有的,是每个线程有自己的一份锁记录,在创建完锁记录的空间后,会将当前对象的MarkWord拷贝到锁记录中)

  2. 让锁记录中的Object reference指向锁对象,并尝试用cas替换Object对象的Mark Word,将Mark Word的值存入所记录

  3. 如果cas交换成功,对象头存储了锁记录地址和状态00(轻量级锁),表示由该线程交给对象加锁,此时图如下:

  4. 如果cas失败(交换时发现对象的Mark Word后两位已经是00),有两种情况:

    • 如果是其他线程(根据lock record判断)已经持有了该Object的轻量级锁,这时表明有竞争,进入锁膨胀过程

    • 如果是线程自己那么表明线程执行了锁重入,就添加一条锁记录作为重入的记数,如下(最后一层的display_header不为null,其他都为null)

  5. 当退出synchronized代码块(解锁时)

    • 如果有锁记录的display_header值为null的锁记录,表示有重入,这时重置锁记录,表示重入计数减1

    • 如果锁记录的display_header值不为null,这时使用cas将display_header的值恢复给对象头(即将对象头恢复成无锁,这样后面的线程才能拿到轻量级锁)

      • 成功则解锁成功
      • 失败则说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁的解锁流程
自旋

轻量级锁也称自旋锁,当出现竞争时,尝试获取锁的线程不会立即阻塞(阻塞需要进行上下文的切换,比较耗时),可以使用自旋(默认 10 次)来进行优化,采用循环的方式去尝试获取锁。

注意:

  • Java6之后的锁是自适应的,比如对象刚刚进行一次自旋操作成功过,那么认为这次自旋的可能性是比较高的,就会多自旋几次,反之就少甚至不自旋。Java7后不能控制是否开启自旋功能,由JVM控制
  • 自旋占用 CPU 时间,单核 CPU 自旋就是浪费时间,因为同一时刻只能运行一个线程,多核 CPU 自旋才能发挥优势
  • 自旋失败的线程会进入阻塞状态
  • 当自旋的线程越来越多时,会不断的消耗 CPU 资源

自旋锁工作流程:

  • 自旋成功情况:
  • 自旋失败情况:

模拟自旋锁:

@Slf4j
public class SpinLock {
  
    // 原子引用线程
    AtomicReference<Thread> atomicReference = new AtomicReference<>();
  
    public void lock() throws InterruptedException {
        Thread thread = Thread.currentThread();
        log.debug("线程{}正在获取锁,尝试使用cas交换MarkWord", thread.getName());

        // 执行cas操作,期望值为null,更新值是当前线程
        while (atomicReference.compareAndSet(null, thread)) {
            Thread.sleep(1000);
            // 锁已被其它线程持有,目标值不为期望值null,cas失败
            log.debug("cas失败,正在自旋...");
        }
  
        log.debug("自旋成功");
    }
  
    public void unlock() {
        Thread thread = Thread.currentThread();
        // 释放锁,将MarkWord的指针清空
        atomicReference.compareAndSet(thread, null);
        log.info("把指针变为null");
    }
}

重量级锁

在尝试加轻量级锁的过程中,CAS操作无法成功(可能是已经其他线程为次对象加上了轻量级锁,即存在竞争),这时就需要进行锁膨胀,将轻量级锁变成重量级锁。

锁膨胀过程:

  1. 当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁

  2. Thread-1 加轻量级锁失败,进入锁膨胀流程:为 Object 对象申请 Monitor 锁,通过 Object 对象头获取到持锁线程,将 Monitor 的 Owner 置为 Thread-0,将 Object 的对象头指向重量级锁地址,然后自己进入 Monitor 的 EntryList BLOCKED

  3. 当 Thread-0 退出同步块解锁时,使用 CAS 将 Mark Word 的值恢复给对象头失败,这时进入重量级解锁流程,即按照 Monitor 地址找到 Monitor 对象,设置 Owner 为 null,唤醒 EntryList 中 BLOCKED 线程

重量级锁工作过程:

在这里插入图片描述

  1. 线程访问同步代码,需要获取monitor锁

  2. 线程被jvm托管

  3. jvm获取充当临界区锁的java对象

  4. 根据java对象对象头中的重量级锁 ptr_to_heavyweight_monitor 指针找到objectMonitor

  5. 开始时 Monitor 中 Owner 为 null

  6. 当 Thread-2 执行 synchronized(obj) 就会将 Monitor 的所有者 Owner 置为 Thread-2,Monitor 中只能有一个 Owner,obj 对象的 Mark Word 指向 Monitor,把对象原有的 MarkWord 存入线程栈中的锁记录

  7. 在 Thread-2 上锁的过程,Thread-3、Thread-4、Thread-5 也执行 synchronized(obj),就会进入 EntryList(双向链表)BLOCKED

  8. Thread-2 执行完同步代码块的内容,根据 obj 对象头中 Monitor 地址寻找,设置 Owner 为空,把线程栈的锁记录中的对象头的值设置回 MarkWord

  9. 唤醒 EntryList 中等待的线程来竞争锁,竞争是非公平的,如果这时有新的线程想要获取锁,可能直接就抢占到了,阻塞队列的线程就会继续阻塞

  10. WaitSet 中的 Thread-0,是以前获得过锁,但条件不满足进入 WAITING 状态的线程(wait-notify 机制)

注意:

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

锁升级简图:

在这里插入图片描述
在这里插入图片描述
锁的优缺点对比

优点缺点使用场景
偏向锁加锁和解锁不需要CAS操作,没有额外的性能消耗,和执行非同步方法相比仅存在纳秒级的差距如果线程间存在锁竞争,会带来额外的锁撤销的消耗适用于只有一个线程访问同步块的场景
轻量级锁竞争的线程不会阻塞,提高了响应速度如线程始终得不到锁竞争的线程,使用自旋会消耗CPU性能追求响应时间,同步块执行速度非常快
重量级锁线程竞争不适用自旋,不会消耗CPU线程阻塞,响应时间缓慢,在多线程下,频繁的获取释放锁,会带来巨大的性能消耗追求吞吐量,同步块执行速度较长

Synchronized与Lock

synchronized的缺陷

  • 效率低:锁的释放情况少,只有代码执行完毕或者异常结束才会释放锁;试图获取锁的时候不能设定超时,不能中断一个正在使用锁的线程,相对而言,Lock可以中断和设置超时
  • 不够灵活:加锁和释放的时机单一,每个锁仅有一个单一的条件(某个对象),相对而言,读写锁更加灵活
  • 无法知道是否成功获得锁,相对而言,Lock可以拿到状态,如果成功获取锁,…,如果获取失败,…

Lock解决相应问题

Lock类这里不做过多解释,主要看里面的4个方法:

  • lock(): 加锁
  • unlock(): 解锁
  • tryLock(): 尝试获取锁,返回一个boolean值
  • tryLock(long,TimeUtil): 尝试获取锁,可以设置超时

Synchronized加锁只与一个条件(是否获取锁)相关联,不灵活,后来Condition与Lock的结合解决了这个问题。

多线程竞争一个锁时,其余未得到锁的线程只能不停的尝试获得锁,而不能中断。高并发的情况下会导致性能下降。ReentrantLock的lockInterruptibly()方法可以优先考虑响应中断。 一个线程等待时间过长,它可以中断自己,然后ReentrantLock响应这个中断,不再让这个线程继续等待。有了这个机制,使用ReentrantLock时就不会像synchronized那样产生死锁了。

ReentrantLock为常用类,它是一个可重入的互斥锁 Lock,它具有与使用 synchronized 方法和语句所访问的隐式监视器锁相同的一些基本行为和语义,但功能更强大。详细分析请看: JUC锁: ReentrantLock详解

再深入理解

synchronized是通过软件(JVM)实现的,简单易用,即使在JDK5之后有了Lock,仍然被广泛的使用。

  • 使用Synchronized有哪些要注意的?

    • 锁对象不能为空,因为锁的信息都保存在对象头里
    • 作用域不宜过大,影响程序执行的速度,控制范围过大,编写代码也容易出错
    • 避免死锁
    • 在能选择的情况下,既不要用Lock也不要用synchronized关键字,用java.util.concurrent包中的各种各样的类,如果不用该包下的类,在满足业务的情况下,可以使用synchronized关键,因为代码量少,避免出错
  • synchronized是公平锁吗?

synchronized实际上是非公平的,新来的线程有可能立即获得监视器,而在等待区中等候已久的线程可能再次等待,这样有利于提高性能,但是也可能会导致饥饿现象。

  • 14
    点赞
  • 34
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值