目录
线程安全问题
加锁(synchronized)
synchronized 使用方法
synchronized的其他使用方法
synchronized 重要特性(可重入的)
死锁的问题
对 2> 提出问题
对 3> 提出问题
解决死锁
对 2> 进行解答
对4> 进行解答
volatile 关键字
wait 和 notify (重要)
wait使用实例:
notify使用实例:
" 线程饿死 "
notify 和 notifyAll
小结
线程安全问题
线程安全问题: 有些代码在单个线程环境下执行完全正确. 但是如果同样的代码让多个线程同时执行, 此时就可能出现 bug, 这种情况叫做 "线程安全问题" / "线程不安全", 它是多线程中最复杂, 最重要的部分
举个例子: 两个线程, 每个线程count++ 5000次, 正常情况下结果为 10w, 实际结果:如下图:
public class Test {
public static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
int i = 0;
while(i < 5000) {
count++;
i++;
}
});
Thread t2 = new Thread(() -> {
int i = 0;
while(i < 5000) {
count++;
i++;
}
});
t1.start();
t2.start();
//如果没有这俩 join , 肯定不行, 线程还没有自增完毕, 就开始打印了,
//打印出来的count 可能是 0;
t1.join();
t2.join();
//预期结果是 10w
System.out.println(count);
}
}
改变一下 join 的次序可以让结果输出正确, 如下代码:
public class Test {
public static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
int i = 0;
while(i < 5000) {
count++;
i++;
}
});
Thread t2 = new Thread(() -> {
int i = 0;
while(i < 5000) {
count++;
i++;
}
});
t1.start();
t1.join();
t2.start();
t2.join();
//预期结果是 10w
System.out.println(count);
}
}
这个代码意味着 t1 执行时 t2并不会启动, 虽然上述代码卸载两个线程中, 但并不是同时执行的, 而第一个代码中 t1 和 t2 同时执行了, 第二个代码结果输出正确, 为 10w, 我们可以猜测是因为两个线程同时执行的原因导致第一个结果出错了.
解释:
count++ 这个操作本质上是分三步进行的 ~~ 站在 cpu 的角度, 是 cpu 通过三个指令实现的
1> load 把数据从内存读到 CPU 寄存器中
2> add 把寄存器中的数据进行 +1
3> sava 把寄存器中的数据保存到内存中
由于多个线程执行上述代码, 由于线程之间的调度顺序是 "随机" 的, 就会导致有些调度顺序下, 上述的逻辑就会出现问题.
如图:
这只是其中的一种情况, 还可能有无数种情况, 这三个步骤的排列顺序有很多种了, 还有可能 t1 连续执行了多次, 然后 t2 再次执行的情况, 有无数种排列顺序.
我们意识到在多线程程序中最困难的一点是: 现成的随机调度, 是两个线程执行逻辑的先后顺序存在很多可能, 我们要做的是保证在每一种情况下都输出正确的结果.
举个例子看一下: 不同的情况怎么输出结果的:
理想情况下:
两次相加后, 最终可以输出2
可能会出现的情况:
这种情况下两次相加得到的结果为 1
因为线程调度是随机的, 很容易出现错误情况, 这样的话最终的结果是一个随机值, 随机值小于 10w.
产生线程安全的原因:
1> 操作系统中, 线程的调度顺序是随机的 (抢占式执行)
2> 两个线程针对同一个变量进行修改
3> 修改操作不是原子的
此处给定的 count++ 就属于是非原子的操作, 先读取, 在修改, 有三个指令
4> 内存可见性问题
5> 指令重排序问题
如何解决这个问题呢? 从这些原因入手
1> 调度随机性在系统内核里实现的, 最早的操作系统奠定了这个基调, 无能为力.
2> 有些情况可以通过调整代码结构来规避在这个问题, 有些情况规避不了
3> 有办法让 count++ 三步走成为 "原子" 的 ----> (加锁) 的方法
加锁(synchronized)
synchronized 使用方法
需要搭配一个代码块 { } 使用, 进入 { 就会加锁, 出去 } 就会解锁
作用
在已经加锁的状态下, 另一个线程尝试同样加这个锁, 就会产生 "锁冲突/锁竞争", 后一个线程就会阻塞等待, 一直等到前一个线程解锁为止.
使用方法举例
用上述代码进行举例:
count++ 加在代码块中, 然后 synchronized() 这个后面的 () 需要表示一个用来加锁的对象, 这个对象是啥不重要, 重要的是通过这个对象来区分两个线程是否在竞争同一个锁, 如果两个线程是针对同一个对象加锁, 就会有锁竞争, 反之不会有锁竞争, 仍然是并发执行.
追妹子: 你想妹子表白, 成功了就相当于加锁了, 另一个小哥准备追同一个妹子, 就得阻塞等待, 等你俩分手了他才有机会, 如果他准备追另一个对象, 那么可以直接表白.
public class Test {
public static int count = 0;
public static void main(String[] args) throws InterruptedException {
//我们任意定义一把锁
Object locker = new Object();
Thread t1 = new Thread(() -> {
int i = 0;
while(i < 5000) {
//进行加锁
synchronized (locker) {
count++;
i++;
}
}
});
Thread t2 = new Thread(() -> {
int i = 0;
while(i < 5000) {
//进行加锁
synchronized (locker) {
count++;
i++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
//预期结果是 10w
System.out.println(count);
}
}
运行结果正确了.
加过锁之后两个线程相互影响, 在进行 count++ 时会先加锁, t1 线程加过锁了, t1没执行完之前, t2 操作会出现阻塞. 只有当 t1 中 count++ 的操作执行完之后才会让 t2 中的 count++ 进行操作, 这就避免了 t1 中的 load add save 与 t2 中的 load add save 操作 出行穿插, 此时线程安全问题就迎刃而解了.
如果在 两个线程加锁时 使用不同的 锁 那么就不会出现锁竞争, 上述问题就不会解决
其中synchronized 后面 () 中的锁对象到底是哪个对象无所谓, 重要的是俩线程加锁的对象是否是同一个对象.
synchronized的其他使用方法
synchronized 还可以修饰 一个方法
class Counter {
public int count;
synchronized public void increase() {
count++;
}
public void increase2() {
synchronized (this) {
count++;
}
}
}
public class Test {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
//预期结果是 10w
System.out.println(counter.count);
}
}
两种的写法是一样的, 上面是下面的简化版本
synchronized还可以修饰一个 静态方法
两种的写法是一样的, 上面代码是下面代码的简化版本
其中 Counter.class 是类对象
.java => .class => JVA加载到内存中(类对象) 可以看作是 .Java 文件中的二进制码
类对象中包含以下内容:
1> 类的属性有哪些, 名字, 类型, 权限
2> 类的方法有哪些, 名字, 类型, 权限
3> 类本身继承自哪个类, 实现了哪些接口
在一个 Java 进程中, 类对象是唯一的.
synchronized 用的锁是存在 Java 对象头里的
Java 的一个对象, 对应的内存空间中, 除了你自己定义的一些属性之外, 还有一些自带的属性, 在对象头中, 其中就有属性表示当前的对象是否已经加锁.
synchronized 重要特性(可重入的)
可重入定义: 一个线程连续针对一把锁加锁两次, 不会出现死锁, 满足这个要求就是 "可重入" , 不满足就是 "不可重入" .
死锁的解释: 有一个线程 t , 锁对象 locker, t 线程中存在下列代码:
synchronized (locker) {
synchronized (locker) {
...........
}
}
第一次加锁能够加锁成功, 此时 locker 属于 "被锁定" 的状态, 第二次加锁 locker 已经是锁定状态, 第二次加锁操作, 应该要 "阻塞等待" 的, 等到锁被释放之后才能加锁成功
第二次想要加锁成功, 需要第一次加锁释放锁, 释放锁就要第二次加锁成功
这样就出现了死锁现象. 就是一个bug, 可能会出现这种情况
private static Object locker = new Object();
public static void func1() {
synchronized (locker) {
func2();
}
}
public static void func2() {
func3();
}
public static void func3() {
func1();
}
public static void func4() {
synchronized (locker) {
}
}
这种bug时常出现而且不容易发现.
问题是: 上述代码中, synchronized 是可重入锁, 没有因为第二次加锁而死锁, 加入上述加锁过程有 N 层, 释放时机该如何判定?
解答: 此处无论有多少层锁, 都是到在最外层才能释放锁, 提前释放会线程不安全
引用计数: 锁对象中不但要记录谁拿到了锁, 还要记录锁被加了几次, 每加锁一次, 计数器 +1, 每解锁一次, 计数器 -1, 除了最后一个大括号恰好减成 0 , 才真正释放锁.
死锁的问题
关于死锁总结:
1> 一个线程针对一把锁, 连续加锁两次, 如果是不可重入锁, 就死锁了. (synchronized 不会出现)
2> 两个线程, 两把锁 (此时无论是不是可重入锁, 都会死锁)
t1 t2 两个线程, A 和 B 两把锁
t1 获取锁 A, t2 获取锁 B, t1 尝试获取B, t2 尝试获取 A. 这种情况出现死锁.
3> N 个线程, M 把锁 (相当于 2> 的扩充) 更容易出现死锁的情况了, 经典模型: 哲学家就餐问题.
对 2> 提出问题
public class Test {
private static Object locker1 = new Object();
private static Object locker2 = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
synchronized (locker1) {
// 此处的 sleep 很重要, 要确保 t1 和 t2 都分别拿到一把锁
// 之后再进行动作
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (locker2) {
System.out.println("t1 加锁成功!");
}
}
});
Thread t2 = new Thread(() -> {
synchronized (locker2) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (locker1) {
System.out.println("t2 加锁成功!");
}
}
});
t1.start();
t2.start();
}
}
此时的代码会出现死锁情况, 什么都没法打印出来
此时打开 jconsole (java带的一个查看线程情况的工具) 就可以看到这俩线程的状态:
当前线程出现了阻塞状态;
对 3> 提出问题
死锁是属于很严重的 bug (导致线程卡住, 无法执行后续工作)
死锁的成因涉及到四个 必要条件
1> 互斥使用 (锁的基本特性) 当一个线程持有一把锁之后, 另一个线程也想获取到锁, 就要阻塞等待
2> 不可抢占 (锁的基本特性) 当锁已经被线程 1 得到之后, 线程2 只能等线程1 主动释放出来, 不能强行抢过来
3> 请求保持 (代码结构) 一个线程尝试获取多把锁, (先拿到锁1 之后, 在尝试获取锁2 的时候锁1 不会释放, 就是的上面的2> 例子
4> 循环等待/环路等待 (代码结构) 等待的依赖关系形成环了, 上面的3> 哲学家就餐的例子
解决死锁
如何解决/避免死锁呢?
核心是破坏上述必要条件
1> 和 2> 是锁的特性, 不能改变, 要从 3> 和 4> 着手
3> 来说, 调整代码结构, 避免编写 "锁嵌套" 逻辑, 当然这个方案不一定好使, 有的需求可能就是需要获取多个锁之后再操作
4> 通过约定加锁顺序, 就可以避免循环的等待
对 2> 进行解答
调整代码结构, 避免编写 "锁嵌套" 逻辑
public class Test {
private static Object locker1 = new Object();
private static Object locker2 = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
synchronized (locker1) {
// 此处的 sleep 很重要, 要确保 t1 和 t2 都分别拿到一把锁
// 之后再进行动作
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
synchronized (locker2) {
System.out.println("t1 加锁成功!");
}
});
Thread t2 = new Thread(() -> {
synchronized (locker2) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
synchronized (locker1) {
System.out.println("t2 加锁成功!");
}
});
t1.start();
t2.start();
}
}
这样就能正常输出结果了.
对4> 进行解答
通过约定加锁顺序, 就可以避免循环的等待
针对锁进行编号, 比如约定 加多把锁的时候先加编号小的锁, 后加编号大的锁.(所有线程都遵守这个规则) 举个例子如下图:
最终死锁问题迎刃而解, 本质上是破除了循环等待
synchronized 使用规则并不复杂, 抓住一个原则, 两个线程针对同一个对象加锁, 就会产生锁竞争.
volatile 关键字
作用:
1> 保证内存可见性
2> 禁止指令重排序
1>什么是 内存可见性
计算机运行的程序/代码, 经常要访问数据, 这些依赖的数据往往存在在 内存中, (定义一个变量, 变量就是存在内存中), CPU 使用这个变量的时候, 就会把这个内存中的数据先读出来, 放到 CPU 的寄存器中在参与运算 (load)
CPU 读内存 相当于 读硬盘 快几千上万倍, 读寄存器 相比于 读内存 又快了几千上万倍, 为了提高效率, 编译器把代码做出优化, 把一些本来要读内存的操作, 优化成读其寄存器, 减少读内存的次数, 也就可以提高整体程序的效率了
举个例子:
public class Test {
private static int isQuit = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
while(isQuit == 0) {
}
System.out.println("t1 退出");
});
t1.start();
Thread t2 = new Thread(() -> {
System.out.println("请输入 isQuit: ");
Scanner sc = new Scanner(System.in);
isQuit = sc.nextInt();
});
t2.start();
}
}
当我们输入 isQuti == 1 或者其他不为零的数时程序应该停止运行, 但结果不是. 打开 jconsole 可以看到 t1 线程正在执行, 为 RUNNABLE 状态
之前是两个线程修改用一个变量会引起线程安全问题, 现在是一个线程读, 一个线程修改也有可能会有问题, 就是因为 内存可见性引起的
使用方法:
在 isQuit 变量加上 volatile限制就可以了
关于内存可见性还涉及到一个关键概念, JMM(Java Memory Model, Java 内存模型) Java规范文档的叫法.
JMM 把存储空间分为 主内存 和工作内存, t1线程对应 isQuit 变量, 本身是在 主内存中的, 由于此处的优化就会把 isQuit 变量放到工作内存中. 进一步的 t2 修改主内存的 isQuit, 不会影响到 t1 的工作内存. 主内存就是咱们平常说的内存, 工作内存就是 CPU 寄存器.
volatile 可以保证内存可见性, 但是不能保证原子性
wait 和 notify (重要)
wait 和 notify 都是 Object 方法, 随便定义一个对象, 都可以使用 wait notify .它俩需要配合使用
作用: 用来协调多个线程的执行顺序
本身多个线程的执行顺序是随机的 (系统随即调度, 抢占式执行) 很多时候希望通过一定手段, 协调执行顺序. join 使用像线程结束的先后顺序, 相比之下此处是希望线程不结束, 也能够有先后顺序的控制.
wait 等待: 让指定线程进入阻塞状态
notify 通知: 唤醒对应的阻塞状态的线程
wait如何使用
public class Test {
public static void main(String[] args) throws InterruptedException {
Object object = new Object();
System.out.println("wait 之前");
object.wait();
System.out.println("wait 之后");
}
}
此处代码报错, 非法的 监视器 状态 异常 , 其中 synchronized 就是监视器锁.
wait 操作在执行的时候要做 三件事
1> 释放当前的锁
2> 让线程进入阻塞
3> 线程被唤醒的时候重新获取到锁
通过object 调用wait 释放锁的过程. 释放锁的前提就是 先加锁
public class Test {
public static void main(String[] args) throws InterruptedException {
Object object = new Object();
synchronized (object) {
System.out.println("wait 之前");
//将 wait 放到 synchronized 里面调用, 保证确实是拿到了锁
object.wait();
System.out.println("wait 之后");
}
}
}
应该把 wait 写到 synchronized 里面加锁, 此时可以运行代码, 但是wait 会持续阻塞等待下去, 直到其他线程调用 notify 唤醒.
此处的状态就是 waiting 状态
wait 除了默认的无参版本之外, 还有一个带参数的版本, 带参数的版本就是指定一个时间
参数的版本就是指定超时时间, 避免 wait 无休止地等待下去.
notify如何使用
public class Test {
public static void main(String[] args){
Object object = new Object();
Thread t1 = new Thread(() -> {
synchronized (object) {
System.out.println("wait 之前");
try {
object.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("wait 之后");
}
});
Thread t2 = new Thread(() -> {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (object) {
System.out.println("进行通知");
object.notify();
}
});
t1.start();
t2.start();
}
}
结果如图.
" 线程饿死 "
针对这种情况:可以使用 wait 和 notify 来解决
让 1 号老铁在发现没钱的时候就进行 wait.(wait 内部本身就会释放锁, 并且进入阻塞)
让 1 号老铁不再进行后续的锁竞争, 把所释放出来让别人获取. 给其他老铁提供机会
运钞车把钱运过来的线程就是调用 notify 唤醒的线程
notify 和 notifyAll
notify 一次唤醒一个线程
notifyAll 一次唤醒全部线程
调用 wait 不一定只有一个线程调用, N 个线程都可以调用 wait, 此时有多个线程调用的时候这些线程都会进入阻塞状态., 唤醒的时候就有两种方式了
nitifyAll: 唤醒的时候 wait 涉及到一个重新获取锁的过程, 需要串行执行(用的更少)
notify: 更可控(用的更多)
小结
保证线程安全需要 : 保证原子性, 可见性, 顺序性:
了解synchronized 和 wait notify , volatile 的语法, 目的
掌握死锁的几种情况, 及如何解决死锁问题.