文章目录
- 1.线程安全
- 1.1 代码体现线程的不安全
- 1.2 线程安全问题分析
- 1.3 产生线程安全问题的原因
- 1.4 线程安全问题的解决办法
- 1.5 synchronized 的使用方法
1.线程安全
多线程的抢占式执行,带来的随机性是线程安全问题的罪魁祸首,万恶之源。
如果没有多线程,此时程序代码执行顺序就是固定的。(只有一条路)
代码顺序固定,程序的结果就是固定的。(单线程的情况下,理清这一条路即可)
如果有了多线程,在此时的抢占式执行下,代码执行的顺序会出现很多的变数!!!
代码执行顺序的可能性就从一种情况变为了无数种情况!!!
所以就需要保证这种无数种线程调度顺序的情况下,代码的执行结果都是正确的。
1.1 代码体现线程的不安全
写一个两个线程的代码,计算出count的个数。
package thread;
class Counter {
public int count = 0;
public void add() {
count++;
}
}
public class ThreadDemo15 {
public static void main(String[] args) {
Counter counter = new Counter();
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();
}
System.out.println("count:" + counter.count);
}
}
两个线程都调用add方法,输出的结果应该是100000才对。
执行结果:
可以看到实际结果却不是100000。
count++ 操作的**++**本质上要分成三步。
- 先把内存中的值读取到 CPU 的寄存器中。(load)
- 把 CPU 寄存器的值进行 +1 运算。(add)
- 把得到的结果写到内存中。(save)
这三个操作就是 CPU 上的三个指令。
约定一个时间轴,靠上的就是先执行,靠下的就是后执行。
上面就是可能的第一种调度顺序。
由于线程之间是随机调度的,导致此处的调度顺序充满了其他的可能性。
还有其他可能的顺序
第二种:
第三种:
第四种:
因为调度顺序的随机性,是有无数种顺序结果的。
1.2 线程安全问题分析
上面的调度顺序只有前两种是安全的。
下面分析第一种和第二种顺序为什么是安全的。
先按照时间轴的顺序执行 load,把 count 的值读取到 t1 中
执行add后 t1里的值变成了1。
执行 save将t1的值写回到内存中。
t1的执行完毕,开始执行t2。
把 count 的值读取到 t2 中。
执行add后 t1里的值变成了2。
执行 save将t2的值写回到内存中。
最终 count 等于2,即是两个线程分别调用一次 add 方法计算的结果。
第二种顺序和第一种类似,只是先操作的是t1,这里不在演示。
下面来演示一种 不安全 的顺序。
先按照时间轴的顺序执行 load,将count的值读取到t2中。
执行 load,将count的值读取到t1中。
执行add后 t2里的值变成了1。
执行 save将t2的值写回到内存中。
执行add后 t1里的值变成了1。
执行 save将t1的值写回到内存中。
最终 count 等于1,即是两个线程分别调用一次 add 方法计算的结果。
但是应该的结果是2,所以这是错误的。
由于线程的抢占式执行,导致当前执行到任意一个指令的时候,
线程都可能被调度走,CPU 让别的线程来执行。
这时就会发生上面的安全问题。
上面的代码是有可能输出10w的,但是这个概率非常小。
因为要求两个线程每次的调度顺序都是上述的第一种或者第二种顺序。
也与可能会小于5w,但是也是不确定的。
1.3 产生线程安全问题的原因
1、抢占式执行,随机调度。(根本原因)
2、代码结构:多个线程同时修改同一个变量。(上面代码就是这样的问题)
一个线程,修改一个变量,没事。
多个线程读取同一个变量,没事。
多个线程修改多个不同的变量,没事。
可以调整代码结构代码规避问题,但是不一定都可以使用。
3、原子性:不可拆分的基本单位
如果修改的操作是原子的,那么出现安全问题的概率会比较低。
但是如果修改的是非原子的,出现问题的概率就会非常高了。
count++ 这里可以拆分成 load、add、save 三个操作。
这三个操作是无法再进一步拆分的单个指令。
如果 ++ 操作是原子的,此时线程安全问题就解决了。
4、内存可见性问题(后面说)
如果一个线程读,一个线程该也可能会出现问题。
可能出现此处读的结果不太符合预期。
5、指令重排序(本质上是编译器优化出bug了)
编译器优化:
在逻辑不变的情况下,编译器会调整代码的执行顺序。
这是五个典型的原因,并不是全部的。
如果一个代码踩中了上面的原因,也可能线程是安全的。
如果一个代码没踩中了上面的原因,也可能线程是不安全的。
对于线程是不是安全的,我们还要结合实际、结合需求、具体问题具体分析。
最终抓住原则:多线程运行代码,不出bug,就是安全的!!!
1.4 线程安全问题的解决办法
对于线程安全问题的解决办法,最主要的手段就是从原子性入手。
把非原子的操作改为原子的,也就是加锁操作。
把不是原子的,变为原子的,需要 synchronized 关键字来实现。
把上面的代码的Counter类中的add方法加上 synchronized 关键字。
class Counter {
public int count = 0;
synchronized public void add() {
count++;
}
}
加了 synchronized 后,进入方法就会加锁,出了方法就会解锁。
如果两个线程同时加锁,此时一个能加锁成功,另一个只能阻塞等待。(BLOCKED)
一直阻塞到刚才的线程释放锁(解锁)后,当前线程才能加锁成功。
下面来掩饰如何加锁。
在 t1的 load 前面加上一个 lock(加锁),在 t1 的 save 后面加上一个unlock(解锁)
此时的 add 方法多了一个加锁和一个解锁的操作。
t2 的 load前也加一个 lock ,此时 t2 的 lock 到 load 之间处于阻塞状态。
会一直阻塞到 t1 unlock之后,才会让 t2 执行。
lock的阻塞就是把 t2 的 load 推迟到 t1 的 save 之后执行。
这就是在 t1 完成提交数据之后,t2 再来读,也就避免了安全问题。
加锁,说是保证原子性。
其实不是说让这里的三个操作一次完成,也不是使这三步操作过程中不进行调度。
而是让其他也想操作的线程阻塞等待了。
使用阻塞的手段,让 t1 和 t2 按照先是 load,再是 add,最后是 save 这样的顺序执行。
加锁的本质是把并发,变成了串行。
虽然加锁之后,计算的速度会变慢,但是还是会比单线程快。
加锁针对的只是 count++,除此之外的 for循环是可以并发执行的。
只是 count++ 是串行执行的。
在一个任务中可以一部分并发,一部分串行。
即使是这样,也是要比全部是串行快的。
加锁之后,可以看到得到了想要的10w。
1.5 synchronized 的使用方法
- 修饰方法
分为修饰普通方法和静态方法。
进入方法就加锁,离开方法就解锁。但是这两种方法加锁的对象不同。
修饰普通方法,锁对象就是this
修饰静态方法,所对象就是类对象(Counter.class)
- 修饰代码块
修改代码块。显示或者手动的指定锁对象。
加锁是要明确是对哪个对象执行加锁的。
如果两个线程对一个对象进行加锁,则会产生阻塞等待。(锁竞争、锁冲突)
如果两个线程对不同的对象进行加锁,则不会产生阻塞等待(不会锁冲突、竞争)
无论是一个什么样的对象,原则就只有一条:
锁对象相同,就会产生锁竞争(阻塞等待),锁对象不同,就不会产生锁竞争
synchronized 可以写在 public 的左或者右边
直接把 synchronized 修饰到方法上了。
此时相当于 this 加锁。
修饰静态方法和一般方法同理。
synchronized修饰代码块。
进入代码块,就加锁,出了代码块,就解锁。
可以指定任意你想指定的对象,不一定要是 this 。
一个加锁一个不加锁的情况。
class Counter {
public int count = 0;
public void add() {
synchronized(this) {
count++;
}
}
public void add2() {
count++;
}
}
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.add2();
}
});
第一个线程执行add,第二个线程执行 add2
第一个线程就是加锁了,第二个线程就是没加锁的。
代码的结果说明产生了安全问题。