文章目录
- 一、什么是线程安全问题
- 二、出现线程安全问题的原因
- 三、解决方案
- 3.1加锁
一、什么是线程安全问题
某些代码在单线程环境下执行结果完全正确,但在多线程环境下执行就会出现Bug,这就是“线程安全问题”。
下面以一个变量n自增两次,每次自增10000为例。
单线程环境下n自增两次结果为20000,执行结果正确。
public class Demo4 {
private static int n = 0;
public static void main(String[] args) {
for (int i = 0; i < 10000; i++) {
n++;
}
for (int i = 0; i < 10000; i++) {
n++;
}
System.out.println(n);
}
}
但在多线程环境下n自增两次,结果却不是20000,此时出现了Bug,出现了线程安全问题。
public class Demo1 {
private static int n = 0;//n要定义成全局变量
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
n++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
n++;
}
});
t1.start();
t2.start();
t1.join();//join一定要有,因为没有join的话t1和t2两个线程中count还没有自加主线程就已经打印n了
t2.join();
System.out.println(n);
}
}
在上诉例子中出现线程安全问题的原因是:
n++这个操作本质上是分成三步执行的,有cpu的三个指令完成这三步。这三步为
①load,把数据从内存读取到cpu寄存器中
②add,把cpu寄存器中的数据+1
③save,将寄存器中的数据再保存到内存中
比如n原始值为0,线程1有一个n++,线程2有一个n++,线程1和线程2并发执行,n最后的结果应该为2,但实际上并非如此。cpu的一个核上先执行线程1的load,再执行线程2的load、add、save,此时内存中的n变为1,最后执行线程1的add、save,此时内存中的n仍然为1。
这说明由于线程之间的调度顺序是随机的,前一个线程的这三个步骤和后一个线程的这三个步骤可能会穿插执行,这时就会出现“线程安全问题”。
二、出现线程安全问题的原因
①操作系统调度线程的顺序是随机的(抢占式执行)。
②两个线程针对同一个变量进行修改。
③修改操作是非原子性的,如n++分为三个步骤,是非原子性的操作。类似地如果一段逻辑中需要根据一定的条件来决定是否修改,也会存在类似的问题。
④内存可见性问题。比如当t1线程有一个循环速度很快的循环,同时这个循环每循环一次要读一次内存,这个短时间内多次读内存的操作开销很大。在这个短时间内,编译器/JVM发现每次从内存中读到的结果是一样的,就做了一个大胆的决定,既然每次从内存中读到寄存器中的数据是一样的,那么这个循环就不需要每循环一次就读一次内存了,第一次从内存中读数据到寄存器上后,以后的循环就读寄存器中的值,不再关心内存中的值了。这是编译器针对短时间内多次读内存操作开销很大的一个优化,提高了程序整体的效率,也可以说是编译器的Bug。
在多线程环境中,t2线程修改了内存中的值,此时t1线程不会感受到内存中数据值的变化,就会造成线程安全问题的出现。
⑤指令重排序问题。比如在下面的代码中:
private static SingletonLazy instance = null;
public static SingletonLazy getInstance() {
if (instance == null) {
synchronized (SingletonLazy.class) {
if (instance == null) {
instance = new SingletonLazy();
}
}
}
return instance;
}
上面代码创建对象的new操作,是有可能触发指令重排序的。
创建对象的new操作大概可以分成三步:
①申请内存空间。
②在内存空间上创建对象(调用构造方法)。
③把内存的地址赋值给instance引用。
可以安装①②③的顺序执行,也可以按照①③②的顺序执行,①一定是首先执行的。
假设t1按①③②的顺序执行,当t1执行完①③的时候,此时instance就已经是非空的了,但是此时instance指向的是还没有初始化的非法对象。此时t2开始执行第一个if判断(t2的第一个if语句不涉及任何加锁操作,这个if是完全可以执行的,锁的阻塞等待,是只有两个线程的某个部分都加上同一把锁的情况下才会触发。这里t1创建对象的new操作加了一把锁,t2的第一个if语句操作没有加锁,这两者是可以并发执行的),判断到instance不为空,直接返回了instance。进而t2线程的代码就可能会访问到inStance里面非法的内容。
三、解决方案
依据以上造成线程安全问题的五个原因依次给出相应的解决方案。
问题①是系统内核调度方式造成的,很难解决。
有些情况下可以通过调整代码解决问题②,但是也有很多情况因为需求要求而调整不了。
问题③是可以解决的,比如上面的例子让n++的三个步骤变为原子性(三个步骤要么全部一起执行,要么一个也不执行),即加锁。
对于问题④,内存中的值比如可以是全局变量中存储的值,此时用volatile关键字修饰这个全局变量,就可以让编译器停止对这个全局变量进行内存可见性相关的优化,内存可见性问题也就解决了。
对于问题⑤,可以给inStance加上volatile修饰,这样就可以保证inStance在被修改的过程中不会发生指令重排序了。这保证了t2的第一个if在判断inStance是否为空时,inStance为非空时一定是指向了一个已经初始化的合法对象。
3.1加锁
通过synchronized对代码进行加锁。synchronized在使用的时候要搭配一个代码块,比如:
synchronized () {
}
在代码块里写上n++。
synchronized () {
n++;
}
进入{会加锁,出了}会解锁,在前一个线程已经加锁的状态中,后一个线程尝试同样加这个锁就会产生“锁冲突/锁竞争”,后一个线程就会阻塞等待一直等到前一个线程解锁为止(这也是synchronized的特性:互斥)。当前一个线程解锁时前一个线程中的n++的三个步骤已经执行完毕,避免了前一个线程的三个步骤和后一个线程的三个步骤穿插执行的现象,实现了“串行执行”的效果,解决了线程安全问题。
那么我们怎样去区分两个线程加的锁是不是同一把锁呢?从上面的代码看出,synchronized关键字的后面有一个(),在这个()中传入一个对象,这个对象可以是随便一个对象,这个对象是谁不重要,重要的是我们可以通过这个对象来区分两个线程加的锁是不是同一把锁。
以下代码是两个线程加的锁是同一把锁,两个线程()里传的是同一个对象lock1。
Object lock1 = new Object();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
synchronized(lock1) {
n++;
}
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
synchronized(lock1) {
n++;
}
}
});
以下代码是两个线程加的锁不是同一把锁,一个线程()里传的对象是lock1,另一个线程()里传的对象是lock2。
Object lock1 = new Object();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
synchronized(lock1) {
n++;
}
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
synchronized(lock2) {
n++;
}
}
});
像下面那样对两个线程的n++加上同一把锁之后,线程安全问题就不会出现了,执行结果依然像单线程那样为20000。
public class Demo1 {
private static int n = 0;//n要定义成全局变量
public static void main(String[] args) throws InterruptedException {
Object lock1 = new Object();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
synchronized(lock1) {
n++;
}
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
synchronized(lock1) {
n++;
}
}
});
t1.start();
t2.start();
t1.join();//join一定要有,因为没有join的话t1和t2两个线程中count还没有自加主线程就已经打印n了
t2.join();
System.out.println(n);
}
}
synchronized除了修饰代码块之外,还可以修饰一个实例方法或者静态方法。以下为示例代码:
class Counter {
public static int count;
//法一
synchronized public void increase1() {
count++;
}
//法二
public void increase2() {//法二是法一的简化写法
synchronized (this) {//对当前对象加锁
count++;
}
}
//法三
synchronized public static void increase3() {
count++;
}
//法四
public static void increase4() {//法四是法三的简化写法
synchronized (Counter.class) {//对类对象加锁
count++;
}
}
}
public class Demo1 {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.increase4();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.increase4();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.count);
}
}
synchronized的特性除了“互斥”之外,还有“可重入”。
“可重入”指的是一个线程针对同一把锁连续加锁多次时不会出现“死锁”。
观察以下代码:
synchronized (locker) {
synchronized(locker) {
}
}
第二次要想加锁成功,就需要第一次加的锁进行释放。第一次加的锁要想进行释放,又必须要让第二次加锁能够成功。由于第二次加锁阻塞住了,导致第一次加的锁无法释放,出现“死锁”的情况(线程卡死了)。
由于Java中synchronized“可重入”的特性,有效地解决了上诉“死锁”的问题。
“可重入锁”包含“线程持有者”和“计数器”两个信息:①如果某个线程加锁的时候, 发现锁已经被某个线程占用, 但是恰好占用的正是自己, 那么仍然可以继续获取到锁并加锁, 并让计数器自增。每加锁一次计数器加1,每解锁一次计数器减1。②解锁的时候计数器递减为0时才真正释放锁。