线程安全问题
如果某个对象是线程安全的,那么对于使用者而言,在使用时就不需要考虑方法间的协调问题,比如不需要考虑不能同时写入或读写不能并行的问题,也不需要考虑任何额外的同步问题,比如不需要额外自己加
synchronized
锁或者lock
锁,那么它就是线程安全的。
运行结果问题
先看一段代码:
public class WrongResult {
private static int temp = 0;
public static void main(String[] args) throws InterruptedException {
Runnable r = () -> {
// 自增 10000
for (int i = 0; i < 10000; i++) {
temp++;
}
};
Thread t1 = new Thread(r);
Thread t2 = new Thread(r);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(temp);
}
}
复制代码
代码逻辑:对 temp 变量初始化为 0,启动两个线程,每个线程都让 temp 自增 1000,结果应该为 20000。
当你实际运行这段代码你会发现,啥结果都有,就是跑不出来 20000。完全不符合我们的预期。
分析:
temp++
操作其实分为 3 步:
- 第一个步骤是读取;
- 第二个步骤是增加;
- 第三个步骤是保存。
假设两个线程 T1,T2,temp 初始值为 1:
(1)T1 读取 temp,并增加 1,此时 T1 手上的 temp 为 2,但是还没有保存;
(2)此时线程上下文切换,T2 执行 temp++ 操作,因为 T1 没有保存,所以读取到的 temp 还是为 1
(3)T2 执行完成,temp++ 操作成功,保存,temp 值改变为 2
(4)此时切换回 T1 线程,将 2 保存
(5)最终结果 temp 为 2,明明执行了两次 temp++ 操作,却只加了 1 次,这就是线程安全问题
数据初始化问题
先看一段代码:
public static void main(String[] args) {
List<String> list = new ArrayList<>();
new Thread(() -> {
list.add("1");
list.add("2");
list.add("3");
list.add("4");
list.add("5");
list.add("6");
}).start();
System.out.println(list.get(5));
}
复制代码
预期结果应该是 6,但是实际运行却抛出了 java.lang.IndexOutOfBoundsException
。
分析:
之所以造成这样的原因是因为 list 变量还没有初始化完毕就获取值了,因为初始化的线程和主线程是两个不同的线程,所以他们的执行是互不干扰的,所以取不到值。
活跃性问题
活跃性问题就是程序始终得不到运行的最终结果。
死锁
死锁是指两个线程之间相互等待对方资源,但同时又互不相让,都想自己先执行。
先看一段代码:
public static void main(String[] args) {
Object lock1 = new Object();
Object lock2 = new Object();
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "\t 尝试获取 lock1");
synchronized (lock1) {
System.out.println(Thread.currentThread().getName() + "\t 已经获取 lock1");
System.out.println(Thread.currentThread().getName() + "\t 尝试获取 lock2\n");
try {
// 休眠 1 秒,模拟延时情况
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lock2) {
System.out.println(Thread.currentThread().getName() + "\t 已经获取 lock2");
}
}
}, "T1").start();
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "\t 尝试获取 lock2");
synchronized (lock2) {
System.out.println(Thread.currentThread().getName() + "\t 已经获取 lock2");
System.out.println(Thread.currentThread().getName() + "\t 尝试获取 lock1");
synchronized (lock1) {
System.out.println(Thread.currentThread().getName() + "\t 已经获取 lock2");
}
}
}, "T2").start();
}
复制代码
代码逻辑:T1 线程给 lock1 变量加锁,然后再给 lock2 变量加锁;T2 的加锁逻辑相反,先给 lock2 变量加锁,再给 lock1 变量加锁。
运行结果:
程序进入死循环:T1 线程持有 lock1 的锁,想获取 lock2 的锁;T2 线程持有 lock2 的锁,想获取 lock1 的锁,就造成一种情况:都在互相等待对方释放锁,从而导致死锁。
活锁
活锁与死锁非常相似,也是程序一直等不到结果,但对比于死锁,活锁是活的,什么意思呢?因为正在运行的线程并没有阻塞,它始终在运行中,却一直得不到结果。
举一个例子,假设有一个消息队列,队列里放着各种各样需要被处理的消息,而某个消息由于自身被写错了导致不能被正确处理,执行时会报错,可是队列的重试机制会重新把它放在队列头进行优先重试处理,但这个消息本身无论被执行多少次,都无法被正确处理,每次报错后又会被放到队列头进行重试,周而复始,最终导致线程一直处于忙碌状态,但程序始终得不到结果,便发生了活锁问题。
饥饿
饥饿是指线程需要某些资源时始终得不到,尤其是CPU 资源,就会导致线程一直不能运行而产生的问题。
一种情况是优先级太低了,长期得不到 CPU 调度。
另一种情况是某个资源的锁长期被某个线程占用,导致想要获取这个资源的线程一致获取不到锁,导致一直等待,就是类似于饥饿一样。
哪些场景需要注意线程安全问题
访问共享变量或资源
型的场景有访问共享对象的属性,访问 static
静态变量,访问共享的缓存,等等。因为这些信息不仅会被一个线程访问到,还有可能被多个线程同时访问,那么就有可能在并发读写的情况下发生线程安全问题。
就比如上面讲的那个运行结果问题。
有依赖关系的操作
单例模式应该都知道,有一种写法是线程不安全的:
public class Singleton {
private static Singleton singleton;
private Singleton() {}
public static Singleton getInstance() {
if (singleton == null) {
singleton = new Singleton();
}
return singleton;
}
}
复制代码
分析:
假如有两个线程:T1 和 T2,singleton 为 null,没有初始化过。
(1)T1 要获取实例对象,进入 if (singleton == null)
,此时为 true,准备执行构造对象代码
(2)注意此时 T1 应该某种原因(线程上下文切换等)还没有执行构造对象代码,也就是说 singleton 还是为 null
(3)同时 T2 要获取实例对象,进入 if (singleton == null)
,此时为 true,准备执行构造对象代码
(4)结果就是:T1 和 T2 分别构造了一个对象,这还算单例吗?
所以有依赖关系的操作必须要保证线程安全!
这里的依赖关系就是:必须为 null 才能进行构造对象。
线程不安全的类
在 JDK
中有些类是线程不安全的,比如说 ArrayList,HashMap、StringBuilder
等,所以在使用它们的时候一定要格外注意是否是在并发环境下使用,如果是的话,请换成线程安全的类,比如 CopyOnWriteArrayList、ConcurrentHashMap、StringBuffer
等。
性能问题
我们使用多线程的最大目的不就是为了提高性能吗?让多个线程同时工作,加快程序运行速度,为什么反而会带来性能问题呢?
这是因为单线程程序是独立工作的,不需要与其他线程进行交互,但多线程之间则需要调度以及合作,调度与合作就会带来性能开销从而产生性能问题。
调度开销
线程上下文切换
在实际开发中,线程数往往是大于 CPU 实际的核心数的,这种情况下,操作系统就会按照一定的调度算法,给每个线程分配时间片,让每个线程都有机会得到运行。而在进行调度时就会引起上下文切换。
假设我们的任务内容非常短,比如只进行简单的计算,那么就有可能发生我们上下文切换带来的性能开销比执行线程本身内容带来的开销还要大的情况。
如果程序频繁地竞争锁,或者由于 IO 读写等原因导致频繁阻塞,那么这个程序就可能需要更多的上下文切换,这也就导致了更大的开销,我们应该尽量避免这种情况的发生。
协作开销
因为线程之间如果有共享数据,为了避免数据错乱,为了保证线程安全,就有可能禁止编译器和 CPU 对其进行重排序等优化,也可能出于同步的目的,反复把线程工作内存的数据 flush
到主存中,然后再从主内存 refresh
到其他线程的工作内存中,等等。
volatile
关键字:
(1)可见性;(2)禁止指令重排;(3)不保证原子性。
线程安全的优先级要比性能优先级更高,这也间接降低了我们的性能。