我们使用多线程通常是为了提高程序执行效率, 充分调度处理器性能. 但是由于多线程的种种特性,使得假如使用不当可能会导致程序执行结果偏离我们的预期, 这就是线程不安全. 下面就列举一些常见的问题产生原因和解决办法.
线程的"抢占式执行"
内核调度线程时, 当一个进程中有多个线程时, 线程的执行几乎是无序的, 无序所以不可控, 就有可能导致程序不按照预期运行. 这属于是CPU的运行逻辑, 我们无法从代码层面干预.
线程操作存在多个指令
CPU是以指令为单位执行的, 假如线程的操作只有一个指令需要执行, 那么就不会产生问题, 因为一个指令从开始到结束不会被干预, 如int的赋值操作就是单指令的. 相反的假如存在多个指令需要被执行, 就有可能在所有指令都执行结束之前, 线程就被调度走了.
就像人要喝水的话, 假如桌面上有一瓶水, 就可以直接拿起来喝. 但假如没有水, 就要穿好衣服裤子去超市买水了才有水喝, 而这中间可能又被别人叫去干啥导致最后没喝着水.
解决方法: 通常是想办法通过特殊手段将多个操作打包为一个"原子操作", 这样在执行结束前都不会被干预.
原子操作通常是add(写入) + load(操作/修改) + save(保存)
内存可见性问题
硬件的速度比较: CPU( > 缓存) > 内存 > 硬盘. 每个层级都差了3~4个数量级. 所以假设有一个程序需要频繁地在内存中读取数据, 但是这个数据本身又没有改变, 就相当于重复读取内存的相同数据, 导致效率很低. 编译器可能会自动优化成将该数据暂时保存在CPU中防止重复内存读取, 加快执行效率. 但是此时假如中间穿插了一个修改内存中该数据的操作, 就会导致与CPU中的数据有出入, 导致程序执行出错. 如
int m = 1;
int n = 2;
for(int i = 0; i < 1_0000; i++)
boolean b = (n == m);
解决方法: 使用volatile关键字, 告诉编译器这里不要进行优化, 数据可能会随时改动.
指令重排序
多个线程访问修改一个公共变量
public class Main {
static class Counter {
public int counter;
public synchronized void increase () {
counter++;
}
}
public static void main(String[] args) throws InterruptedException {
Counter c = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1_0000; i++) {
c.increase();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1_0000; i++) {
c.increase();
}
});
t1.start();
t2.start();
t2.join();
System.out.println(c.counter);
}
}
输出随机数
解决方法就是加锁!!!
用synchronized关键字
一个线程针对一个锁对象加锁, 加锁后别的线程想对该对象进行操作(可能是加锁), 就只能阻塞等待. 只有当锁内的代码执行完了才会被解锁. 因为加锁就是为了解决线程安全为题, 防止同时修改导致数据错误.
这个加锁的顺序取决于系统调度和代码写法. 比如写了两个线程的加锁, 就由系统调度随机加锁了.但是一个一个加锁后跟了一个几秒钟的sleep, 肯定就是前面的先被加锁.
加锁方法
方法内对当前对象加锁: synchronized (this \ 特定对象 \ 特定静态对象 \ 类对象)
class Counter {
public int counter = 0;
this表示对创建的Counter对象加锁
多个Counter对象之间加锁不冲突
public void increment () {
synchronized (this) {
counter++;
}
}
}
class Counter {
public int counter = 0;
表示对创建的Counter对象中的object对象加锁
多个Counter对象之间加锁不冲突
private Object ob = new Object();
public void increment () {
synchronized (ob) {
counter++;
}
}
}
class Counter {
public int counter = 0;
表示对创建的Counter对象中的obj对象加锁
但是此时由于obj是静态的, 所以创建多个Counter也是针对同一个obj加锁, 涉及到了锁竞争
static private Object obj = new Object();
public void increment () {
synchronized (obj) {
counter++;
}
}
}
increment1是对Counter对象加锁
increment2是对ob对象加锁
不会产生锁竞争!!!!
class Counter {
public int counter = 0;
public void increment1 () {
synchronized (this) {
counter++;
}
}
private Object ob = new Object();
public void increment2 () {
synchronized (ob) {
counter++;
}
}
}
通过Counter.class反射得到Counter的类对象
对一个类对象进行加锁
所以之后每一个Counter.increment锁的都是同一个对象, 涉及锁竞争
class Counter {
public int counter = 0;
public void increment () {
synchronized (Counter.class) {
counter++;
}
}
}
ps:Java对象中的各种信息如有什么属性, 多少个属性,参数有多少个等等的信息, 都存储在该对象的类对象中, 类对象保存于.class文件(.java源文件)里, 通过反射就是套出来存有这些信息的类对象, 让这个类的信息变得可见.
锁对象
Java中所有对象都可以成为锁对象, 针对同一个锁对象进行加锁就会产生互斥.
所以在判断锁是否存在竞争互斥的时候, 判断锁是否加在同一个对象上是最核心的判断原则.
package threading;
public class Demo {
static class IT{
public int iterator = 0;
public void increment () {
synchronized (this) {
iterator++;
}
}
}
public static void main(String[] args) {
IT it1 = new IT();
IT it2 = new IT();
Thread t1 = new Thread(() -> {
it1.increment();
});
Thread t2 = new Thread(() -> {
it2.increment();
});
t1.start();
t2.start();
}
}
//this表示锁当前对象, 两个对象it1和it2都不一样, 所以不存在锁竞争
可重入锁
假如一个锁里面再有一个锁, 就会造成死循环. 因为前一个锁需要等锁的内容执行完才会开锁, 而后一个锁要等前一个锁执行才会加锁, 前面等后面, 后面等前面, 死循环. Java为了避免这样情况的发生, 将synchronized设置为了可重入锁.
假如使用synchronized之后, 发现里面还准备加锁, 就会直接忽略那个锁, 相当于后面那个锁不存在.
实现原理大概是通过计数器的形式实现, 锁的数量为0可加, 不为0就放行.
ps:一个程序在锁中发生异常后, 会脱离当前代码块, 实现锁-1, 防止死锁(永远无法解开的锁)的发生.