一、线程安全
概述
1、定义
线程安全是指代码在多线程环境下执行时,仍然可以表现出现正确行为。
那代码会在多线程的情况下,出现线程不安全问题呢?这是因为存在共享变量。可以参考Java内存模型
2、线程不安全行为
(1)共享变量
因为共享变量而出现的线程不安全问题,主要是因为对共享变量的操作是非原子性的,经典的有俩种get-do-set
和check-then-act
。
如何解决因为共享变量而出现的线程不安全问题呢?
- 不可变对象,比如
String
; - 各种锁,比如
synchonized
、Lock
- 并发工具包,即
juc
包
(2)死锁问题
死锁问题的定义就是俩个线程已经各自持有一个锁了,并且等待另一个线程释放锁的现象。
了解死锁的解决方案,以及预防死锁。
背景知识
1、java内存模型
Java Memory Model
简称JMM
,即java内存模型
。下面有俩张图介绍了:
每个线程都有一个方法栈;堆中存放共享的变量。就是这些堆中变量而引发线程安全问题。
但是为了进一步减少CPU
与内存之间速度得差异,公有变量会在每一个线程中存在私有得副本。有时候代码会因此出现很诡异得情况。
每个线程都会有一个工作内存,其存储的是公有变量的副本。它会定时的与主内存去同步。这也是为了提高CPU的运行效率。 这里就是导致一个问题:一个线程以为自己修改了公有变量,另一个会反应过来。但可能并没有。比如说:
public class Demo {
static boolean cancelled = false;
public static void main(String[] args) throws InterruptedException {
Thread time = new Thread(() -> {
while (true){
if (cancelled){
// 取消自己做的事情
break;
}
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
e.printStackTrace();
}
// 做一些定时器相关的工作
}
});
cancelled = true;
time.start();
}
}
比如说这个子线程在获取共享变量cancelled
,假设它只从自己的工作内存读取这个cancelled
,这就完了它直接执行定时器工作,但是其实我们业务上也取消这个。
2、volatile
上面的问题可以使用volatile
这个关键字来解决的。它提供了:
(1)可见性,并非原子性
可见性是指线程在操作共享变量得时候,会直接去主内存去操作,不会去线程得工作内存操作。可见,见的是线程对主内存中的共享变量。
标明volatile
的变量会直接操作主内存变量,不会去处理线程工作内存的变量值。
(2)禁止指令重排
- 什么是指令重排?
编译器和处理器都可能对指令进行重排,导致问题 。指令重排是在cpu运行指令时发生的操作,它会原来将某些不影响执行顺序的指令进行重新排列,以此来优化代码执行效率。比如:
public class Demo {
static boolean initializationFinished = false;
public static void main(String[] args) {
init();
initializationFinished = true;
}
}
当编译器认为这俩条语句直接顺序并没有直接关系,所以它可能把他们优化成:
initializationFinished = true;
init();
但这对业务来说,就是有问题,可能会产生莫名奇妙的错误。比如:
public class Demo {
static boolean initializationFinished = false;
public static void main(String[] args) {
init();
initializationFinished = true;
if (initializationFinished){
//执行初始化之后的操作
}
}
}
由于指令重排是在一个线程上的操作,如果主线程先修改了initializationFinished
变量,子线程执行的逻辑顺序就不对了。这里就发生主线程还没执行完初始化方法,子线程就已经在做初始化后的方法了。
(3)注意
当使用其他同步手段的话,比如:synchronized
、Lock
。这时候就没必要使用volatitle
。synchronized、Lock
属于更强的同步手段、它会产生可见性和禁止指令重排的效果
线程安全问题
1、非线程安全的操作
针对于共享变量而引出的非线程安全操作,都是由于对共享变量的操作是非原子性的。大概有俩种模式:
get-do-set
check-then-act
(1)get-do-set
问题
private static int globalI = 0;
public static void main(String[] args) {
for (int i = 0; i < 3; i++) {
new Thread(() -> globalI++).start();
}
}
这里的问题是globalI++
这个操作不是原子的,它会分成三步完成。
(2)check-then-act
问题
private static Map<String, Object> values = new ConcurrentHashMap<>();
public static void main(String[] args) {
for (int i = 0; i < 3; i++) {
new Thread(() -> {
if (!values.containsKey("key")){
values.put("key","value");
}
}).start();
}
}
看看,如果操作是非原子的话,即使是线程安全的容器也不管用。这里就是典型的check-then-do
,尽管线程安全的容器的所有方法都是原子的,但俩个原子在一起就不是原子的了。这里可以使用putIfAbsent()
方法。
2、如何解决
解决这个问题很简单,就是让非原子操作变成原子操作。这里有俩种方式:
- 使用不可变对象???这是什么意思,
final
关键字也有作用,惊了 - 使用线程安全容器。比如
juc
包下的类 - 使用锁(
synchonized
、Lock
)
死锁问题
1、定义
俩个线程都持有一个锁,并且都在等到对方释放锁的状态。称之为死锁。如何产生一个死锁:
public class Demo {
private static Object lock1 = new Object();
private static Object lock2 = new Object();
public static void main(String[] args) {
new Thread(() -> {
synchronized (lock1){
try {
Thread.sleep(5000);
synchronized (lock2){
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
e.printStackTrace();
}
}
}).start();
synchronized (lock2){
try {
Thread.sleep(5000);
synchronized (lock1){
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
e.printStackTrace();
}
}
}
}
2、碰到死锁问题如何排查
程序死锁的时候,程序会处于静止的状态。它什么都做不了,跟死了似的。排查:
jps
+jstack
- 结合源代码
Object.wait()
+Object.notify()
等待唤醒机制Lock
、Condition
3、如何避免死锁
其实死锁产生的所有原因都是多个线程在获取锁的时候,没有以相同的顺序获取锁。所以解决方案也很简单,获取相同锁的线程要以相同顺序获取锁。
但是实际开发中,由于代码过于复杂,一般无法在编码和代码审查阶段就避免这个问题。只有等报错的时候,再去处理这个问题。