1、运行结果错误。
比如 i++ 操作,表面上看只是一行代码,但实际上它并不是一个原子操作,它的执行步骤主要分为三步,而且在每步操作之间都有可能被打断。
第一个步骤是读取;
第二个步骤是增加;
第三个步骤是保存。
public class WrongResult { public static void main(String[] args) throws Exception { var add = new AddThread(); var dec = new AddThread(); add.start(); dec.start(); add.join(); dec.join(); System.out.println(Counter.count); } } class Counter { public static int count = 0; } class AddThread extends Thread { public void run() { for (int i=0; i<10000; i++) { Counter.count += 1; } } } class DecThread extends Thread { public void run() { for (int i=0; i<100; i++) { Counter.count -= 1; } } }
打印出的 count 不是20000.
线程 1 首先拿到 i=1 的结果,然后进行 i+1 操作,但此时 i+1 的结果并没有保存下来,线程 1 就被切换走了,于是 CPU 开始执行线程 2,它所做的事情和线程 1 是一样的 i++ 操作,但此时我们想一下,它拿到的 i 是多少?实际上和线程 1 拿到的 i 的结果一样都是 1,为什么呢?因为线程 1 虽然对 i 进行了 +1 操作,但结果没有保存,所以线程 2 看不到修改后的结果。
然后假设等线程 2 对 i 进行 +1 操作后,又切换到线程 1,让线程 1 完成未完成的操作,即将 i+1 的结果 2 保存下来,然后又切换到线程 2 完成 i=2 的保存操作,虽然两个线程都执行了对 i 进行 +1 的操作,但结果却最终保存了 i=2 的结果,而不是我们期望的 i=3,这样就发生了线程安全问题,导致了数据结果错误,这也是最典型的线程安全问题。
注意:
join() method suspends the execution of the calling thread until the object called finishes its execution.
t.join()方法阻塞调用此方法的线程(calling thread),直到线程t完成,此线程再继续;通常用于在main()主线程内,等待其它线程完成再结束main()主线程。
2、发布和初始化导致线程安全问题。
import java.util.HashMap; import java.util.Map; public class WrongInit { public static void main(String[] args) { WrongInit multiThreadsError6 = new WrongInit(); System.out.println(multiThreadsError6.getStudents().get(1)); } private Map<Integer, String> students; public WrongInit() { new Thread(new Runnable() { @Override public void run() { students = new HashMap<>(); students.put(1, "王小美"); students.put(2, "钱二宝"); students.put(3, "周三"); students.put(4, "赵四"); } }).start(); } public Map<Integer, String> getStudents() { return students; } }
会发生空指针异常。
因为 students 这个成员变量是在构造函数中新建的线程中进行的初始化和赋值操作,而线程的启动需要一定的时间,但是我们的 main 函数并没有进行等待就直接获取数据,导致 getStudents 获取的结果为 null,这就是在错误的时间或地点发布或初始化造成的线程安全问题。
3、活跃性问题。
包括死锁、活锁和饥饿。
①死锁是指两个线程之间相互等待对方资源,但同时又互不相让,都想自己先执行
public class MayDeadLock{ Object o1 = new Object(); Object o2 = new Object(); public static void main(String[] args) { MayDeadLock mayDeadLock = new MayDeadLock(); new Thread(new Runnable() { public void run() { try { mayDeadLock.thread1(); }catch(InterruptedException e){ e.printStackTrace(); } } }).start(); new Thread(new Runnable() { public void run() { try { mayDeadLock.thread2(); }catch(InterruptedException e){ e.printStackTrace(); } } }).start(); } public void thread1() throws InterruptedException{ synchronized(o1) { Thread.sleep(500); synchronized(o2) { System.out.println("线程1成功拿到两把锁"); } } } public void thread2() throws InterruptedException{ synchronized(o2) { Thread.sleep(500); synchronized(o1) { System.out.println("线程2成功拿到两把锁"); } } } }
②活锁是指正在运行的线程并没有阻塞,它始终在运行中,却一直得不到结果。
假设有一个消息队列,队列里放着各种各样需要被处理的消息,而某个消息由于自身被写错了导致不能被正确处理,执行时会报错,可是队列的重试机制会重新把它放在队列头进行优先重试处理,但这个消息本身无论被执行多少次,都无法被正确处理,每次报错后又会被放到队列头进行重试,周而复始,最终导致线程一直处于忙碌状态,但程序始终得不到结果,便发生了活锁问题。
③饥饿是指线程需要某些资源时始终得不到,尤其是CPU 资源,就会导致线程一直不能运行而产生的问题。或者是某个线程始终持有某个文件的锁,而其他线程想要修改文件就必须先获取锁,这样想要修改文件的线程就会陷入饥饿,长时间不能运行。
四种需要额外注意线程安全问题的场景:
1.访问共享变量或资源
2.依赖时序的操作
3.不同数据之间存在绑定关系
4.对方没有声明自己是线程安全的,如 ArrayList
ref:
1.拉勾课程 徐隆曦 《Java 并发编程 78 讲》
2.廖雪峰 https://www.liaoxuefeng.com/wiki/1252599548343744/1306580844806178