共享带来的问题
分析
两个线程对初始值为 0 的静态变量一个做自增,一个做自减,各做 5000 次,结果是 0 吗?
分析:
以上的结果可能是正数、负数、零
因为Java中对静态变量的自增、自减并不是原子操作,需要从字节码进行分析
例如对于i++而言(i为静态变量),会产生如下的JVM字节码指令:
i--:
而Java的内存模型如下,完成静态变量的自增、自减需要在主存和工作内存中进行数据交换:
如果是单线程,以上8行代码是顺序执行(不会交错),没有问题
但多线程下这8行代码可能交错运行
出现负数的情况:
出现正数的情况:
临界区Critical Section
一个程序运行多个线程本身是没有问题的,问题出在多个线程访问共享资源
- 多个线程读共享资源没有问题
- 在多个线程对共享资源读写操作时发生指令交错,就会出现问题
一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区
例如:
static int counter = 0;
static void increment()
//临界区
{
counter++;
}
static void decrement()
//临界区
{
counter--;
}
竞态条件Race Condition
多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件
为了避免临界区的竞态条件发生,有多种解决方案:
- 阻塞式:synchronized,Lock
- 非阻塞式:原子变量
synchronized解决方案
synchronized,俗称对象锁,它采用互斥的方式让同一时刻至多只有一个线程能持有对象锁,其他线程再想获取这个对象锁时就会阻塞住,这样就能保证拥有锁的线程可以安全地执行临界区内的代码,不用担心线程上下文切换
注意:
虽然Java中互斥和同步都可以采用synchronized关键字来完成,但存在区别:
- 互斥是保证临界区的竞态条件发生,同一时刻只能有一个线程执行临界区代码
- 同步是由于线程执行的先后顺序不同,需要一个线程等待其他线程运行到某个点
语法1
synchronized(对象) // 线程1, 线程2(blocked)
{
临界区
}
解决:
理解:
- synchronized(对象) 中的对象,可以想象为一个房间(room),有唯一入口(门)房间只能一次进入一人进行计算,线程 t1,t2 想象成两个人
- 当线程 t1 执行到 synchronized(room) 时就好比 t1 进入了这个房间,并锁住了门拿走了钥匙,在门内执行 count++ 代码
- 这时候如果 t2 也运行到了 synchronized(room) 时,它发现门被锁住了,只能在门外等待,发生了上下文切换,阻塞住了
- 这中间即使 t1 的 cpu 时间片不幸用完,被踢出了门外(不要错误理解为锁住了对象就能一直执行下去), 这时门还是锁住的,t1 仍拿着钥匙,t2 线程还在阻塞状态进不来,只有下次轮到 t1 自己再次获得时间片时才能开门进入
- 当 t1 执行完 synchronized{} 块内的代码,这时候才会从 obj 房间出来并解开门上的锁,唤醒 t2 线程把钥匙给它,t2 线程这时才可以进入 obj 房间,锁住了门拿上钥匙,执行它的 count-- 代码
图示:
思考:
synchronized实际是用对象锁保证了临界区内代码的原子性,临界区内的代码对外是不可分割的,不会被线程切换所打断
- 如果把synchronized(obj)放在for循环的外面,如何理解?——原子性
- 如果t1 synchronized(obj1),而t2 synchronized(obj2)会怎么运作?——不同的锁对象
- 如果t1 synchronized(obj),而t2没有加会怎么样?——相当于都没有加锁
面向对象改进:
import lombok.extern.slf4j.Slf4j;
@Slf4j(topic = "c.Test17")
public class Test17 {
public static void main(String[] args) throws InterruptedException {
Room room = new Room();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
room.increment();
}
}, "t1");
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
room.decrement();
}
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
log.debug("{}", room.getCounter());
}
}
class Room {
private int counter = 0;
public void increment() {
synchronized (this){
counter++;
}
}
public void decrement() {
synchronized (this){
counter--;
}
}
public int getCounter() {
synchronized (this){
return counter;
}
}
}
语法2
成员方法:锁的是this对象
静态方法:锁的是类对象
线程八锁
考察synchronized锁住的是哪个对象
情况1:
12或21
情况2:
1s后12 或 2 1s后1
情况3:c方法没有synchronized,c和ab并行执行
3 1s后1 2 或 3 2 1s后1 或 2 3 1s后1
情况4: 两个对象n1和n2,不互斥
2 1s后1
情况5:a方法为静态方法,锁住的是类对象,与b方法锁住的对象不同,不互斥
2 1s后1
情况6:a、b都为静态方法
1s后12 或 2 1s后1
情况7:锁对象不同,不互斥
2 1s后1
情况8:锁对象相同,互斥
1s后12 或 2 1s后1
变量的线程安全
成员变量和静态变量是否线程安全?
如果它们没有共享,则线程安全
如果它们被共享,根据它们的状态是否能够改变,分为两种情况:
- 如果只有读操作,则线程安全
- 如果有读写操作,则这段代码是临界区,需要考虑线程安全
局部变量是否线程安全?
局部变量是线程安全的
但局部变量引用的对象则未必
- 如果该对象没有逃离方法的作用范围,它是线程安全的
- 如果该对象逃离方法的作用范围,需要考虑线程安全
每个线程调用test1()方法时,局部变量i会在每个线程的栈帧内存中被创建多份,因此不存在共享
局部变量的引用:
如果是成员变量
其中一种情况是,如果线程2还未add,线程1remove就会报错:
分析:
无论哪个线程的method2,引用的都是同一个对象中的list成员变量
将list修改为局部变量
分析:
list是局部变量,每个线程调用时会创建其不同实例,没有共享
如果把method2和method3的方法修改为public会不会有线程安全问题?
情况1:有其他线程调用method2和method3
- 没有问题,不同线程创建不同的list对象
情况2:在情况1的基础上,为ThreadSafe类添加子类,子类覆盖method2或method3方法
- 有问题,两个线程共享一个资源
常用线程安全类
- String
- Integer
- StringBuffer
- Random
- Vector
- Hashtable
- java.util.concurrent包下的类
这里的线程安全指的是多个线程调用它们同一个实例的某个方法时,是线程安全的
它们的每个方法是原子的,但它们方法的组合不是原子的
线程安全类方法的组合:
下面代码不是线程安全的
不可变类线程安全性
String、Integer等都是不可变类,因为其内部的状态不可以改变,因此它们的方法都是线程安全的
Monitor
Java对象头
以32位虚拟机为例
普通对象
数组对象
其中Mark Word结构为
64位虚拟机Mark Word
Monitor(监视器/管程)
每个Java对象都可以关联一个Monitor对象,如果使用synchronized给对象上锁(重量级)之后,该对象头的Mark Word中就被设置指向Monitor对象的指针
Monitor结构:
- 刚开始 Monitor 中 Owner 为 null
- 当 Thread-2 执行 synchronized(obj) 就会将 Monitor 的所有者 Owner 置为 Thread-2,Monitor中只能有一个 Owner
- 在 Thread-2 上锁的过程中,如果 Thread-3,Thread-4,Thread-5 也来执行synchronized(obj),就会进入 EntryList BLOCKED
- Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争是非公平的
- 图中 WaitSet 中的 Thread-0,Thread-1 是之前获得过锁,但条件不满足进入 WAITING 状态的线程
注意:
- synchronized必须是进入同一个对象的monitor才有上述的效果
- 不加synchronized的对象不会关联监视器,不遵从以上规则
原理:synchronized
轻量级锁
轻量级锁的使用场景:如果一个对象虽然有多线程要加锁,但加锁的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化
轻量级锁对使用者是透明的,即语法仍然是synchronized
假设有两个方法同步块,利用同一个对象加锁:
- 创建锁记录(Lock Record)对象,每个线程的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的Mark Word
- 让锁记录中Object reference指向锁对象,并尝试用cas替换Object的Mark Word,将Mark Word的值存入锁记录
- 如果cas替换成功,对象头中存储了锁记录地址和状态00,表示由该线程给对象加锁
如果cas失败,有两种情况:
- 如果是其他线程已经持有了该Object的轻量级锁,这时表明有竞争,进入锁膨胀过程
- 如果是自己执行了synchronized锁重入,那么再添加一条Lock Record作为重入的计数
- 当退出synchronized代码块(解锁时),如果有取值为null的锁记录,表示有重入,这时重置锁记录,表示重入计数减一
当退出synchronized代码块(解锁时),锁记录的值不为null,这时使用cas将Mark Word的值恢复给对象头
- 成功,则解锁成功
- 失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程
锁膨胀
如果在尝试加轻量级锁的过程中,cas操作无法成功,这是一种情况就是有其他线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁
- 当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁
- 这时 Thread-1 加轻量级锁失败,进入锁膨胀流程,即为 Object 对象申请 Monitor 锁,让Object 指向重量级锁地址,然后自己进入 Monitor 的 EntryList BLOCKED
- 当 Thread-0 退出同步块解锁时,使用 cas 将 Mark Word 的值恢复给对象头,失败。这时会进入重量级解锁流程,即按照 Monitor 地址找到 Monitor 对象,设置 Owner 为 null,唤醒EntryList 中 BLOCKED 线程
自旋优化
重量级锁竞争的时候,还可以使用自旋来进行优化,如果当时线程自旋成功(即这时持锁线程已经退出来同步块,释放了锁),这时当前线程就可以避免阻塞
自旋会占用cpu时间,单核cpu自旋就是浪费,多核cpu自旋才能发挥优势
在Java 6之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,比较智能
Java 7之后不能控制是否开启自旋功能
自旋重试成功的情况:
自旋重试失败的情况:
偏向锁
轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行cas操作
Java 6引入了偏向锁来做进一步优化:只有第一次使用cas将线程ID设置到对象的Mark Word头,之后发现这个线程 ID是自己的就表示没有竞争,不用重新cas,以后只要不发生竞争,这个对象就归该线程所有