Java多线程之线程安全问题
1、线程安全
1.1、什么是线程安全?
当多个线程访问一个对象时如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行文都可以获得正确的结果,那这个对象是线程安全的。
翻译:
不管业务中遇到怎样的多个线程访问某对象或某方法的情况,而在编程这个业务逻辑的时候,都不需要额外做任何处理(也就是可以像单线程编程一样),程序也可以正常运行,不会因为多线程而出错,这就可以称为线程安全。
相反,如果在编程的时候,需要考虑这些线程在运行时的调度和交替(例如在get()调用到期间不能调用set()),或者需要进行额外的同步(比如使用synchronized关键字等),那么就是线程不安全的。
1.2、什么情况下会出现线程安全问题,怎么避免?
- 数据争用:两个数据同时去写,造成其中一方的数据要么被丢弃要么写入错误;
- 竞争条件:执行顺序,比如去读取一个文件的内容,那么自然是在这个文件写完之后的,假设线程配合的不好,我在你没写完之前就来读取,这样就会造成顺序上的错误。
1.3、一共有哪几类线程安全问题?
- 运行结果错误
- 活跃性问题:死锁、活锁、饥饿
- 对象发布和初始化的时候的安全问题
1.3.1、运行结果错误
a++多线程下出现消失的请求现象:
public class MultiThreadsError implements Runnable {
static MultiThreadsError instance = new MultiThreadsError();
int index = 0;
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(instance);
Thread thread2 = new Thread(instance);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("表面上结果是" + instance.index); //小于20000,每次结果不一样
}
@Override
public void run() {
/* while (index <10000) { //不能控制执行次数
index++;
}*/
for (int i = 0; i < 10000; i++) {
index++;
}
}
}
原因:i++写入时出错
假设 i=1, 正常情况下,两个线程执行完 i++,之后 i=3;但是结果可能会发生 i=2的情况:
线程1拿到 i=1 之后 计算i++,但是并没有立刻写进去,导致第二个线程在拿的时候 i还是1
于是把拿到的1在加1得到 i=2
解决:a、加入同步锁-MultiThreadsErrorFix0.java
;b、引入CyclicBarrier
-MultiThreadsErrorFix1.java
1.3.2、活跃性问题
死锁:
public class MustDeadLock implements Runnable {
int flag = 1;
static Object o1 = new Object();
static Object o2 = new Object();
public static void main(String[] args) {
MustDeadLock r1 = new MustDeadLock();
MustDeadLock r2 = new MustDeadLock();
r1.flag = 1;
r2.flag = 0;
Thread t1 = new Thread(r1);
Thread t2 = new Thread(r2);
t1.start();
t2.start();
}
@Override
public void run() {
System.out.println("flag = " + flag);
if (flag == 1) {
synchronized (o1) {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (o2) {
System.out.println("线程1成功拿到两把锁");
}
}
}
if (flag == 0) {
synchronized (o2) {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (o1) {
System.out.println("线程2成功拿到两把锁");
}
}
}
}
}
分析原因:由于线程1走到 synchronized (o1) {}
这边,去拿synchronized (o2)时,
而synchronized (o2)已经被线程2所拿到,所以,线程1在synchronized (o2) 处等待,而线程2会在synchronized (o1) 处等待。
1.3.3、对象发布和初始化的时候的安全问题
什么是发布?
如果一个对象被声明为public那么就是被发布出去了;或者一个方法的return如果是一个对象的话,那么任何调用这个方法的类都获得这个对象;或者把这个类作为参数传到其他类的方法中,这些都称为发布。
什么是逸出?发布到了不该发布的地方。
1、方法返回一个private对象(private的本意是不让外部访问)——MultiThreadsError3.java
;
2、还未完成初始化(构造函数没完全执行完毕)就把对象提供给外界,比如:
- 在构造函数中未初始化完毕就this赋值-
MultiThreadsError4.java
; - 隐式逸出——注册监听事件-
MultiThreadsError5.java
; - 构造函数中运行线程-
MultiThreadsError6.java
;
如何解决逸出?
- 返回“副本”;
MultiThreadsError3.java
- 工厂模式
MultiThreadsError7.java
2、哪些场景需要额外注意线程安全问题?
- 访问共享的变量或资源,会有并发风险;比如对象的属性、静态变量、共享缓存、数据库等;
- 所有依赖时序的操作;即使每一步操作都是线程安全的,还是存在并发问题:可以用
synchronized
作为一个原子操作绑定起来read-modify-write
:先读取再修改、check-then-act
:先检查再执行; - 不同的数据之间存在捆绑关系的时候:ip和端口号;
- 我们使用其它类的时候,如果对方没有声明自己是线程安全的。
HashMap
不是线程安全,ConcurrentHashMap
线程安全
3、如何保证高并发场景下的线程安全
线程安全问题只在多线程环境下才出现,单线程串行执行不存在此问题。保证高并发场景下的线程安全,可以从以下四个维度考量:
- 数据单线程内可见。单线程总是安全的。通过限制数据仅在单线程内可见,可以避免数据被其他线程篡改。最典型的就是线程局部变量,它存储在独立虚拟机栈帧的局部变量表中,与其他线程毫无瓜葛。
ThreadLocal
就是采用这种方式来实现线程安全的。 - 只读对象。只读对象总是安全的。它的特性是允许复制、拒绝写入。最典型的只读对象有
String
、Integer
等。一个对象想要拒绝任何写入,必须要满足以下条件:a、使用final 关键字修饰类,避免被继承;b、使用private final 关键字避免属性被中途修改;c、没有任何更新方法;d、返回值不能为可变对象。 - 线程安全类。某些线程安全类的内部有非常明确的线程安全机制。比如
StringBuffer
就是一个线程安全类,它采用synchronized
关键字来修饰相关方法。 - 同步与锁机制。如果想要对某个对象进行并发更新操作,但又不属于上述三类,需要开发工程师在代码中实现安全的同步机制。虽然这个机制支持的并发场景很有价值,但非常复杂且容易出现问题。
4、Java 并发包(JUC)
线程安全的核心理念就是要么只读,要么加锁。合理利用好JDK 提供的并发包,往往能化腐朽为神奇。并发包主要分成以下几个类族:
- 线程同步类。这些类使线程间的协调更加容易,支持了更加丰富的线程协调场景,逐步淘汰了使用
Object
的wait()
和notify()
进行同步的方式。主要代表为CountDownLatch
、Semaphore
、CyclicBarrier
等。 - 并发集合类。集合并发操作的要求是执行速度快,提取数据准。最著名的类
ConcurrentHashMap
莫属,它不断地优化,由刚开始的锁分段到后来的CAS,不断地提升并发性能。其他还有ConcurrentSkipListMap
、CopyOnWriteArrayList
、BlockingQueue
等。 - 线程管理类。虽然Thread 和ThreadLocal 在JDK1.0 就已经引入,但是真正把Thread 发扬光大的是线程池。根据实际场景的需要,提供了多种创建线程池的快捷方式,如使用Executors 静态工厂或者使用ThreadPoolExecutor 等。另外,通过ScheduledExecutorService 来执行定时任务。
- 锁相关类。锁以Lock 接口为核心,派生出在一些实际场景中进行互斥操作的锁相关类。最有名的是
ReentrantLock
。锁的很多概念在弱化,是因为锁的实现在各种场景中已经通过类库封装进去了。
5、为什么多线程会带来性能问题?
1、调度开销:上下文切换、缓存失效(CPU高速缓存);
2、协作开销:内存同步,为了数据的正确性,同步手段往往会使用禁止编译器优化、使CPU内的缓存失效。
上下文切换:
在实际开发中,线程数往往是大于 CPU 核心数的,比如 CPU 核心数可能是 8 核、16 核,等等,但线程数可能达到成百上千个。这种情况下,操作系统就会按照一定的调度算法,给每个线程分配时间片,让每个线程都有机会得到运行。而在进行调度时就会引起上下文切换,上下文切换会挂起当前正在执行的线程并保存当前的状态,然后寻找下一处即将恢复执行的代码,唤醒下一个线程,以此类推,反复执行。但上下文切换带来的开销是比较大的,假设我们的任务内容非常短,比如只进行简单的计算,那么就有可能发生我们上下文切换带来的性能开销比执行线程本身内容带来的开销还要大的情况。概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换。
缓存失效(CPU高速缓存):
不仅上下文切换会带来性能问题,缓存失效也有可能带来性能问题。由于程序有很大概率会再次访问刚才访问过的数据,所以为了加速整个程序的运行,会使用缓存,这样我们在使用相同数据时就可以很快地获取数据。可一旦进行了线程调度,切换到其他线程,CPU就会去执行不同的代码,原有的缓存就很可能失效了,需要重新缓存新的数据,这也会造成一定的开销,所以线程调度器为了避免频繁地发生上下文切换,通常会给被调度到的线程设置最小的执行时间,也就是只有执行完这段时间之后,才可能进行下一次的调度,由此减少上下文切换的次数。
协作开销:
除了线程调度之外,线程协作同样也有可能带来性能问题。因为线程之间如果有共享数据,为了避免数据错乱,为了保证线程安全,就有可能禁止编译器和CPU对其进行重排序等优化,也可能出于同步的目的,反复把线程工作内存的数据 flush 到主存中,然后再从主内存 refresh 到其他线程的工作内存中等等。这些问题在单线程中并不存在,但在多线程中为了确保数据的正确性,就不得不采取上述方法,因为线程安全的优先级要比性能优先级更高,这也间接降低了我们的性能。
下一章: 第十一章 Java内存模型——底层原理