Java并发编程之 synchronized

x33g5p2x  于2021-09-22 转载在 Java  
字(10.3k)|赞(0)|评价(0)|浏览(373)

Java中的每一个对象都可以作为锁。具体表现为以下3种形式:

  • 对于普通同步方法,锁是当前实例对象
  • 对于静态同步方法,锁是当前类的Class对象
  • 对于同步代码块,锁是synchonized括号里配置的对象

对于同步方法而言,当JVM执行引擎执行某一个方法时,其会从方法区中获取该方法的flags,检查其是否有ACC_SYNCRHONIZED标识符,若是有该标识符,则说明当前方法是同步方法,需要先获取当前对象的monitor,再来执行方法。

对于同步代码块而言,JVM是使用monitorentermonitorexit实现的:

monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处,JVM要保证每个monitorenter必须有两个对应的monitorexit与之配对(一个异常退出,一个正常退出)。任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。

每一个锁都对应一个monitor对象,在HotSpot虚拟机中它是由ObjectMonitor实现的(C++实现)。每个对象都存在着一个monitor与之关联,对象与其monitor之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个monitor被某个线程持有后,它便处于锁定状态。

ObjectMonitor() {
    _header       = NULL;
    _count        = 0;  //锁计数器
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL; //持有对象的锁的线程
    _WaitSet      = NULL; //处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; //处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }

ObjectMonitor中有两个队列_WaitSet_EntryList,用来保存ObjectWaiter对象列表(每个等待锁的线程都会被封装ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时,首先会进入_EntryList集合,当线程获取到对象的monitor后进入 _owner区域并把monitor中的_owner变量设置为当前线程,同时monitor中的计数器_count+1,若线程调用wait()方法,将释放当前持有的monitor_owner变量恢复为null_count-1,同时该线程进入_WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。

_EntryList:锁池,所有新进需要竞争同步锁的线程都会放入锁池中。比如当前对象的锁已经被一个线程得到,那么其他线程则需要在这个锁池中进行等待;当前面的线程释放掉这个同步锁后锁池中的线程再去竞争同步锁,当某个线程得到后就会进入就绪队列等待CPU进行资源分配。

_WaitSet:等待池。调用wait()方法后,线程会被放到等待池中。等待池中的线程不会去竞争同步锁,只有调用了notify()/notifyAll()方法后等待池的线程才会开始去竞争锁。notify()是随机释放等待池的一个线程到锁池中,notifyAll()则是将等待池所有的线程释放到锁池中。

synchronized如何保证原子性

线程1在执行monitorenter指令的时候,会对monitor进行加锁,加锁后其他线程无法获得锁,除非线程1主动解锁。即使在执行过程中,由于某种原因,比如CPU时间片用完,线程1放弃了CPU,但是它并没有进行解锁。而由于synchronized的锁是可重入的,下一个时间片还是只能被他自己获取到,还是会继续执行代码,直到所有代码执行完。这就保证了原子性。

synchronized如何保证有序性

synchronized修饰的代码,同一时间只能被同一线程访问。那么可以认为是单线程执行的。所以可以保证其有序性。

  • monitorenter指令和Load屏障之后,会加一个Acquire屏障,这个屏障的作用是禁止读操作和读写操作之间发生指令重排,
  • monitorexit指令前加一个Release屏障,也是禁止写操作和读写操作之间发生重排序。

synchronized如何保证可见性

JMM中关于synchronized有如下规定:

  • 线程加锁时,必须清空工作内存中共享变量的值,从而使用共享变量时需要从主内存重新读取;
  • 线程解锁时,必须把工作内存中最新的共享变量的值写入到主存,以此来保证共享变量的可见性。

具体实现则是:

  • monitorenter指令之后会有一个Load屏障,执行refresh处理器缓存操作,把别的处理器修改过的最新的值加载到自己的高速缓存中
  • monitorexit指令之后会有一个Store屏障,让线程把自己修改的变量都执行flush处理器缓存操作,刷到主内存中

Java对象头

synchronized用的锁是存在Java对象头里的。

Java对象头的长度:

Java对象头里的Mark Word里默认存储对象的HashCode、分代年龄和锁标记位。32位JVM的Mark Word的默认存储结构是这样:

在运行期间,Mark Word里存储的数据会随着锁标志位的变化而变化。Mark Word可能变化为存储以下4种数据:

在64位虚拟机下,Mark Word是64bit大小的,其存储结构是这样:

悲观锁

假设会有冲突发生,每次去读数据的时候,就会加锁,这样别的线程就获取不到锁,会一直阻塞直到锁被释放。synchronized是悲观锁

非公平锁

多个线程去获取锁的时候,会直接去尝试获取,获取不到,再去进入等待队列,如果能获取到,就直接获取到锁。synchronized是非公平锁

  • 优点:可以减少CPU唤醒线程的开销,整体的吞吐效率会高点,CPU也不必取唤醒所有线程,会减少唤起线程的数量。
  • 缺点:可能导致队列中间的线程一直获取不到锁或者长时间获取不到锁,导致饿死。

可重入锁

可重入锁指的是可重复可递归调用的锁,在外层使用锁之后,在内层仍然可以使用,并且不发生死锁(前提得是同一个对象或者class),这样的锁就叫做可重入锁。synchronized是可重入锁

class Father {
    public synchronized void doSomething() {
        System.out.println("father.doSomething() " + Thread.currentThread().getName());
    }
}

public class Main extends Father {
    public static void main(String[] args) {
        Main child = new Main();
        new Thread(child::doSomething).start();
    }

    @Override
    public synchronized void doSomething() {
        System.out.println("child.doSomething() " + Thread.currentThread().getName());
        doAnotherThing(); // 调用自己类中其他的synchronized方法
    }

    private synchronized void doAnotherThing() {
        super.doSomething(); // 调用父类的synchronized方法
        System.out.println("child.doAnotherThing() " + Thread.currentThread().getName());
    }
}

这里的对象锁只有一个,就是child对象的锁,当执行child.doSomething()时,该线程获得child对象的锁,在doSomething()方法内执行doAnotherThing()时再次请求child对象的锁,因为synchronized是可重入锁,所以可以得到该锁。

重入锁实现可重入性原理或机制是:每一个monitorObjectMonitor)都关联一个线程持有者_owner和计数器_count,当计数器为0时表示该锁没有被任何线程持有,那么任何线程都可能获得该锁而调用相应的方法;当某一线程请求成功后,JVM会记下锁的持有线程,并且将计数器置为1;此时其它线程请求该锁,则必须等待;而该持有锁的线程如果再次请求这个锁,就可以再次拿到这个锁,同时计数器会_count+1;当线程退出同步代码块时,计数器会_count-1,如果计数器为0,则释放该锁。

自旋锁

自旋锁就是指当一个线程尝试获取某个锁时,如果该锁已被其他线程占用,就一直循环检测锁是否被释放,而不是进入线程挂起或睡眠状态。

自旋锁适用于锁保护的临界区很小的情况,临界区很小的话,锁占用的时间就很短。自旋等待不能替代阻塞,虽然它可以避免线程切换带来的开销,但是它占用了CPU处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好,反之,自旋的线程就会白白消耗掉处理的资源,它不会做任何有意义的工作,典型的占着茅坑不拉屎,这样反而会带来性能上的浪费。所以说,自旋等待的时间(自旋的次数)必须要有一个限度,如果自旋超过了定义的时间仍然没有获取到锁,则应该被挂起。

自适应自旋锁

自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

线程如果自旋成功了,那么下次自旋的次数会更加多。因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。

有了自适应自旋锁,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测会越来越准确,虚拟机会变得越来越聪明。

互斥锁

对共享数据进行锁定,保证同一时刻只能有一个线程去操作

每个线程在对资源操作前都尝试先加锁,成功加锁才能操作,操作结束解锁。

互斥锁是多个线程一起去抢,抢到锁的线程(加锁)先执行,没有抢到锁的线程需要等待,等互斥锁使用完释放(解锁)后,其它等待的线程再去抢这个锁。

锁消除

如果编译器经过细致的分析后,认定一个锁只会被单个线程访问,那么这个锁可以被消除。

比如StringBuffer内部的方法都是加了synchronized关键字来加锁保证线程安全,但是如果JVM断定一个StringBuffer的实例只在一个线程内部使用,JVM就会取消StringBuffer内部的加锁过程。

-server -XX:+DoEscapeAnalysis -XX:-EliminateLocks

锁粗化

在使用同步锁的时候,需要让同步块的作用范围尽可能小—仅在共享数据的实际作用域中才进行同步,这样做的目的是 为了使需要同步的操作数量尽可能缩小,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。

在大多数的情况下,上述观点是正确的。但是如果一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,所以引入锁粗化的概念。

锁粗话概念比较好理解,就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁

比如Vector对象每次add的时候都需要加锁操作,JVM检测到对同一个Vector对象连续加锁、解锁操作,会合并一个更大范围的加锁、解锁操作,即加锁解锁操作会移到for循环之外。

synchronized的锁升级机制

JDK 1.6为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”,在Java SE 1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。

偏向锁

大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。

偏向锁的撤销

偏向锁使用了一种等到竞争出现才释放锁的机制,所以只有当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态(001);如果线程仍然活着,则尝试将Mark Word中的线程ID CAS 为竞争线程的ID,如果CAS成功,则说明该对象的偏向锁“换了一个线程去偏”;否则说明CAS失败,将对象恢复为无锁状态或升级为轻量级锁,最后唤醒暂停的线程。

偏向锁的关闭

偏向锁是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。

轻量级锁

加锁

线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录(Lock Record)的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,标志位变为“00”,即表示此对象处于轻量级锁定状态;如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁,如果自旋达到一定次数(默认10次,可以通过-XX:PreBlockSpin参数修改)仍然不能获得锁的话,就会膨胀为重量级锁。

解锁

轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头的Mark Word,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。

重量级锁

当轻量级锁膨胀到重量级锁之后,意味着线程只能被挂起阻塞等待唤醒了。每个对象中都有一个monitor监视器,而monitor依赖操作系统的mutelock(互斥锁)来实现,线程被阻塞后便进入内核调度状态,这个会导致系统在用户态和内核态来回切换,严重影响锁的性能。

monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处,JVM要保证每个monitorenter必须有对应的monitorexit与之配对。而且当一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。可以简单理解为,在加重量级锁的时候会执行monitorenter指令,解锁时会执行monitorexit指令。

因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。

这也就解释了:

为什么wait()、notify()、notifyAll()只能在synchronized方法或代码块中使用

  • 调用某个对象的wait()方法能让当前线程阻塞,前提是当前线程必须拥有此对象的monitor(锁),相当于让当前线程交出monitor
  • 调用某个对象的notify()方法能够唤醒一个正在等待这个对象的monitor的线程,如果有多个线程都在等待这个对象的monitor,则只能唤醒其中一个线程
  • 调用notifyAll()方法能够唤醒所有正在等待这个对象的monitor的线程

同时这也可以解释:

为什么wait()、notify()、notifyAll()是Object类的方法而不是Thread类的

每个对象都拥有monitor(锁),所以让线程等待某个对象的锁,当然应该通过这个对象来操作了:objectInstance.wait()、objectInstance.notify()、objectInstance.notifyAll()

三种锁的比较以及各个锁之间的转换

通过代码来查看synchronized的锁升级机制

import org.openjdk.jol.info.ClassLayout;

class Clazz {}

public class Main {
    public static void main(String[] args) throws InterruptedException {
        System.out.println("无锁(001):" + ClassLayout.parseInstance(new Clazz()).toPrintable());

        Thread.sleep(5000);

        Clazz clazz = new Clazz();
        System.out.println("启用偏向锁(101):" + ClassLayout.parseInstance(clazz).toPrintable());

        synchronized (clazz) {
            System.out.println("偏向锁(101)(带线程ID):" + ClassLayout.parseInstance(clazz).toPrintable());
        }
        System.out.println("释放偏向锁(101)(带线程ID):" + ClassLayout.parseInstance(clazz).toPrintable());

        new Thread(() -> {
            synchronized (clazz) {
                System.out.println("轻量级锁(00):" + ClassLayout.parseInstance(clazz).toPrintable());
                try {
                    System.out.println("睡眠3秒(轻量级锁在sleep时又有线程进来竞争锁,导致锁膨胀为重量级锁):");
                    Thread.sleep(3000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("轻量级锁-->重量级锁(10):" + ClassLayout.parseInstance(clazz).toPrintable());
            }
        }).start();

        Thread.sleep(1000);

        new Thread(() -> {
            synchronized (clazz) {
                System.out.println("重量级锁(10):" + ClassLayout.parseInstance(clazz).toPrintable());
            }
        }).start();
    }
}
无锁(001):Clazz object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           09 00 00 00 (00001001 00000000 00000000 00000000) (9)
      4     4        (object header)                           c0 2e 23 15 (11000000 00101110 00100011 00010101) (354627264)
Instance size: 8 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

JVM默认偏向锁的启动延时为4秒,因此sleep5秒:
启用偏向锁(101):Clazz object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           05 00 00 00 (00000101 00000000 00000000 00000000) (5)
      4     4        (object header)                           c0 2e 23 15 (11000000 00101110 00100011 00010101) (354627264)
Instance size: 8 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

偏向锁(101)(带线程ID):Clazz object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           05 cc 86 02 (00000101 11001100 10000110 00000010) (42388485)
      4     4        (object header)                           c0 2e 23 15 (11000000 00101110 00100011 00010101) (354627264)
Instance size: 8 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

释放偏向锁(101)(带线程ID):Clazz object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           05 cc 86 02 (00000101 11001100 10000110 00000010) (42388485)
      4     4        (object header)                           c0 2e 23 15 (11000000 00101110 00100011 00010101) (354627264)
Instance size: 8 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

轻量级锁(00):Clazz object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           6c f9 bb 16 (01101100 11111001 10111011 00010110) (381417836)
      4     4        (object header)                           c0 2e 23 15 (11000000 00101110 00100011 00010101) (354627264)
Instance size: 8 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

睡眠3秒(轻量级锁在sleep时又有线程进来竞争锁,导致锁膨胀为重量级锁):
轻量级锁-->重量级锁(10):Clazz object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           3e 97 d2 02 (00111110 10010111 11010010 00000010) (47355710)
      4     4        (object header)                           c0 2e 23 15 (11000000 00101110 00100011 00010101) (354627264)
Instance size: 8 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

重量级锁(10):Clazz object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           3e 97 d2 02 (00111110 10010111 11010010 00000010) (47355710)
      4     4        (object header)                           c0 2e 23 15 (11000000 00101110 00100011 00010101) (354627264)
Instance size: 8 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

Process finished with exit code 0

相关文章