1. 什么是线程安全问题
线程的合理使用能够提升程序的处理性能,主要有两个方面,第一个是能够利用多核 cpu 以及超线程技术来实现线程的并行执行;第二个是线程的异步化执行相比于同步执行来说,异步执行能够很好的优化程序的处理性能提升并发吞吐量。同时也带来了很多麻烦。如:多线程对于共享变量访问带来的安全性问题
一个变量 i,假如一个线程去访问这个变量进行修改,这个时候对于数据的修改和访问没有任何问题。但是如果多个线程对于这同一个变量进行修改,就会存在一个数据安全性问题。
对于线程安全性,本质上是管理对于数据状态的访问,而且这个这个状态通常是共享的、可变的。共享,是指这个数据变量可以被多个线程访问;可变,指这个变量的值在它的生命周期内是可以改变的。若共享变量对于多线程来说只读不写并不存在线程安全问题。
public class SynchronizedDemo {
private static int count = 0;
private static void countIncr() {
try {
TimeUnit.MILLISECONDS.sleep(10);
}
catch (InterruptedException e) {
e.printStackTrace();
}
count++;
}
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 100; i++) {
new Thread(() -> {
countIncr();
}).start();
}
// 睡眠5秒,确保线程执行结束
TimeUnit.SECONDS.sleep(5);
System.out.println("count_result:" + count);
}
}
通过结果发现count_result有时为98或100,不是一个固定不变的值,和我们期望的结果不一样。这就是多线程对共享变量的读写带来的安全性问题。
2. 多线程的数据安全性
2.1 如何保证数据安全性
问题的本质在于共享数据存在并发访问。如果我们能够有一种方法使线程并行变成串行去访问共享数据,这样就不存问题了。
我们可以通过加锁来保证共享数据的安全问题。锁是处理并发的一种同步手段,而如果需要达到前面我们说的一个目的,那么这个锁一定需要实现互斥的特性。
java提供加锁的方法就是Synchroinzed关键字
2.2 Synchroinzed
通过Synchroinzed解决前面例子出现的线程安全问题。
// 加锁保证线程安全问题
private synchronized static void countIncr() {
try {
TimeUnit.MILLISECONDS.sleep(10);
}
catch (InterruptedException e) {
e.printStackTrace();
}
count++;
}
synchronized实现同步的基础:java中的每一个对象都可以作为锁。具体表现为以下3种形式:
- 修饰实例方法,作用于
当前实例
加锁,进入同步代码前要获得当前实例的锁 - 静态方法,作用于
当前类对象
加锁,进入同步代码前要获得当前类对象的锁 - 修饰代码块,
指定加锁对象
,对给定对象加锁,进入同步代码库前要获得给定对象的锁。
2.3 锁是如何存储的
为了实现多线程的互斥性,那么这把锁需要哪些东西呢?
- 锁需要一个东西来表示。
- 需要记录锁的状态(获得锁是什么状态,无锁是什么状态)
- 锁状态需要对多个线程共享
synchroinzed(lock)是基于lock对象来控制锁的,因此锁和这个lock对象有关。因此我们需要关注对象在JVM内存中是如何存储的。
从JVM规范中可以看出Synchroinzed在JVM的实现原理,JVM进入和退出Monior对象来实现方法同步和代码块同步,两者的实现细节不一样但是,方法的同步同样可以使用这两个指令来实现。
monitorenter指令实在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处。JVM要保证每个monitorenter必须有对应的monitorexit与之配对。任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权即尝试获取对象的锁。
2.4 JAVA对象头
在 Hotspot 虚拟机中,对象在内存中的存储布局,可以分为三个区域:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。
Synchronized用的锁存在java对象头里的。
java对象头里的Mark Word里默认存储对象的hashCode、分带年龄和锁标记位。32位JVM的Mark Word存储结构如下:
在运行期间,Mark Word里面存储的数据会随着锁标志位的变化而变化。Mark Word可能变为存储以下4种数据
可以看到,当对象状态为偏向锁时,Mark Word
存储的是偏向的线程ID;当状态为轻量级锁时,Mark Word
存储的是指向线程栈中Lock Record
的指针;当状态为重量级锁时,Mark Word
为指向堆中的monitor对象的指针。
2.5 用户态和内核态
平时我们所写的java程序是运行在用户空间的,因为我们的jvm对于操作系统来讲就是一个普通程序。用户空间的程序要执行读写硬盘、读写网络、读写内存等重要操作时必须经过操作系统内核来进行。
在JDK早期,Synchronized是重量级锁,每次申请锁都需要调用系统内核。需要从用户空间切换到内核空间,拿到锁后再将状态返回给用户空间。
2.6 CAS原理
2.6.1 什么是CAS
Compare and Swap,即比较再交换。jdk5增加了并发包java.util.concurrent.*,其下面的类使用CAS算法实现了区别于synchronouse同步锁的一种乐观锁。JDK 5之前Java语言是靠synchronized关键字保证同步的,这是一种独占锁,也是是悲观锁。
2.6.2 CAS算法理解
CAS是一种无锁算法,CAS有3个操作数,内存值N,旧的预期值E,要修改的新值V。当且仅当预期值E和内存值N相同时,将内存值N修改为V。
存在ABA问题:一个线程把数据A变为了B,然后又重新变成了A。此时另外一个线程读取的时候,发现A没有变化,就误以为是原来的那个A。此问题可以加入版本号解决,每次更新内存值后加入一个版本号进行区分。
2.7 锁的升级与对比
java 1.6后为了减少获得锁和释放锁带来的性能消耗,引入了偏向锁、轻量级锁。锁一共有4种状态,从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。这几个状态会随着竞争情况逐渐升级。锁升级后不能降级。
匿名偏向:锁对象线程ID为空,偏向锁的标识为1。
2.7.1 偏向锁
Hotspot的作者经过以往的研究发现大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,于是引入了偏向锁。
当一个线程访问加了同步锁的代码块时,会在对象头中存储当前线程的 ID,后续这个线程进入和退出这段加了同步锁的代码块时,不需要再次加锁和释放锁。而是直接比较对象头里面是否存储了指向当前线程的偏向锁。如果相等表示偏向锁是偏向于当前线程的,就不需要再尝试获得锁了。
2.7.1.1 偏向锁的获取
首先获取锁对象的Markword,检查对象头中是否存储了当前线程的ID,如果存储了当前线程ID,表示当前线程已经获得了锁。
-
如果没有存储当前线程ID,锁对象处于可偏向状态(MarkWord中的偏向锁标识为
1
且线程ID为空
)。通过 CAS 操作,把当前线程的 ID写入到 MarkWord。-
如果 cas 成功,将对象头MarkWord的线程ID指向自己(变为T1|Epoch|1|01)。表示已经获得了锁对象的偏向锁,接着执行同步代码
块。
如果 cas 失败,说明有其他线程已经获得了偏向锁,这种情况说明当前锁存在竞争,需要撤销已获得偏向锁的线程,并且把它持有的锁升级为轻量级锁。
-
如果没有存储当前线程ID,锁对象处于已偏向状态(MarkWord中的偏向锁标识为
1
且线程ID不为空
)。当前锁偏向于其他线程,需要撤销偏向锁并升级到轻量级锁如果没有存储当前线程ID,且偏向锁标识为
0
,通过 CAS 操作,将对象头MarkWord的线程ID指向自己。
2.7.1.2 偏向锁的撤销
偏向锁的撤销并不是把对象恢复到无锁可偏向状态(因为偏向锁并不存在锁释放的概念),而是在获取偏向锁的过程中,发现 cas 失败也就是存在线程竞争时,直接把被偏向的锁对象升级到被加了轻量级锁的状态。
- 原获得偏向锁的线程如果已经退出了临界区,也就是同步代码块执行完了,那么这个时候会把对象头设置成无锁状态并且争抢锁的线程可以基于 CAS 重新偏向但前线程。
- 如果原获得偏向锁的线程的同步代码块还没执行完,处于临界区之内,这个时候会把原获得偏向锁的线程升级为轻量级锁后继续执行同步代码块。
2.7.1.3 关闭偏向锁
偏向锁默认是启用的,但是它在应用程序启动几秒后才激活。如有必要可以使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。如果你确定程序所有的锁通常情况下是处于竞争状态,可以通过JVM参数关闭偏向锁--XX:-UseBiasedLocking,那么程序默认会进入轻量级锁状态。
偏向锁为什么要延迟激活?
jvm在启动过程中是有大量的线程竞争资源的,这个时候启动偏向锁是没有意义的,所以延迟开启等待JVM启动。
openjdk提供了一个查看java对象布局的工具jol-core,来验证各个状态的MarkWord。注意关注锁标志位的变化
-
JVM启动后创建对象
此时偏向锁延迟开启还未启动,创建的对象为普通对象,加锁后直接变为轻量级锁。
public class MarkWordDemo { public static void main(String[] args) throws InterruptedException { // 默认情况下偏向锁会延迟打开,此时偏向锁未启动 Object object = new Object(); System.out.println(ClassLayout.parseInstance(object).toPrintable()); System.out.println("-----------------"); synchronized (object) { System.out.println(ClassLayout.parseInstance(object).toPrintable()); } } }
-
延迟创建对象
睡眠后创建对象,此时偏向锁已经开启,创建的对象为匿名偏向对象,加锁后为偏向锁。
public class MarkWordDemo { public static void main(String[] args) throws InterruptedException { // 10后,偏向锁已启动 TimeUnit.SECONDS.sleep(10); Object object = new Object(); System.out.println(ClassLayout.parseInstance(object).toPrintable()); System.out.println("-----------------"); synchronized (object) { System.out.println(ClassLayout.parseInstance(object).toPrintable()); } } }
-
关闭延迟参数
启动参数 -XX:BiasedLockingStartupDelay=0
关闭偏向锁的延迟开启,创建的对象为匿名偏向对象,加锁后为偏向锁。结论和
2
相同public class MarkWordDemo { public static void main(String[] args) throws InterruptedException { // VM配置-XX:BiasedLockingStartupDelay=0关闭偏向锁的启动延迟 Object object = new Object(); System.out.println(ClassLayout.parseInstance(object).toPrintable()); System.out.println("-----------------"); synchronized (object) { System.out.println(ClassLayout.parseInstance(object).toPrintable()); } } }
- 关闭偏向锁
启动参数 -XX:-UseBiasedLocking,结论和1
相同
public class MarkWordDemo {
public static void main(String[] args) throws InterruptedException {
// -XX:-UseBiasedLocking
Object object = new Object();
System.out.println(ClassLayout.parseInstance(object).toPrintable());
System.out.println("-----------------");
synchronized (object) {
System.out.println(ClassLayout.parseInstance(object).toPrintable());
}
}
}