并发编程三、Synchronized和Volatitle

前言:
  1. 文章内容:线程与进程、线程生命周期、线程中断、线程常见问题总结
  2. 本文章内容来源于笔者学习笔记,内容可能与相关书籍内容重合
  3. 偏向于知识核心总结,非零基础学习文章,可用于知识的体系建立,核心内容复习,如有帮助,十分荣幸
  4. 相关文献:并发编程实战、计算机原理

Synchronized实现原理:

  1. 同步代码块:通过monitorenter和monitorexit指令。其中monitorenter指向同步代码块开始位置,monitorexit指向同步代码块结束位置。每个对象都有一个监视器锁(monitor对象),当monitor被占用时就会处于锁定状态。
  2. 线程执行monitorneter指令尝试获取monitor的所有权。monitor进入数为0,代表可以进入,线程占有monitor后设置进入数为1。线程占有后重新进入,则进入数加1。如果其他线程占用了montor,则该线程进入阻塞,直到monitor进入数为0,再重新尝试获取所有权。执行monitorexit的线程必须是对象获得monitor的线程,指令执行时,monitor进入数减1,如果减1后为0,则线程不再占用这个monitor,其他被阻塞的线程可以尝试去获取该monitor
  • 同步方法:增加了一个ACC_SYNCHRONIZED标识,JVM根据该标识判断方法是否声明为同步方法,如果标识被设置了,执行线程将先获取monitor,获取成功后才能执行方法体,执行完后释放monitor。
  • Synchronized语义底层是通过一个Monitor机制和对象头来完成,wait/notify等方法也依赖于Monitor对象,这也是为什么只有在同步块或同步方法中才能调用wait/notify等方法,否则会抛出illegalMonitorStateException异常。

Java对象头(Synchronized的优化)

  每个对象分为对象头、实例数据和对齐填充。实例数据包括父类的属性信息及类属性数据信息,对齐填充是为了满足虚拟机要求对象起始地址必须是8字节的整数倍。对象头一部分是Mark Word,包含哈希码、GC分代年龄(对象在堆中经历了几次GC)、锁标志位、是否是偏向锁、偏向线程ID等。另一部分是类型指针,虚拟机通过该指针来确定这个对象是哪个类的实例。

Synchronized的优化:

  1. Java6之前,Synchronized属于重量级锁,线程切换需要从用户态转到核心态,需要消耗比较长的时间,这也是早期synchronized效率低下的原因。
  2. Java6后,从JVM层对synchronized进行了优化,通过引入偏向锁、轻量级锁、自旋锁来减少获得锁和释放锁带来的性能消耗。
  3. Synhchronized的锁升级过程:偏向锁(01)->轻量级锁(00)->重量级锁(10)。对象未加锁,标志位是01,但是偏向锁标志位是0,代表无锁。

偏向锁:

  • 获取锁:
  1. 判断锁标志位,是重量级锁、轻量级锁、还是偏向锁。是偏向锁,再判断是否是是偏向锁标志,为1代表该对象已经被加了偏向锁,为0,代表该对象是无锁。
  2. 已加了偏向锁,对比偏向线程ID是否是当前线程,是获得偏向锁,执行同步代码块。无锁状态,直接CAS替换线程ID为当前线程ID,成功则获得偏向锁,执行同步代码块。失败要进行偏向锁撤销,等待多线程竞争才会释放锁。
  3. 当出现了新线程来尝试获取锁,这时候会检查原持有偏向锁的线程状态,线程状态是未活动或已退出同步代码块,那么原持有偏向锁线程释放锁,唤醒要竞争锁的线程,再次尝试获取锁。

  • 目的:在无多线程竞争下(同一个锁由一个线程多次获得)尽量减少CAS,只需要在置换线程ID时进行一次CAS,降低获取和释放锁的消耗,轻量级锁的获取和释放会多次CAS
  • 升级/膨胀:出现多线程竞争时,检查原持有偏向锁的线程状态,发现原线程未退出同步代码块,此时偏向锁升级为轻量级锁。

轻量级锁:

  • 膨胀后原持有偏向锁的线程:原线程在栈中分配锁记录空间,拷贝对象头的mark word到自己的锁记录空间中,代表原持有偏向锁的线程获得了轻量级锁。执行完会唤醒原持有偏向锁的线程执行同步代码块,执行完就会开始轻量级解锁。
  • 新线程获取锁:当新线程竞争时,判断锁标志位是轻量级锁,则这个新线程会在栈帧中分配锁记录空间,拷贝对象头的Mark Word到新线程的锁记录中。CAS的尝试将对象头的Mark word中锁记录指针指向到自己的锁记录空间。如果CAS成功了,获得轻量级锁,执行同步代码块完,开始轻量级解锁。如果CAS失败,会进行自旋,自旋一定次数扔失败,就将当前的锁升级为重量级锁。
  • 解锁:持有轻量级锁的线程,会CAS的将之前复制在栈帧中的mark work替换回对象头的mark word。成功就释放锁,失败就代表当前线程再执行同步代码块期间,有其他线程也在访问,当前资源是存在竞争的。
  • 目的:轻量级锁考虑的是竞争锁对象的线程不多,且线程持有锁的时间也不长的情景。通过自旋等待锁释放,避免阻塞线程需要CPU从用户态转到内核态。
  • 升级/膨胀:获取锁过程CAS修改对象头中Mark word锁记录指针为当前线程的锁记录失败,或轻量级锁解锁时失败,说明该锁对象被其他线程抢占,轻量级锁会升级为重量级锁

重量级锁:

  JVM就开始采用Object Monitor机制控制各线程抢占对象的过程了,线程切换耗时,标志位为10,等待锁的线程都会进入阻塞状态。等待线程会直接阻塞,线程的挂起和唤醒涉及了两次上下文切换,消耗cpu性能低

  Monitor机制有几个关键属性:
  • _count:记录该线程获取锁的次数、_WaitSet存放处于wait状态线程的队列
  • _EntryList:存放被阻塞线程的队列、_owner:指向持有锁对象的线程
  原理:
  1. 当多个线程同时访问一段同步代码,会进入_EntryList中,当某个线程获取到对象的monitor后进入_owner区域并设置该变量为当前线程。
  2. monitor中的计数器_count+1。如线程调用wait方法,将释放当前持有的monitor,_owner变量为null,_count-1。同时该线程进入_WaitSet中等待被唤醒。
  3. 若当前线程执行完毕释放了Monitor并复位变量的值,其他线程可以尝试获取monitor。

自旋锁:

  • 目的:阻塞或唤醒线程需要CPU从用户态切换到核心态,耗费处理器时间,很多同步代码块执行时间很短,为这段时间去频繁阻塞和唤醒线程是非常不值得的
  • 原理:自旋锁让等待的线程不会立即被挂起,而是等待一段时间看持有锁的线程是否会很快释放锁,等待的线程执行一段无意义的循环(自旋)
  • 缺点及解决方案:如果持有锁的线程很快释放锁,那么自旋效率高,反之,自旋会白白消耗处理器资源。所以通过-XX:PreBlockSpin设置自旋次数,后续又退出了自适应自旋锁。JDK6默认10次

自适应自旋锁:

  • 目的:如果自旋锁设置了次数,但是大部分不到限制次数就释放了锁,那也会造成性能浪费。
  • 原理:自旋次数不固定,由前一次在同一个锁上的自旋时间及锁的拥有者状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,以后尝试获取这个锁时将可能直接阻塞线程,避免浪费处理器资源。

Synchronized如何保证可见性、有序性、原子性:

  • 可见性内存屏障:
    • Load屏障:在monitorenter指令后加Load屏障,共享变量每次读数据都强制从主内存读取最新的。
    • Store屏障:在monitorexit指令后加Store屏障,共享变量如果有变更,强制刷新到主内存
  • 有序性内存屏障:代码块内部可以重排,和外部指令不能重排
    • Acquire屏障:在monitorenter指令后加Acquire屏障,禁止读操作和读写操作之间发生重排序。
    • Release屏障 :在monitorexit指令前加Release屏障,禁止写操作和读写操作之间发生重排序。
  • 原子性:加锁和释放锁

Volatitle:

  • 作用:保证变量的内存可见性、禁止指令重排序
  • 内存可见性:volatile修饰的变量,一个线程对其进行修改,JMM会立即把该线程本地内存的共享变量值刷新到主内存,另一个线程来读该变量,JMM会禁止其从本地内存读而是从主内存读。
  • 可见性原理:
  • volatile写之前有lock前缀命令,将缓存的变量刷回主存,同时通过总线嗅探机制和mesi协议,值刷回主存后,其他处理器的缓存会把这个变量的值从主存加载到自己的缓存。
  • 有序性原理:
    • Acquire屏障:禁止volatile读操作之后的任何读写操作跟volatile读指令重排序
    • Release屏障 :禁止volatile写操作之前的任何读写操作跟volatile读指令重排序
  • 指令重排序:编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段

 

 

 

 

 

 

 

 

 

 

 

 

 

 

posted @ 2022-08-30 10:30  难得  阅读(132)  评论(0编辑  收藏  举报