初识synchronized

·  阅读 197

一、几种基本使用

  • 修饰实例方法,对当前实例对象this加锁
  • 修饰静态方法,对当前类的Class对象加锁
  • 修饰代码块,指定一个加锁的对象,给对象加锁

二、基本原理

在 JVM 中,对象在内存中分为三块区域:

  • 对象头
    • Mark Word(标记字段):默认存储对象的HashCode,分代年龄和锁标志位信息。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
    • Klass Point(类型指针):对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
  • 实例数据:这部分主要是存放类的数据信息,父类的信息。
  • 对其填充:由于虚拟机要求对象起始地址必须是8字节的整数倍,仅仅是为了字节对齐。

基本原理 对象头会关联到一个monitor对象。

  • 当进入一个方法的时候,执行monitorenter指令,就会获取当前对象的一个所有权,这个时候monitor进入数为1,当前的这个线程就是这个monitorowner
  • 如果你已经是这个monitorowner了,你再次进入,就会把进入数+1.
  • 同理,当他执行完monitorexit指令,对应的进入数就-1,直到为0,才可以被其他线程持有。

所谓的互斥,其实在这里,就是看你能否获得monitor的所有权,一旦你成为owner就是获得者。

三、锁膨胀

膨胀总体过程:无锁(锁对象初始化时)-> 偏向锁(有线程请求锁) -> 轻量级锁(多线程轻度竞争)-> 重量级锁(线程过多或长耗时操作,线程自旋过度

3.1 偏向锁

偏向锁是 JDK1.6 引入的一项锁优化,默认开启。这个锁会偏向于第一个获得它的线程,在接下来的执行过程中,假如该锁没有被其他线程所获取,没有其他线程来竞争该锁,那么持有偏向锁的线程将永远不需要进行同步操作。当锁对象第一次被线程获取的时候,虚拟机会将对象头中的标志位设为01,偏向模式置1,JVM在当前线程的栈帧中建立一个叫锁记录Lock Record的空间,锁记录的锁标识指向当前对象。同时通过 CAS 把获取到这个锁线程的ID记录到对象的Mark Word中,成功后持有偏向锁的线程再进入这个锁相关的同步块时虚拟机都是直接放行的。当另一个线程来经竞争这个锁,偏向模式马上结束。

执行 monitorexit 指令退出同步代码块时,将获得当前线程栈内与当前锁记录对象相关的锁记录,释放最后一条锁记录(根据锁记录条数记录重入次数),检查当前锁对象是否时偏向状态,是的话直接退出。

3.2 轻量级锁

轻量锁加锁工作过程:如果这个对象是无锁的

  1. 当线程尝试获取锁时,会在栈中创建一个锁记录,并把锁对象的Mark Word拷贝到锁记录的Displaced Mark Word
  2. 使用 CAS 尝试将锁对象的 Mark Word 更新为当前线程锁记录的指针,如果成功,表示持有锁,执行同步块,如果失败执行步骤 3
  3. 线程就会自旋,重复步骤 2;如果达到一定次数,没有获取成功,就执行步骤 4
  4. 锁升级,将线程锁对象头修改指向 monitor 的指针,然后继续执行代码块,释放重量级锁

​ 解锁过程:使用CAS将之前复制在栈桢中的Displaced Mard Word替换回Mark Word中。如果替换成功,则说明整个过程都成功执行,期间没有其他线程访问同步代码块。但如果替换失败了,表示当前线程在执行同步代码块期间,有其他线程也在访问,当前锁资源是存在竞争的,那么锁将会膨胀成重量级锁。

偏向锁 -> 轻量级锁

  • 向当前线程栈插入一条锁记录,引用字段指向当前锁对象
  • 检查当前锁状态,发现处于偏向状态。提交撤销偏向锁任务,交给VM线程处理,在safepoint状态下执行
  • 撤销偏向锁会检查JVM内持有偏向锁的线程是否存活。消亡:直接将锁对象是Mark Word修改为无锁状态。存活:遍历线程栈内的锁记录,计算出偏向线程是否在同步代码块内;若不在同步代码块内,直接将锁对象是Mark Word修改为无锁状态;若在同步代码块内,升级为轻量级锁
  • 遍历线程栈内的锁记录,找到锁记录指向当前锁对象的初始记录,修改其Displaced Mark Word为无锁状态,再修改锁对象的Mark Word为轻量级锁状态,持有这个锁,完成升级。

safepoint:安全点,此时除了VM线程外,所有线程处于阻塞状态。VM线程执行 Full GC 和撤销偏向锁等任务。

3.3 重量级锁

重要组成:

  • _owner:初始时为NULL。当有线程占有该 monitor 时,owner 标记为该线程的唯一标识。当线程释放 monitor 时,owner 又恢复为NULL。owner 是一个临界资源,JVM是通过 CAS 操作来保证其线程安全的。
  • _cxq:竞争队列,所有请求锁的线程首先会被放在这个队列中(单向链接)。_cxq 是一个临界资源,JVM通过CAS原子指令来修改 _cxq 队列。修改前 _cxq的旧值填入了node 的 next 字段,_cxq 指向新值(新线程)。因此 _cxq 是一个后进先出的stack(栈)。
  • _EntryList:并发情况下,竞争队列会被大量的并发线程进行 CAS 访问,为了降低对尾部元素的竞争,JVM 会将一部分线程移动到 EntryList 中作为候选竞争线程。
  • _WaitSet:曾经获取到锁,但是调用了wait方法,会被放在该队列中。
  • OnDeck:任意时刻,最多只有一个线程正在竞争锁资源,该线程被成为 OnDeck。OnDeck 线程获取到锁资源后会变为 Owner 线程。

加锁:Mark Word保存管程对象monitor的内存位置和重量锁状态,抢占锁的线程去管程内抢占,自旋尝试获取锁。如果获得成功,owner 指向该线程;否则说明有其他线程占用中,则会将该线程封装成一个ObjectWaiter对象插入到_cxq的队列尾部,然后暂停当前线程。当持有锁的线程释放锁前,会将_cxq中的所有元素移动到EntryList中去,并唤醒EntryList的队首线程。 释放锁:将 owner 置 null,检查竞争队列和_EntryList是否有线程等待,有的话根据策略唤醒EntryList的线程。

升级到重量级锁的情况

  • 有线程调用偏向锁对象的hashcode方法,这种的Mark Down是无法存储hash值的,因此膨胀
  • 持有的线程调用锁对象的wait()方法
  • 轻量级锁状态竞争激烈时膨胀

轻量级锁 -> 重量级锁

  • 竞争线程获取锁失败,进入锁膨胀逻辑,获取空闲的管程对象monitor,通过CAS修改锁状态为膨胀中,修改失败则自旋
  • CAS成功,将管程对象的 owner 设置为原轻量级锁持有的线程,然后将最开始的锁记录内的Dispalced Mark Word保存到管程对象内
  • 设置锁对象的Mark Word为重量级锁,修改锁对象内存位置

四、synchronized 和 Lock 的区别

  • synchronized是关键字,是JVM层面的底层啥都帮我们做了,而Lock是一个接口,是JDK层面的有丰富的API。
  • synchronized会自动释放锁,而Lock必须手动释放锁。
  • synchronized是不可中断的,Lock可以中断也可以不中断。
  • Lock可以知道线程有没有拿到锁,而synchronized不能。
  • synchronized能锁住方法和代码块,而Lock只能锁住代码块。
  • Lock可以使用读锁提高多线程读效率。
  • synchronized是非公平锁,ReentrantLock可以控制是否是公平锁。

五、volatile

5.1 可见性

现代计算机的内存模型:cpu的指令速度远超内存的存取速度,由于计算机的存储设备与处理器的运算速度有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲。将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。

基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是也为计算机系统带来更高的复杂度,因为它引入了一个新的问题:缓存一致性(CacheCoherence)。在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(MainMemory)。

JMM有以下规定:所有的共享变量都存储于主内存,这里所说的变量指的是实例变量和类变量,不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。每一个线程还存在自己的工作内存,保留了被线程使用的变量的工作副本。线程对变量的所有的操作(读,取)都必须在工作内存中完成,而不能直接读写主内存中的变量。不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存中转来完成。

正是因为这样的机制,才导致了==可见性==问题的存在,那我们就讨论下可见性的解决方案。

5.2 为啥加锁可以解决可见性问题

因为某一个线程进入synchronized代码块前后,线程会获得锁,清空工作内存,从主内存拷贝共享变量最新的值到工作内存成为副本,执行代码,将修改后的副本的值刷新回主内存中,线程释放锁。而获取不到锁的线程会阻塞等待,所以变量的值肯定一直都是最新的。

5.3 是怎么发现数据是否失效呢

嗅探:每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。

带来问题:由于volatile的MESI缓存一致性协议,需要不断的从主内存嗅探和CAS不断循环,无效交互会导致总线带宽达到峰值。

5.4 禁止指令重排

单线程不改变程序执行结果的前提下,尽可能提高执行效率,编译器和处理器常常会对既定的代码执行顺序进行指令重排序。

java编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。写屏障(Store Barrier)保证在该屏障之前的,对共享变量的改动,都同步到主存当中。读屏障(Load Barrier)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据。

java的内存屏障通常所谓的四种即 LoadLoad,StoreStore,LoadStore,StoreLoad,实际上也是上述两种的组合,完成一系列的屏障和数据同步功能:

  • LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕
  • StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
  • LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
  • StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。

写操作是在前面StoreStore(禁止上面的普通写跟下面volatile写重排序)和后面StoreLoad(禁止上面的volatile写跟下面可能有的volatile读/写重排)分别插入内存屏障,而读操作是在后面插入两个内存屏障LoadLoad(禁止下面普通读操作跟上面的volatile读重排序)-LoadStore(禁止下面所有的普通写操作和上面的volatile读重排序)。

5.5 volatile 与 synchronized 的区别

  • volatile只能修饰实例变量和类变量,而synchronized可以修饰方法,以及代码块。

  • volatile保证数据的可见性,但是不保证原子性(多线程进行写操作,不保证线程安全);而synchronized是一种排他(互斥)的机制。

  • volatile用于禁止指令重排序:可以解决单例双重检查对象初始化代码执行乱序问题。

  • volatile可以看做是轻量版的synchronizedvolatile不保证原子性,但是如果是对一个共享变量进行多个线程的赋值,而没有其他的操作,那么就可以用volatile来代替synchronized,因为赋值本身是有原子性的,而volatile又保证了可见性,所以就可以保证线程安全了。

分类:
后端
标签:
分类:
后端
标签:
收藏成功!
已添加到「」, 点击更改