持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第9天,点击查看活动详情
一、synchronized基础
1-1、Java共享内存模型带来的线程安全问题
思考: 两个线程对初始值为 0 的静态变量一个做自增,一个做自减,各做 5000 次,结果是 0 吗? 下面我们用两个线程来测试一下。
1-1-1、两个线程加减的代码
@Slf4j
public class SyncDemo {
private static int counter = 0;
public static void increment() {
counter++;
}
public static void decrement() {
counter--;
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
increment();
}
}, "t1");
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
decrement();
}
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
//思考: counter=?
log.info("{}", counter);
}
}
运行测试结果,并且每次结果都不一样
1-1-2、问题分析
以上的结果可能是正数、负数、零。为什么呢?因为 Java 中对静态变量的自增,自减并不是原子操作。
我们可以查看 i++和 i--(i 为静态变量)的 JVM 字节码指令 ( 可以在idea中安装一个jclasslib插件)
1-1-2-1、i++的JVM 字节码指令
getstatic i // 获取静态变量i的值
iconst_1 // 将int常量1压入操作数栈
iadd // 自增
1-1-2-2、i--的JVM 字节码指令
getstatic i // 获取静态变量i的值
iconst_1 // 将int常量1压入操作数栈
isub // 自减
1-1-3、流程图
如果是单线程以上 8 行代码是顺序执行(不会交错)没有问题。
但多线程下这 8 行代码可能交错运行:
1-2-4、临界区( Critical Section)
- 一个程序运行多个线程本身是没有问题的
- 问题出在多个线程访问共享资源
- 多个线程读共享资源其实也没有问题
- 在多个线程对共享资源读写操作时发生指令交错,就会出现问题
一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区,其共享资源为临界资源
//临界资源
private static int counter = 0;
public static void increment() { //临界区
counter++;
}
public static void decrement() {//临界区
counter--;
}
1-2-5、竞态条件( Race Condition )
多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件
为了避免临界区的竞态条件发生,有多种手段可以达到目的:
- 阻塞式的解决方案:synchronized,Lock
- 非阻塞式的解决方案:原子变量
注意:
虽然 java 中互斥和同步都可以采用 synchronized 关键字来完成,但它们还是有区别的:
互斥是保证临界区的竞态条件发生,同一时刻只能有一个线程执行临界区代码
同步是由于线程执行的先后、顺序不同、需要一个线程等待其它线程运行到某个点
1-2、synchronized的使用
synchronized 同步块是 Java 提供的一种原子性内置锁,Java 中的每个对象都可以把它当作一个同步锁来使用,这些 Java 内置的使用者看不到的锁被称为内置锁,也叫作监视器锁。
1-2-1、加锁方式
1-2-2、解决之前的共享问题
- 方式一
private static String lock = "";
public static void increment() {
synchronized (lock){
counter++;
}
}
public static void decrement() {
synchronized (lock) {
counter--;
}
}
- 方式二
public static synchronized void increment() {
counter++;
}
public static synchronized void decrement() {
counter--;
}
运行结果:
synchronized 实际是用对象锁保证了临界区内代码的原子性
二、synchronized高级
2-1、synchronized底层原理
synchronized是JVM内置锁,基于Monitor机制实现,依赖底层操作系统的互斥原语Mutex(互斥量),它是一个重量级锁,性能较低。当然,JVM内置锁在1.5之后版本做了重大的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、自适应自旋(Adaptive Spinning)等技术来减少锁操作的开销,内置锁的并发性能已经基本与Lock持平。
The Java® Language Specification
Each object is associated with a monitor (§17.1), which is used by synchronized methods (§8.4.3) and the synchronized statement (§14.19) to provide control over concurrent access to state by multiple threads (§17 (Threads and Locks)).
The Java® Virtual Machine Specification
The Java Virtual Machine supports synchronization of both methods and sequences of instructions within a method by a single synchronization construct: the monitor.
2-1-1、在属性上添加synchronized
Java虚拟机通过一个同步结构支持方法和方法中的指令序列的同步:monitor。
同步方法是通过方法中的access_flags中设置 ACC_SYNCHRONIZED 标志来实现;同步代码块是通过 monitorenter和monitorexit 来实现。两个指令的执行是JVM通过调用操作系统的互斥原语mutex来实现,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态之间来回切换,对性能有较大影响。
还以上面count++代码添加了synchronized之后,看下字节码文件,如下:会添加monitorenter和monitorexit,但是字节码中有两个monitorext。如果代码遇到异常的时候,会自动帮助我们释放锁,而ReentrantLock在使用的时候,在释放锁的时候,需要**手动在finally代码块中添加lock.unlock()**。
2-1-2、在方法上添加synchronized
通过下图可以看到,在方法上面添加synchronized,是没有monitorenter和monitorexit的
方法的访问标记为0x0029
下图为字节码编号,这样001+008+0020=0029,这样就相当于给方法添加了ACC_SYNCHRONIZED
2-2、Monitor(管程/监视器)
Monitor,直译为“监视器”,而操作系统领域一般翻译为“管程”。管程是指管理共享变量以及对共享变量操作的过程,让它们支持并发。在Java 1.5之前,Java语言提供的唯一并发语言就是管程,Java 1.5之后提供的SDK并发包也是以管程为基础的。除了Java之外,C/C++、C#等高级语言也都是支持管程的。synchronized关键字和wait()、notify()、notifyAll()这三个方法是Java中实现管程技术的组成部分。
2-3、MESA模型
在管程的发展史上,先后出现过三种不同的管程模型,分别是Hasen模型、Hoare模型和MESA模型。现在正在广泛使用的是MESA模型。下面我们便介绍MESA模型:
管程中引入了条件变量的概念,而且每个条件变量都对应有一个等待队列。条件变量和等待队列的作用是解决线程之间的同步问题。
2-4、wait()的正确使用姿势
对于MESA管程来说,有一个编程范式:
while(条件不满足) {
wait();
唤醒的时间和获取到锁继续执行的时间是不一致的,被唤醒的线程再次执行时可能条件又不满足了,所以循环检验条件。MESA模型的wait()方法还有一个超时参数,为了避免线程进入等待队列永久阻塞。
可以看下Object类中的wait()使用的相关注释
2-5、notify()和notifyAll()分别何时使用
满足以下三个条件时,可以使用notify(),其余情况尽量使用notifyAll():
- 所有等待线程拥有相同的等待条件;
- 所有等待线程被唤醒后,执行相同的操作;
- 只需要唤醒一个线程。
2-6、Java语言的内置管程synchronized
Java 参考了 MESA 模型,语言内置的管程(synchronized)对 MESA 模型进行了精简。MESA 模型中,条件变量可以有多个,Java 语言内置的管程里只有一个条件变量。模型如下图所示。
2-7、Monitor机制在Java中的实现
java.lang.Object 类定义了 wait(),notify(),notifyAll() 方法,这些方法的具体实现,依赖于 ObjectMonitor 实现,这是 JVM 内部基于 C++ 实现的一套机制。
ObjectMonitor其主要数据结构如下(hotspot源码ObjectMonitor.hpp):
ObjectMonitor() {
_header = NULL; //对象头 markOop
_count = 0;
_waiters = 0,
_recursions = 0; // 锁的重入次数
_object = NULL; //存储锁对象
_owner = NULL; // 标识拥有该monitor的线程(当前获取锁的线程)
_WaitSet = NULL; // 等待线程(调用wait)组成的双向循环链表,_WaitSet是第一个节点
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ; //多线程竞争锁会先存到这个单向链表中 (FILO栈结构)
FreeNext = NULL ;
_EntryList = NULL ; //存放在进入或重新进入时被阻塞(blocked)的线程 (也是存竞争锁失败的线程)
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
_previous_owner_tid = 0;
运行逻辑流程图如下
2-7-1、使用sleep,不释放锁测试代码
@Slf4j
public class SyncQModeDemo {
public static void main(String[] args) throws InterruptedException {
SyncQModeDemo demo = new SyncQModeDemo();
demo.startThreadA();
//控制线程执行时间
Thread.sleep(100);
demo.startThreadB();
Thread.sleep(100);
demo.startThreadC();
}
final Object lock = new Object();
public void startThreadA() {
new Thread(() -> {
synchronized (lock) {
log.debug("A get lock");
try {
Thread.sleep(300);
//lock.wait(300);
} catch (InterruptedException e) {
e.printStackTrace();
}
log.debug("A release lock");
}
}, "thread-A").start();
}
public void startThreadB() {
new Thread(() -> {
synchronized (lock) {
try {
log.debug("B get lock");
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
log.debug("B release lock");
}
}, "thread-B").start();
}
public void startThreadC() {
new Thread(() -> {
synchronized (lock) {
log.debug("C get lock");
}
}, "thread-C").start();
}
}
测试结果:
为何会造成如上结果呢,我们来分析一下
分别启动A、B、C三个线程。
- 1、A线程启动根据策略先进入
_cxq队列中,然后获得锁。因为Thread.sleep(300)未释放锁,这时候就会等待300毫秒,然后释放锁。 - 2、B线程启动的时候同样进入到
_cxq队列中(此时由于A线程睡眠300毫秒,并且未释放锁,因此B线程处于等待获取锁的状态) - 3、C线程和B线程同样进入
_cxq队列,进行排队 - 4、当A线程释放锁,遵循FILO原则,由于C线程后进入队列,因此优先处理,这时候C线程就获得了锁。
- 5、当C线程运行完释放锁,B就获得了锁,并且在睡眠500毫秒后,释放锁。
2-7-2、使用wait睡眠释放锁测试
将上面的代码中A线程中的Thread.sleep改为lock.wait,再次运行代码,结果如下(会发现和刚刚的代码运行结果截然不同):
如上图,为何将sleep改为wait之后,运行的代码结果就不一样了了,下面来进行分析一下。
分别启动A、B、C三个线程。
- 1、A线程启动根据策略先进入
_cxq队列中,然后获得锁。因为lock.wait(300)释放锁,这时候A线程就会进入到_EntryList队列中。 - 2、B线程启动的时候同样进入到
_cxq队列中(此时由于A线程启动后,使用sleep(100)) - 3、由于A线程释放锁,就会从
cxq队列中拿到B线程进行获取锁,这时候B就获取到了锁,由于B线程使用Thread.sleep并未释放锁,因此B运行完成之后释放锁,需要注意的是,此时C已经进入了_cxq队列等待获取锁 - 4、当B线程释放锁锁之后,就剩在
_cxq中的C线程和在_EntryList队列中的A线程。 - 5、此时优先从
_EntryList中获取线程进行运行,因此就看到A线程释放锁的结果,然后C线程获得锁。
根据以上得出结论:
在获取锁时,是将当前线程插入到cxq的头部,而释放锁时,默认策略(QMode=0)是:如果EntryList为空,则将cxq中的元素按原有顺序插入到EntryList,并唤醒第一个线程,也就是当EntryList为空时,是后来的线程先获取锁。_EntryList不为空,直接从_EntryList中唤醒线程。