更多内容请看:Java并发编程学习笔记
文章目录
Java并发编程2–synchronized深入理解
1. 由一个问题引发的思考
线程的合理使用能够提升程序的处理性能,主要有两个方
面:
- 能够利用多核 cpu 以及超线程技术来实现线程的并行执行;
- 线程的异步化执行相比于同步执行来说,合理的异步执行能够很好的优化程序的处理性能提升并
发吞吐量;
但是同样带来了很多问题:
记得之前面试碰见一个问题,i++是线程安全的吗,当时一脸懵逼,这个还有线程安全的问题?
看一下下面这个demo。
public class Demo {
private static int count = 0;
public static void inc() {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
count++;
}
public static void main(String[] args)
throws InterruptedException {
for (int i = 0; i < 1000; i++) {
//每次创建新的线程,对inc()方法进行操作
new Thread(() -> Demo.inc()).start();
}
Thread.sleep(10000);
System.out.println("运行结果" + count);
}
}
运行结果:
循环了调用了1000次,期望结果打印的count值应该是1000,但是实际运行结果是998。
2.导致线程安全的原因
从上诉demo中分析:
一个变量 i, 假如一个线程去访问这个变量进行修改,这个时候对于数据的修改和访问没有任何问题。但是如果多个线程对于这同一个变量进行修改,就会存在一个数据安全性问题。对于线程安全性,本质上是管理对于数据(或者对象)状态的访问,而且这个状态通常是共享的、可变的。
总结来说,原因有俩:
- 存在共享的数据
- 多线程共同操作共享数据;
所以,如果多个线程访问同一个共享对象,在不需额外的同步以及调用端代码不用做其他协调的情况下,这个共享对象的状态依然是正确的(正确性意味着这个对象的结果与我们预期规定的结果保持一致),那说明这个对象是线程安全的。
那,在 Java 中如何解决由于线程并行导致的数据安全性问题呢?
如果我们能够有一种方法使得线程的并行变成串行,是不是就不存在这个问题,第一个能想到什么呢?现在就可以引出来Synchronized了。
3.synchronized 的基本认识
在多线程并发编程中 synchronized 一直是元老级角色,synchronized是java内置关键字,很多人都会称呼它为重量级锁。但是,随着 Java SE 1.6 对synchronized 进行了各种优化之后,有些情况下它就并不那么重,Java SE 1.6 中为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁。
4.synchronized的加锁方式
synchronized 有三种方式来加锁,分别是
- 修饰实例方法,作用于当前实例加锁,进入同步代码前
要获得当前实例的锁 - 静态方法,作用于当前类对象加锁,进入同步代码前要
获得当前类对象的锁 - 修饰代码块,指定加锁对象,对给定对象加锁,进入同
步代码库前要获得给定对象的锁。
不同的修饰类型,代表锁的控制粒度
修改前面的demo,使用 synchronized 关键字后,可以达到数据安全的效果。
public class Demo {
private static int count = 0;
public static void inc() {
synchronized (Demo.class){
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
count++;
}
}
public static void main(String[] args)
throws InterruptedException {
for (int i = 0; i < 1000; i++) {
new Thread(() -> Demo.inc()).start();
}
Thread.sleep(10000);
System.out.println("运行结果" + count);
}
}
达到了预期效果,但是运行耗时长了很多。因为多线程会抢占锁,抢不到锁的线程会进入BLOCKED阻塞状态,等待锁的释放。
上诉解释了什么是线程安全,怎么保证线程安全以及synchronized 的使用,接下来就是对锁的深入理解了。
5.锁是如何存储的
要实现多线程的互斥特性,那这把锁需要哪些因素?
- 锁需要有一个东西来表示,比如获得锁是什么状态、无
锁状态是什么状态 - 这个状态需要对多个线程共享
观察synchronized 的整个语法发现,synchronized(lock)是基于lock这个对象的生命周期来控制锁粒度的,那是不是锁的存储和这个 lock 对象有关系呢?以对象在 jvm 内存中是如何存储作为切入点,去
看看对象里面有什么特性能够实现锁。
对象在内存中的布局
在 Hotspot 虚拟机中,对象在内存中的存储布局,可以分为三个区域:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding),synchronized用的锁是存在Java对象头里的。
Mark word 记录了对象和锁有关的信息,当某个对象被synchronized 关键字当成同步锁时,那么围绕这个锁的一系列操作都和 Mark word 有关系。在 Hotspot 中,markOop 的定义在 markOop.hpp 文件
中,代码如下
public:
// Constants
enum { age_bits = 4, //分带年龄
lock_bits = 2, //锁标识
biased_lock_bits = 1, //是否为偏向锁
max_hash_bits = BitsPerWord - age_bits - lock_bits - biased_lock_bits,
hash_bits = max_hash_bits > 31 ? 31 : max_hash_bits,
cms_bits = LP64_ONLY(1) NOT_LP64(0),
epoch_bits = 2 //偏向锁的标签
};
任何对象都可以实现锁吗?
- 首先,Java 中的每个对象都派生自 Object 类,而每个Java Object 在 JVM 内部都有一个 native 的 C++对象oop/oopDesc 进行对应。
- 线程在获取锁的时候,实际上就是获得一个监视器对象(monitor) ,monitor 可以认为是一个同步对象,所有的Java 对象是天生携带 monitor。在 hotspot 源码的markOop.hpp 文件中。
多个线程访问同步代码块时,相当于去争抢对象监视器修改对象中的锁标识,上面的代码中ObjectMonitor这个对象和线程争抢锁的逻辑有密切的关系
6.synchronized 锁的升级
hotspot 虚拟机的作者经过调查发现,大部分情况下,加锁的代码不仅仅不存在多线程竞争,而且总是由同一个线程多次获得。所以基于这样一个概率,是的 synchronized 在
JDK1.6 之后做了一些优化,为了减少获得锁和释放锁带来的性能开销,引入了偏向锁、轻量级锁的概念。在JDK1.6之后 synchronized 中,锁存在四种状态,级别从低到高依次是:无锁、偏向锁、轻量级锁、重量级锁; 会随着竞争情况逐渐升级,但是不能降级。
偏向锁
偏向锁的原理
当一个线程访问加了同步锁的代码块时,会在对象头中存储当前线程的 ID,后续这个线程进入和退出这段加了同步锁的代码块时,不需要再次加锁和释放锁。而是直接比较对象头里面是否存储了指向当前线程的偏向锁。如果相等表示偏向锁是偏向于当前线程的,就不需要再尝试获得锁了。(相当于CAS竞争)
偏向锁的获取和撤销逻辑
- 首先获取锁 对象的 Markword,判断是否处于可偏向状
态。(biased_lock=1、且 ThreadId 为空) - 如果是可偏向状态,则通过 CAS 操作,把当前线程的 ID
写入到 MarkWord
a) 如果 cas 成功,那么 markword 就会变成这样。表示已经获得了锁对象的偏向锁,接着执行同步代码块。
b) 如果 cas 失败,说明有其他线程已经获得了偏向锁,这种情况说明当前锁存在竞争,需要撤销已获得偏向锁的线程,并且把它持有的锁升级为轻量级锁(这个操作需要等到全局安全点,也就是没有线程在执行字节码)才能执行 - 如果是已偏向状态,需要检查 markword 中存储的ThreadID 是否等于当前线程的 ThreadID
a) 如果相等,不需要再次获得锁,可直接执行同步代码
块
b) 如果不相等,说明当前锁偏向于其他线程,需要撤销
偏向锁并升级到轻量级锁
一张神图,虽然挺复杂的,但是是我见过讲解偏向锁最详细的,耐心看会有一种恍然大明白的感觉,这个是链接–偏向锁及轻量锁的获取撤销流程图
注:
偏向锁的撤销并不是把对象恢复到无锁可偏向状态,而是在获取偏向锁的过程中,发现 cas 失败也就是存在线程竞争时,直接把被偏向的锁对象升级到被加了轻量级锁的状态。
轻量级锁
加锁
锁升级为轻量级锁之后,对象的 Markword 也会进行相应的的变化。升级为轻量级锁的过程:
- 线程在自己的栈桢中创建锁记录 LockRecord。
- 将锁对象的对象头中的MarkWord复制到线程的刚刚创建的锁记录中。
- 将锁记录中的 Owner 指针指向锁对象。
- 将锁对象的对象头的 MarkWord替换为指向锁记录的指针。
解锁
轻量级锁的锁释放逻辑其实就是获得锁的逆向逻辑,通过CAS 操作把线程栈帧中的 LockRecord 替换回到锁对象的MarkWord 中,如果成功表示没有竞争。如果失败,表示当前锁存在竞争,那么轻量级锁就会膨胀成为重量级锁
自旋锁
轻量级锁在加锁过程中,用到了自旋锁所谓自旋,就是指当有另外一个线程来竞争锁时,这个线程会在原地循环等待,而不是把该线程给阻塞,直到那个获得锁的线程释放锁之后,这个线程就可以马上获得锁的。注意,锁在原地循环的时候,是会消耗 cpu 的,就相当于在执行一个啥也没有的 for 循环。所以,轻量级锁适用于那些同步代码块执行的很快的场景,这样,线程原地等待很短的时间就能够获得锁了。
重量级锁
如果 Thread#1 和 Thread#2 正常交替执行,那么轻量级锁基本能够满足锁的需求。但是如果 Thread#1 和 Thread#2同时进入临界区,那么轻量级锁就会膨胀为重量级锁,意味着 Thread#1 线程获得了重量级锁的情况下,Thread#2就会被阻塞。