为什么会有线程安全问题?
1. 在Java内存模型中规定了所有的变量都存储在主内存中,每条线程都有自己的工作内存。
2. 线程的工作内存中保存了该线程中使用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写内存。
3. 线程访问一个变量,首先将该变量从主内存中拷贝到工作内存,对变量的写操作不会马上同步到主内存。
4. 不同的线程之间也无法直接访问对方工作内存中的变量,线程之间变量的传递均需要自己的工作内存和主内存进行数据同步。
1. 实现原理
我们知道使用volatile关键字可以保证变量的可见性和有序性,不能保证原子性。Java提供了更加强大的synchronized关键字,可以保证原子性、可见性以及有序性。使用synchronized后只有一个线程可以进入临界区执行代码。
synchronized作用域:
- 普通同步方法,锁是当期实例对象
- 静态同步方法,锁是当前类的class对象
- 同步方法块,锁是括号里面的对象
那么synchronized关键字是如何保证原子性的呢?我们使用synchronized关键字对代码块进行加锁操作
public class SynchronizedTest {
public static void main(String[] args) {
synchronized(SynchronizedTest.class){
System.out.println(1);
}
}
}
部分字节码如下
public static void main(java.lang.String[]);
Code:
0: ldc #2
2: dup
3: astore_1
4: monitorenter //监视器进入,获取锁
5: getstatic #3
8: iconst_1
9: invokevirtual #4
12: aload_1
13: monitorexit //监视器退出,释放锁
14: goto 22
17: astore_2
18: aload_1
19: monitorexit
20: aload_2
21: athrow
22: return
}
同步代码块:
- 反编译成字节码后可以看到,临界区的操作会在monitorenter指令和monitorexit指令之间,而JVM需要保证每一个monitorenter指令都有一个monitorexit进行匹配。同一个时刻,只有一个线程可以获取到对象的监视器monitor,只有当线程获取到之后,才能继续执行,否则就只能等待。如果是当前线程重新进入,则monitor的冲入次数+1。这就是锁的可重入性。
- 当执行monitorexit指令时,monitor的重入次数-1,如果-1后进入次数为0,那么线程退出monitor,当前线程释放锁。
只有当线程获取到monitor的所有权时,才可以执行临界区代码块。
同步方法:
而当synchronized关键字作用于方法上时没有monitorenter指令和monitorexit指令,其常量池中多了ACC_SYNCHRONIZED标识符。通过该标识符来实现方法的同步。
当某个线程要访问某个方法的时候,会检查是否有ACC_SYNCHRONIZED标志。当方法被设置为ACC_SYNCHRONIZED时,需要先获得监视器锁,然后开始执行方法,方法执行完再释放监视器。当其他线程来尝试获取锁的时候,会进入blocked状态。如果在方法执行过程中发生了异常,并且方法内部没有处理该异常,那么异常被跑出到方法外面之前监视器锁会被自动释放。
2. Java对象头、Monitor
Java 对象头包括以下数据:
- 对象头主要包括:
-
Markword:用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,
-
Kclass: 元数据指针,指向元数据去中该对象锁代表的类描述,虚拟机通过这个指针确定对象所属的类
-
数组长度:只有数组对象有
-
- 实例数据:对象的实际数据。
- 对齐填充:对齐填充区域并不是必须存在的,知识为了起到占位作用,因为Hotspot虚拟机要求被管理的对象的大小必须是8字节的整数倍
2.1 Markword结构
Mark Word里存储的数据会随着锁标志位的变化而变化。在32位操作系统上mark word长度为32位,64位系统上是64位。
2.2 Monitor
当synchronized升级为重量级锁时,对象头中的markword就会指向monitor对象。
管程:
管程 (英语:Monitors,也称为监视器) 是一种程序结构,结构内的多个子程序(对象或模块)形成的多个工作线程互斥访问共享资源。这些共享资源一般是硬件设备或一群变量。管程实现了在一个时间点,最多只有一个线程在执行管程的某个子程序。与那些通过修改数据结构实现互斥访问的并发程序设计相比,管程实现很大程度上简化了程序设计。 管程提供了一种机制,线程可以临时放弃互斥访问,等待某些条件得到满足后,重新获得执行权恢复它的互斥访问。
Monitor是一种同步工具,或者说是一种同步机制,它通常被描述为一个对象,主要特点是互斥和信号机制
- 互斥:一个Monitor锁在同一时刻只能被一个线程占用,其他线程无法占用
- 信号机制:占用Monotor锁失败的线程会暂时放弃竞争等待某个谓词成真(条件变量),当改条件成立后,当前线程会通过释放锁通知正在等待这个条件变量的其他线程,让其可以重新获得锁。
- 想要获取monitor锁的线程,会首先进入_EntryList队列中。
- 当某个线程调用了wait()方法,则会进入_WaitSet队列中,同时释放锁。
- 如果其他线程调用notify()\notifyAll()方法,会唤醒_WaitSet中的某个线程,会再次尝试获取monitor锁,获取成功就会进入_Owner区域
- _Owner中的线程执行完同步方法后,就会退出临界区,并将monitor的owner设为null,释放监视器锁。
3. 锁优化
在使用synchronized之后,同一时刻只有一个线程能够获得对象的监视器,从而进入到同步代码块或者同步方法之中,是一种重量级锁。在Java1.6后对Synchronized进行了许多优化,引入了偏向锁和轻量级锁。
3.1 自旋锁
线程的阻塞和唤醒需要CPU从用户态切换到内核态。频繁的唤醒和阻塞对CPU来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。同时,我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间。为了这一段很短的时间,频繁的切换和唤醒线程是非常不值得的。
自旋锁就是当一个线程尝试获取某个锁时,如果该锁已经被其他线程占用,就一直循环检测锁是否被释放,而不是进入线程挂起或睡眠状态。
同时自旋锁并不能替代阻塞,当临界区执行时间很短的时候使用自旋锁,那么效率就很好。反之,自旋的线程就会白白的消耗处理器的资源。所以说,自选等待的时间(自旋次数)必须有一个限度,如果超过了定义的时间仍然没有获取到锁,则应该被挂起。
3.1.1 适应自旋锁
在上一节中我们提到了必须为自旋锁设置一个高效的自旋次数,来避免过长的等待时间。而如何选择自旋次数就是一个问题。
因此JDK1.6引入了适应自旋锁,自旋的次数不再是固定的,而是由前一次同一个锁上的自旋时间及锁的拥有者的状态来决定。
- 如果在同一个锁对象上,自旋等待刚刚获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功的,进而它将会允许线程自旋相对更长的时间。 2. 如果对于一个锁,很少自旋能够成功,则会减少自旋的时间甚至直接进入阻塞的状态,避免浪费处理器资源。
3.2 锁消除
加锁的主要原因是线程间存在竞争,如果线程之间不存在竞争那就不需要进行加锁操作,JVM会对这些同步操作进行锁消除。而锁消除的依据是逃逸分析的数据支持。
3.3 锁粗化
在同步的时候我们希望只对产生竞争的一段代码进行加锁操作,减少不必要的因加锁带来的额外的等待时间。 然而,如果是一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,所以引入锁粗话的概念,就是将多个连续的加锁、解锁操作连接在一起,扩展成一个更大的锁。
3.4 锁的升级
锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,锁只能升级不能降级。
3.4.1 偏向锁
大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。
3.4.1.1 偏向锁的获取
当线程进入同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁的偏向ID,以后线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需要简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。
- 检测Mark Word是否为可偏向状态,即是否为偏向锁1,锁标志位位01;
- 若为可偏向状态,则测试当前对象头的Mark Word存储的线程ID是否为当前线程ID。如果是,则执行步骤(5),否则执行步骤(3)
- Mark Word中存储的线程ID不是当前线程ID,尝试使用CAS竞争锁,即使用CAS将对象头中的偏向锁指向当前线程,如果竞争失败执行(4)
- 通过CAS竞争锁失败,证明当前存在多线程竞争的情况,当到达安全点时,获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行。
- 执行同步代码块
3.4.1.2 偏向锁的撤销
偏向锁使用了一种等到竞争才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。
偏向锁的撤销过程
线程1展示了锁的获取过程,线程2展示了偏向锁的撤销过程
3.4.2 轻量级锁
轻量级锁时为了在线程交替执行同步块时提高性能,而偏向锁则是在只有一个线程执行同步块时进一步提高性能。关闭偏向锁功能或者多个线程竞争偏向锁会导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁。
3.4.2.1 轻量级锁加锁
- 线程在执行同步块之前,JVM会先在当前线程的栈帧中创建用于存储锁记录的空间
- 尝试将对象头中的Mark Word复制到锁记录中
- 使用CAS将对象头中的Mark Word 替换为锁记录的指针
- 如果成功,当前线程获得锁
- 如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋锁来获取锁。
3.4.2.2 轻量级锁解锁
- 使用原子的CAS操作将Displaced Mark Word替换回到对象头。
- 如果成功,则表示没有竞争发生。
- 如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。 线程1展示了轻量级锁的获取过程,线程2展示了偏向锁的撤销过程。
3.4.3 重量级锁
synchronized是通过对象内部的监视器锁(monitor)来实现的。而Monitor的在操作系统层面又是基于Mutex Lock来实现的。执行Mutex Lock指令需要从用户态切换到内核态,这种切换的成本非常高。
3.4.4.4 三种锁的对比
| 锁 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法金存在纳秒级别的差距 | 如果线程间存在竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步块的场景 |
| 轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度 | 如果线程时钟得不到锁竞争的线程使用自旋会消耗CPU | 追求响应时间, 同步块执行速度非常快 |
| 重量级锁 | 线程竞争不适用自旋,不会消耗CPU | 线程阻塞,相应时间非常缓慢 | 追求吞吐量。 同步块执行速度较长 |