为了线程安全 - 互斥同步 - Synchronized

1. 多线程安全问题概述

1.1 临界区和竞争条件

多线程安全问题出现在多个线程访问共享资源的时候

  • 多个线程读共享资源其实没有问题
  • 在多个线程对共享资源读写操作的时候发生指令交错,就会出现问题

一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区

1.2 线程上下文切换

因为以下一些原因导致 cpu 不再执行当前的线程,转而执行另一个线程的代码

  • 线程的 cpu 时间片用完
  • 垃圾回收
  • 有更高优先级的线程需要运行 线程自己调用了 sleep、yield、wait、join、park、synchronized、lock 等方法

当 Context Switch 发生时,需要由操作系统保存当前线程的状态,并恢复另一个线程的状态,Java 中对应的概念 就是程序计数器(Program Counter Register),它的作用是记住下一条 jvm 指令的执行地址,是线程私有的

  • 状态包括程序计数器、虚拟机栈中每个栈帧的信息,如局部变量、操作数栈、返回地址等
  • Context Switch 频繁发生会影响性能

2. 关于synchronized

synchronized通过使用对象锁保证了临界区代码的原子性

为了避免临界区的竞态条件发生,可以使用阻塞式或非阻塞式的方手段达到目的

synchronized是阻塞式的解决方案,即俗称的对象锁,它采用互斥的方式让同一时刻只有一个线程能持有对象锁,其他线程再想获得这个对象锁就会被阻塞住以保证拥有锁的线程能安全的执行临界区的代码,不再担心线程的上下文切换

synchronized,是Java中用于解决并发情况下数据同步访问的一个很重要的关键字。当我们想要保证一个共享资源在同一时间只会被一个线程访问到时,我们可以在代码中使用synchronized关键字对类或者对象加锁,在Java中,synchronized有两种使用形式,同步方法和同步代码块

2.1 同步代码块

static final Object lock = new Object(); 
static int counter = 0;
 
public static void main(String[] args) {    
	synchronized (lock) {        
		counter++;    
	} 
}

2.2 同步方法

public class SynchronizedTest {
    public synchronized void doSth(){
        System.out.println("Hello World");
    }
}

① synchronized加在实例方法上的时候锁对象是当前对象(this对象)
② synchronized加在静态方法上的时候锁对象是类对象(.class对象)

3. Monitor管程

3.1 Java对象头

Java对象保存在堆内存中。在内存中,一个Java对象包含三部分:对象头实例数据对齐填充。其中对象头是一个很关键的部分,因为对象头中包含锁状态标志、线程持有的锁等标志
关于对象模型可以看这篇Java的对象模型

synchronized是悲观锁,在操作同步资源之前需要给同步资源先加锁,这把锁就是存在Java对象头里的,而Java对象头又是什么呢?

每一个Java类,在被JVM加载的时候,JVM会给这个类创建一个instanceKlass,保存在方法区,用来在JVM层表示该Java类。当我们在Java代码中,使用new创建一个对象的时候,JVM会创建一个instanceOopDesc对象,这个对象中包含了对象头以及实例数据

我们以Hotspot虚拟机为例,Hotspot的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)

Mark Word: 默认存储对象的HashCode,分代年龄和锁标志位信息。Mark Word用于存储对象自身的运行时数据,所以Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。

Klass Point: 对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

以 32 位虚拟机为例
① 普通对象
在这里插入图片描述

其中的Mark Word结构
在这里插入图片描述

从上图中可以看出,对象的状态一共有五种,分别是无锁态轻量级锁重量级锁GC标记偏向锁。在32位的虚拟机中有两个Bits是用来存储锁的标记位的,但是我们都知道,两个bits最多只能表示四种状态:00、01、10、11,那么第五种状态如何表示呢 ,就要额外依赖1Bit的空间,使用0和1来区分

在32位的HotSpot虚拟机 中对象未被锁定的状态下,Mark Word的32个Bits空间中的25Bits用于存储对象哈希码(HashCode),4Bits用于存储对象分代年龄,2Bits用于存储锁标志位,1Bit固定为0,表示非偏向锁

3.2 synchronized实现原理

关于Synchronize的实现原理,无论是同步方法还是同步代码块,无论是ACC_SYNCHRONIZED还是monitorentermonitorexit都是基于Monitor实现的

Monitor可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。每个Java对象都可以关联一个Monitor对象,如果使用synchronized给对象上锁(重量级),该对象头的Mark Word中就被设置为指向Monitor对象(Monitor对象是操作系统的对象)的指针

Monitor是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联,同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用
在这里插入图片描述

  • 刚开始的Monitor中Owner为null
  • Thread1执行synchronize(obj),就会把Monitor的所有者Owner置为Thread1,也就是Thread1获得了锁
  • Thread1上锁的过程中,Thread2和3也来执行synchronized代码块中的内容,就会进入EntryList进入阻塞状态
  • Thread1执行完synchronized中的内容,释放锁,唤醒EntryList中等待的线程来竞争锁
  • WaitSet是用来存之前获得过锁,但条件不满足进入waiting状态的线程,也就是调用了wait()方法的线程

3.3 从字节码看synchronized

static final Object lock = new Object(); 
static int counter = 0;
 
public static void main(String[] args) {    
	synchronized (lock) {        counter++;    
	} 
}

对应的字节码为

public static void main(java.lang.String[]);    
	descriptor: ([Ljava/lang/String;)V    
	flags: ACC_PUBLIC, ACC_STATIC
    Code:      
    	stack=2, locals=3, args_size=1         
    	0: getstatic     #2                  // <- 拿到lock引用 (synchronized开始)         
    	3: dup         
    	4: astore_1                          // lock引用存入slot 1  为了之后的解锁       
    	5: monitorenter                      // 将 lock对象 MarkWord 置为 Monitor 指针         
    	6: getstatic     #3                  // <- i         
    	9: iconst_1                          // 准备常数 1        
    	10: iadd                              // +1        
    	11: putstatic     #3                  // -> i        
    	14: aload_1                           // <- lock引用        
    	15: monitorexit                       // 将 lock对象 MarkWord 重置, 唤醒 EntryList        
    	16: goto          
    	24        							  // 下面的代码是对异常的情况 保证出现异常也会释放锁
    	19: astore_2                          // e -> slot 2         
    	20: aload_1                           // <- 拿到lock引用        
    	21: monitorexit                       // 将 lock对象 MarkWord 重置, 唤醒 EntryList        
    	22: aload_2                           // <- slot 2 (e)        
    	23: athrow                            // throw e        
    	24: return      
    Exception table:         
    	from    to  target type             
    	6       16    19   any            
    	19      22    19   any      
    LineNumberTable:        
    	line 8: 0        
    	line 9: 6        
    	line 10: 14        
    	line 11: 24      
    LocalVariableTable:        
    	Start  Length  Slot  Name   Signature           
    	 0       25      0   args   [Ljava/lang/String;      
    StackMapTable: number_of_entries = 2        
    	frame_type = 255 /* full_frame */          
    		offset_delta = 19          
    		locals = [ class "[Ljava/lang/String;", class java/lang/Object ]          
    		stack = [ class java/lang/Throwable ]       
    	 frame_type = 250 /* chop */         
    	  	offset_delta = 4

注意:方法级别的synchronized不会在字节码指令中有所体现

4. jvm对synchronized的优化

在说下面的优化前先提一句为什么要优化,最开始在jdk1.6以前只有synchronized的重量级锁,调用的是底层的native方法,也就是会去调用操作系统的函数,因为要操作操作系统的函数所以有用户态-->内核态的状态的切换,是重量级锁,但是对于很多方法来说并不是时时刻刻都是多线程去访问的,那我时时刻刻都去加重量级锁效率低下,所以我们才有下面的轻量级锁等优化

目前锁一共有4种状态,级别从低到高依次是:无锁 --> 偏向锁 --> 轻量级锁 --> 重量级锁。锁状态只能升级不能降级

通过上面的介绍,我们对synchronized的加锁机制以及相关知识有了一个了解,那么下面我们给出四种锁状态对应的的Mark Word内容,然后再分别讲解四种锁状态的思路以及特点:

锁状态存储内容存储内容
无锁对象的hashCode、对象分代年龄、是否是偏向锁(0)01
偏向锁偏向线程ID、偏向时间戳、对象分代年龄、是否是偏向锁(1)01
轻量级锁指向栈中锁记录的指针00
重量级锁指向互斥量(重量级锁)的指针10

4.1 自旋锁

线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。同时我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。

所以引入自旋锁,何谓自旋锁?

所谓自旋锁,就是让该线程等待一段时间,不会被立即挂起(就是不让前来获取该锁(已被占用)的线程立即阻塞),看持有锁的线程是否会很快释放锁。

怎么等待呢?
执行一段无意义的循环即可(自旋)。

自旋等待不能替代阻塞,先不说对处理器数量的要求(多核,貌似现在没有单核的处理器了),虽然它可以避免线程切换带来的开销,但是它占用了处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好;反之,自旋的线程就会白白消耗掉处理的资源,它不会做任何有意义的工作,典型的占着茅坑不拉屎,这样反而会带来性能上的浪费。

所以说,自旋等待的时间(自旋的次数)必须要有一个限度,如果自旋超过了定义的时间仍然没有获取到锁,则应该被挂起。自旋锁在JDK 1.4.2中引入,默认关闭,但是可以使用-XX:+UseSpinning来开启,在JDK1.6中默认开启。同时自旋的默认次数为10次,可以通过参数-XX:PreBlockSpin来调整。

如果通过参数-XX:preBlockSpin来调整自旋锁的自旋次数,会带来诸多不便。假如我将参数调整为10,但是系统很多线程都是等你刚刚退出的时候就释放了锁(假如你多自旋一两次就可以获取锁),你是不是很尴尬?于是JDK1.6引入自适应的自旋锁,让虚拟机会变得越来越聪明

4.2 适应自旋锁

JDK 1.6引入了更加聪明的自旋锁,即自适应自旋锁。所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定

它怎么做呢?

线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。有了自适应自旋锁,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测会越来越准确,虚拟机会变得越来越聪明

4.3 锁消除

为了保证数据的完整性,我们在进行操作时需要对这部分操作进行同步控制,但是在有些情况下,JVM检测到不可能存在共享数据竞争,这是JVM会对这些同步锁进行锁消除。锁消除的依据是逃逸分析的数据支持

所以锁消除可以节省毫无意义的请求锁的时间。变量是否逃逸,对于虚拟机来说需要使用数据流分析来确定,但是对于我们程序员来说这还不清楚么?我们会在明明知道不存在数据竞争的代码块前加上同步吗?但是有时候程序并不是我们所想的那样?

我们虽然没有显示使用锁,但是我们在使用一些JDK的内置API时,如StringBufferVectorHashTable等,这个时候会存在隐形的加锁操作

public void vectorTest(){
    Vector<String> vector = new Vector<String>();           
    for(int i = 0 ; i < 10 ; i++){
        vector.add(i + "");
     } 
      
    System.out.println(vector);
}

在运行这段代码时,JVM可以明显检测到变量vector没有逃逸出方法vectorTest()之外,所以JVM可以大胆地将vector内部的加锁操作消除

4.4 锁粗化

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

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

那什么是锁粗化?

就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。

如上面实例:vector在for循环内每次add的时候都需要加锁操作,JVM检测到对同一个对象(vector)连续加锁、解锁操作,会合并一个更大范围的加锁、解锁操作,即加锁解锁操作会移到for循环之外

4.5 轻量级锁

引入轻量级锁的主要目的是在多没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

轻量级锁不使用Monitor而是使用线程栈中的一个锁记录充当锁

**1. 使用场景:**如果一个对象虽然有多线程范访问,但是多线程访问的时间是错开的(也就是没有竞争),轻量级锁对于使用者是透明的,也就是语法依旧是synchronized,第一次加锁会尝试加轻量级锁,有竞争再换重量级锁

2. 具体流程

static final Object obj = new Object(); 
public static void method1() {    
	synchronized( obj ) {        
		// 同步块 A        
		method2();    
	} 
}
public static void method2() {
    synchronized( obj ) {        
    // 同步块 B    
    } 
}

① 判断当前对象是否处于无锁状态(hashcode、0、01),若是,在线程的栈帧创建锁记录(Lock Record)的空间(JVM层面),用于存储锁对象目前的Mark Word的拷贝,每个线程都的栈帧都会包含一个锁记录的结构
在这里插入图片描述

② 让锁记录中 Object reference 指向锁对象,并利用CAS操作尝试替换对象的Mark Word,将Mark Word的值存入锁记录
在这里插入图片描述

③ 如果 cas 替换成功(cas是一种原子性的操作),对象头中存储了 锁记录地址和状态 00 ,表示由该线程给对象加锁,这时图示如下**(00表示加了轻量级锁)**
在这里插入图片描述

④ 如果 cas 失败,有两种情况

  • 如果是其它线程已经持有了该 Object 的轻量级锁,这时表明有竞争,进入锁膨胀过程
  • 如果是自己执行了 synchronized 锁重入重入就是在这个线程内再请求这个锁),那么再添加一条 Lock Record 作为重入的计数
    在这里插入图片描述

⑤ 当退出 synchronized 代码块(解锁时)如果有取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重 入计数减一
在这里插入图片描述
⑥ 当退出 synchronized 代码块(解锁时)锁记录的值不为 null,这时使用 casMark Word 的值恢复给对象头

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

也就是说在轻量级锁中的过程中,我们并没有把对象和Monitor进行关联

轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”,这是一个经验数据。轻量级锁在当前线程的栈帧中建立一个名为锁记录的空间,用于存储锁对象目前的指向和状态。如果没有竞争,轻量级锁使用CAS操作避免了使用互斥量的开销,但如果存在锁竞争,除了互斥量的开销外,还额外发生了CAS操作,因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢

4.6 偏向锁(解决重入)

HotSpot的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得(重入)。偏向锁是为了在只有一个线程执行同步块时提高性能,轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作

也就是说,发生重入的时候在这个线程栈又会产生一条锁记录,去尝试把MarkWord替换成锁记录的地址(当然这次失败了),虽然失败了,但是他知道这个时候是同一个线程的操作,于是把锁记录保留,作为锁的重入
在这里插入图片描述
Java 6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现 这个线程 ID 是自己的就表示没有竞争,不用重新 CAS。以后只要不发生竞争,这个对象就归该线程所有

static final Object obj = new Object(); 
public static void m1() {    
	synchronized( obj ) {        
		// 同步块 A        m2();    
	} 
} 
public static void m2() {    
	synchronized( obj ) {        
		// 同步块 B        m3();    
		} 
	} 
public static void m3() {    
	synchronized( obj ) {
	// 同步块 C    } 
}

默认是先加偏向锁

调用hashcode会撤销偏向锁,因为在对象头里,如果是偏向状态,存不下32位的hash码(hash码是调用才会产生,最初全0)
在这里插入图片描述

4.7 偏向锁的撤销

1. 调用对象的hashcode
调用了对象的 hashCode,但偏向锁的对象 MarkWord 中存储的是线程 id,如果调用 hashCode 会导致偏向锁被 撤销

  • 轻量级锁会在锁记录中记录 hashCode
  • 重量级锁会在 Monitor 中记录 hashCode

2. 其他线程使用对象
当有其它线程使用偏向锁对象时,会将偏向锁升级为轻量级锁(两个线程错开访问)

3. 撤销 - 调用 wait/notify

4.8 批量重偏向

偏向锁的撤销对性能的损耗也有些大,所以对于两个线程只是错开进行,那么会优化为批量重偏向

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

当撤销偏向锁阈值超过 20 次后,jvm 会这样觉得,我是不是偏向错了呢,于是会在给这些对象加锁时重新偏向至 加锁线程

4.9 锁膨胀

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

  • 当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁
    在这里插入图片描述

  • 这时 Thread-1 加轻量级锁失败,进入锁膨胀流程;即为 Object 对象申请 Monitor 锁,让 Object 指向重量级锁地址 然后自己进入 Monitor 的 EntryList BLOCKED
    在这里插入图片描述

  • 当 Thread-0 退出同步块解锁时,使用 casMark Word 的值恢复给对象头,失败。这时会进入重量级解锁流程,即按照 Monitor 地址找到 Monitor 对象,设置 Ownernull,唤醒 EntryListBLOCKED 线程

也就是说Monitor是在这里才有的,也就是所谓的重量级锁

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值