目录
一、多线程带来的风险-线程安全
1、观察线程不安全
2、线程安全的概念
3、线程不安全的原因
4、解决之前的线程不安全问题
5、synchronized 关键字 - 监视器锁 monitor lock
5.1 synchronized 的特性
5.2 synchronized 使用示例
5.3 Java 标准库中的线程安全类
一、多线程带来的风险-线程安全
1、观察线程不安全
public class ThreadDemo2 {
private static long count = 0;
public static void main(String[] args) throws InterruptedException{
Thread t1 = new Thread(()->{
for (int i = 1;i <= 500000;i++) {
count++;
}
});
Thread t2 = new Thread(()->{
for (long i = 0;i < 500000;i++) {
count++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
//存在线程安全问题,输出的结果可能不准确
System.out.println("count= "+count);
}
}
其运行结果:
明显这个结果和我们的预期是不一样的,这是因为存在线程安全问题。若把count++的操作在一个单线程环境下运行 ,便不会出现这样的问题。下面我们来说一下线程安全问题。
2、线程安全的概念
如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的。线程安全,在单线程环境下和多线程环境下都不会出现问题。
3、线程不安全的原因
- 线程调度是随机的
这是线程安全问题的根本原因 ;随机调度使⼀个程序在多线程环境下,执行顺序存在很多的变数;程序猿必须保证 在任意执行顺序下 , 代码都能正常工作。
- 修改共享数据
多个线程修改同⼀个变量。
- 原子性
什么是原子性:
我们把⼀段代码想象成⼀个房间,每个线程就是要进入这个房间的人。如果没有任何机制保证,A进入房间之后,还没有出来;B 是不是也可以进入房间,打断 A 在房间里的隐私。这个就是不具备原子性的。
那我们应该如何解决这个问题呢?是不是只要给房间加一把锁,A 进去就把门锁上,其他人是不是就进不来了。这样就保证了这段代码的原子性了。有时也把这个现象叫做同步互斥,表示操作是互相排斥的。
⼀条 java 语句不⼀定是原子的,也不一定只是一条指令。
- 从内存把数据读到 CPU 寄存器中
- 进行数据更新
- 把数据写回到内存
那么不保证原子性会给多线程带来什么问题呢?
如果不保证原子性,⼀个线程正在对⼀个变量操作,中途其他线程插入进来了,如果这个操作被打断了,结果就可能是错误的。这点也和线程的抢占式调度密切相关,如果线程不是 "抢占" 的,就算没有原子性,问题也不⼤。
- 可见性
可见性指,⼀个线程对共享变量值的修改,能够及时地被其他线程看到。这里先不过多介绍。
- 指令重排序
- 去前台取下 U 盘
- 去教室写 10 分钟作业
- 去前台取下快递
编译器对于指令重排序的前提是 "保持逻辑不发⽣变化". 这⼀点在单线程环境下比较容易判断, 但是在多线程环境下就没那么容易了, 多线程的代码执行复杂程度更高, 编译器很难在编译阶段对代码的执行效果进行预测, 因此激进的重排序很容易导致优化后的逻辑和之前不等价.
重排序是⼀个比较复杂的话题, 涉及到 CPU 以及编译器的⼀些底层⼯作原理, 此处不做过多讨论。
4、解决之前的线程不安全问题
解决之后的代码:
public class ThreadDemo2 {
private static long count = 0;
public static void main(String[] args) throws InterruptedException{
Object locker = new Object();
Thread t1 = new Thread(()->{
for (int i = 1;i <= 500000;i++) {
synchronized (locker) {
count++;
}
}
});
Thread t2 = new Thread(()->{
for (long i = 0;i < 500000;i++) {
synchronized (locker) {
count++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count= "+count);
}
}
这时的结果就一定是1000000,如图:
下面就给大家解释一下,这个线程不安全的问题是如何解决的。
5、synchronized 关键字 - 监视器锁 monitor lock
5.1 synchronized 的特性
1) 互斥
-
进入 synchronized 修饰的代码块,相当于 加锁
-
退出 synchronized 修饰的代码块,相当于 解锁
可以粗略理解成, 每个对象在内存中存储的时候, 都存有⼀块内存表示当前的 "锁定" 状态(类似于厕所 的 "有人/无人").如果当前是 "无人" 状态, 那么就可以使用, 使用时需要设为 "有人" 状态.如果当前是 "有人" 状态, 那么其他人无法使用, 只能排队
理解 "阻塞等待":
针对每⼀把锁, 操作系统内部都维护了⼀个等待队列. 当这个锁被某个线程占有的时候, 其他线程尝试 进行加锁, 就加不上了, 就会阻塞等待, ⼀直等到之前的线程解锁之后, 由操作系统唤醒⼀个新的线程, 再来获取到这个锁。注意:
- 上⼀个线程解锁之后, 下⼀个线程并不是立即就能获取到锁. 而是要靠操作系统来 "唤醒". 这也就是操作系统线程调度的⼀部分工作.
- 假设有 A B C 三个线程, 线程 A 先获取到锁, 然后 B 尝试获取锁, 然后 C 再尝试获取锁, 此时 B 和 C 都在阻塞队列中排队等待. 但是当 A 释放锁之后, 虽然 B 比 C 先来的, 但是 B 不⼀定就能获取到锁, 而是和 C 重新竞争, 并不遵守先来后到的规则.
synchronized的底层是使用操作系统的mutex lock实现的。
2) 可重入
synchronized 同步块对同⼀条线程来说是可重入的,不会出现自己把自己锁死的问题;
理解 "把自己锁死" :一个线程没有释放锁, 然后又尝试再次加锁。// 第一次加锁, 加锁成功lock();// 第二次加锁, 锁已经被占用, 阻塞等待.lock();按照之前对于锁的设定, 第二次加锁的时候, 就会阻塞等待. 直到第⼀次的锁被释放, 才能获取到第二 个锁. 但是释放第⼀个锁也是由该线程来完成, 结果这个线程已经躺平了, 啥都不想干了, 也就无法进行 解锁操作. 这时候就会死锁。
这样的锁称为 不可重入锁。
Java 中的 synchronized 是 可重入锁, 因此没有上面的问题。
for (int i = 0; i < 50000; i++) {
synchronized (locker) {
synchronized (locker) {
count++;
}
}
}
- 如果某个线程加锁的时候, 发现锁已经被人占用, 但是恰好占用的正是自己, 那么仍然可以继续获取到锁, 并让计数器自增.
- 解锁的时候计数器递减为 0 的时候, 才真正释放锁. (才能被别的线程获取到)
5.2 synchronized 使用示例
1) 修饰代码块: 明确指定锁哪个对象.
锁任意对象:
public class SynchronizedDemo {
private Object locker = new Object();
public void method() {
synchronized (locker) {
}
}
}
锁当前对象:
public class SynchronizedDemo {
public void method() {
synchronized (this) {
}
}
}
2) 直接修饰普通方法: 锁的 SynchronizedDemo 对象
public class SynchronizedDemo {
public synchronized void methond() {
}
}
3) 修饰静态方法: 锁的 SynchronizedDemo 类的对象
public class SynchronizedDemo {
public synchronized static void method() {
}
}
5.3 Java 标准库中的线程安全类
- ArrayList
LinkedList HashMap TreeMap HashSet TreeSet StringBuilder
- Vector (不推荐使⽤)
- HashTable (不推荐使⽤)
- ConcurrentHashMap
- StringBuffer
StringBuffer 的核心方法都带有 synchronized .