线程安全的概念
想给出一个线程安全的确切定义是复杂的,但我们可以这样认为:
如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的。
观察线程不安全
class Counter {
public int count = 0;
public void add() {
count++;
}
}
public class ThreadDemo13 {
public static void main(String[] args) {
Counter counter = new Counter();
// 搞两个线程, 两个线程分别针对 counter 来 调用 5w 次的 add 方法
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.add();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.add();
}
});
// 启动线程
t1.start();
t2.start();
// 等待两个线程结束
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
// 打印最终的 count 值
System.out.println("count = " + counter.count);
}
}
预想的情况是两个线程各自增5w次,运行结束 count 的值应该为10w,但结果每次不一样,因此我们就说出现了 bug。
线程不安全的原因
1.【根本原因】抢占式执行,随即调度
2.代码结构:多个线程同时修改同一个变量。这种可以通过调整代码结构来规避这个问题,但是这种调整不是一种普遍性高的方案(因为代码结构是源于 需求 的,改了之后可能达不到需求或者性价比太低)
3.原子性:如果改写操作是原子性的,那还没啥事;但是是非原子的,出现线程安全问题的概率就非常高了。(原子:不可拆分的基本单位)
像上面的 count++,可以拆分成 load,add,sava 这三个操作。所以 ++ 操作并不是原子性的,因此我们想要解决线程安全问题,就需要把这个操作弄成原子性。也就是通过加锁的操作。这是解决线程安全问题最主要的手段。
synchronized public void add() {
count++;
}
不保证原子性会给多线程带来什么问题
如果一个线程正在对一个变量操作,中途其他线程插入进来了,如果这个操作被打断了,结果就可能是错误的。这点也和线程的抢占式调度密切相关. 如果线程不是 "抢占" 的, 就算没有原子性, 也问题不大.
4.内存可见性问题
可见性指, 一个线程对共享变量值的修改,能够及时地被其他线程看到.
Java 内存模型 (JMM): Java虚拟机规范中定义了Java内存模型.
目的是屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果.
线程之间的共享变量存在 主内存 (Main Memory).
每一个线程都有自己的 "工作内存" (Working Memory) .
当线程要读取一个共享变量的时候, 会先把变量从主内存拷贝到工作内存, 再从工作内存读取数据.
当线程要修改一个共享变量的时候, 也会先修改工作内存中的副本, 再同步回主内存.
由于每个线程有自己的工作内存, 这些工作内存中的内容相当于同一个共享变量的 "副本". 此时修改线程1 的工作内存中的值, 线程2 的工作内存不一定会及时变化.
1) 初始情况下, 两个线程的工作内存内容一致.
2) 一旦线程1 修改了 a 的值, 此时主内存不一定能及时同步. 对应的线程2 的工作内存的 a 的值也不一定能及时同步.
这个时候代码中就容易出现问题.
此时引入了两个问题:
1) 为啥整这么多内存?
实际并没有这么多 "内存". 这只是 Java 规范中的一个术语, 是属于 "抽象" 的叫法.
所谓的 "主内存" 才是真正硬件角度的 "内存". 而所谓的 "工作内存", 则是指 CPU 的寄存器和高速缓存.
2) 为啥要这么麻烦的拷来拷去?
因为 CPU 访问自身寄存器的速度以及高速缓存的速度, 远远超过访问内存的速度(快了 3 - 4 个数量级, 也就是几千倍, 上万倍).
比如某个代码中要连续 10 次读取某个变量的值, 如果 10 次都从内存读, 速度是很慢的. 但是如果只是第一次从内存读, 读到的结果缓存到 CPU 的某个寄存器中, 那么后 9 次读数据就不必直接访问内存了. 效率就大大提高了.
那么接下来问题又来了, 既然访问寄存器速度这么快, 还要内存干啥?? 因为寄存器贵!
值的一提的是, 快和慢都是相对的. CPU 访问寄存器速度远远快于内存, 但是内存的访问速度又远远快于硬盘.对应的, CPU 的价格最贵, 内存次之, 硬盘最便宜.
5.指令重排序
有一段代码是这样的:
1. 去楼下取下外卖
2. 回房间写 10 分钟作业
3. 去楼下取下快递
如果是在单线程情况下,JVM、CPU指令集会对其进行优化,比如,按 1 → 3 → 2的方式执行,也是没问题,可以少下一次楼。这种叫做指令重排序。
编译器对于指令重排序的前提是 "保持逻辑不发生变化",从而提高执行效率. 这一点在单线程环境下比较容易判断, 但是在多线程环境下就没那么容易了, 多线程的代码执行复杂程度更高, 编译器很难在编译阶段对代码的执行效果进行预测, 因此激进的重排序很容易导致优化后的逻辑和之前不等价.
重排序是一个比较复杂的话题, 涉及到 CPU 以及编译器的一些底层工作原理, 此处不做过多讨论
synchronized 关键字 — 监视器锁monitor lock
synchronized 的特性
1) 互斥
synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到同一个对象 synchronized 就会阻塞等待.
进入 synchronized 修饰的代码块, 相当于 加锁
退出 synchronized 修饰的代码块, 相当于 解锁
举个例子,加锁就像是上厕所:
如果当前是 "无人" 状态, 那么就可以使用, 使用时需要设为 "有人" 状态.
如果当前是 "有人" 状态, 那么其他人无法使用, 只能排队,理解 "阻塞等待".
注意:
上一个线程解锁之后, 下一个线程并不是立即就能获取到锁. 而是要靠操作系统来 "唤醒". 这也就是操作系统线程调度的一部分工作.
假设有 A B C 三个线程, 线程 A 先获取到锁, 然后 B 尝试获取锁, 然后 C 再尝试获取锁, 此时 B和 C 都在阻塞队列中排队等待. 但是当 A 释放锁之后, 虽然 B 比 C 先来的, 但是 B 不一定就能获取到锁, 而是和 C 重新竞争。
关于锁对象的规则:
1.如果两个线程针对同一个对象加锁,此时就会出现 锁竞争 / 锁冲突 的问题,其中的一个线程获取到了锁(先到先得),另一个线程只能阻塞等待,等到那个线程解锁了,这个线程才能取锁成功。
2.如果两个线程针对同一个线程,一个加锁一个不加锁,这个时候就没有 锁竞争 / 锁冲突 的问题,但是却会发生线程安全问题。
3.如果两个线程针对两个对象加锁,则不会出现 锁竞争 / 锁冲突 的问题,各自获取到各自的锁,不会有阻塞等待。
2) 可重入
一个线程针对同一个对象连续进行两次加锁,是否会出现问题?如果没问题,则是可重入锁;如果有问题,就叫做不可重入锁。
代码示例
synchronized public void add() { // 锁对象是 this
synchronized (this) {
count++;
}
}
只要有线程调用 add ,进入 add 方法的时候就会进行加锁,紧接着又遇到了代码块。此时站在 this(锁对象)的视角,它认为自己已经被别的线程占用了(上面的代码是个特殊情况:别的线程其实就是这个线程),这里的第二次加锁是否要阻塞等待?如果需要阻塞等待,那么就是不可重入的,这个情况就会导致线程“僵住”,也就是死锁了。
因为Java 中的 synchronized 是 可重入锁, 因此没有上面的问题。会在锁对象里记录一下,如果当前加锁线程和持有锁的线程是同一个,就会直接通过,不会阻塞。
死锁的四个必要条件:
1.互斥使用:线程 1 拿到了锁,线程 2 就得等着(锁的基本特性)
2.不可抢占:线程 1 拿到锁之后,必须是线程 1 主动释放。不能是线程 2 把锁强行获取到
3.请求和保持:线程 1 拿到 锁A 之后,再尝试获取 锁B ,A 这把锁还是保持的(不会因为获取 B 就把 A 释放了)
4.循环等待:线程 1 尝试获取 A 和 B ,线程 2 尝试获取 B 和 A 。 线程 1 在获取 B 的时候等到线程 2 释放 B ;同时线程 2 在获取 A 的时候等待线程 1 释放 A
前三个条件都是锁的基本特性,改不了;第四点是唯一一个和代码相关的,也是我们可以控制的,因此解决死锁的办法就是给锁编号,按固定的顺序(从小到大)来加锁。
死锁几种常见的情况:
1.一个线程,一把锁,连续加锁两次,如果锁是不可重入锁,就会出现死锁。
2.两个线程两把锁,t1 和 t2 各自先针对 锁A 和 锁B 加锁,再尝试获取对方的锁。
public class ThreadDemo14 {
public static void main(String[] args) {
// 假设 jiangyou 是 1 号, cu 是 2 号, 约定先拿小的, 后拿大的.
Object jiangyou = new Object();
Object cu = new Object();
Thread tanglaoshi = new Thread(() -> {
synchronized (jiangyou) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (cu) {
System.out.println("汤老湿把酱油和醋都拿到了");
}
}
});
Thread shiniang = new Thread(() -> {
synchronized (cu) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (jiangyou) {
System.out.println("师娘把酱油和醋都拿到了");
}
}
});
tanglaoshi.start();
shiniang.start();
}
}
这个代码的逻辑是:老师先拿了酱油,师娘先拿了醋,这时候俩人都想混合吃,老师说你先把醋给我,等我倒完了再给你,师娘不愿意,结果就僵持住了,出现了死锁。
3.多个线程多把锁(相当于 2 的更进一步)。锁更多,线程更多,情况也就更复杂了。因此根据上述死锁条件的第四点作为突破口就能解决了。
Thread shiniang = new Thread(() -> {
synchronized (jiangyou) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (cu) {
System.out.println("师娘把酱油和醋都拿到了");
}
}
});
synchronized 使用示例
1) 直接修饰普通方法:
synchronized public void add() {
count++;
}
2) 修饰静态方法:
synchronized public static void add() {
count++;
}
3) 修饰代码块: 明确指定锁哪个对象.
锁当前对象
public void add() {
// 进入代码块就加锁
synchronized (this) { // 这里可以指定任意想指定的对象,不一定非得是 this
count++;
}
// 出了代码块就解锁
}
Java 标准库中的线程安全类
Java 标准库中很多都是线程不安全的. 这些类可能会涉及到多线程修改共享数据, 因此需要我们自己手动加锁
ArrayList、LinkedList、HashMap、TreeMap、HashSet、TreeSet、StringBuilder
但是还有一些是相对线程安全的,已经内置了 synchronized 加锁。使用了一些锁机制来控制.
Vector (不推荐使用)、HashTable (不推荐使用)、ConcurrentHashMap、StringBuffer
还有的虽然没有加锁, 但是不涉及 "修改", 仍然是线程安全的
String