这是我2021年的第3篇原创文章,原汁原味的技术之路尽在Jerrycodes
- 串行和并行
- 线程安全常见错误
- 运行结果错误
- 发布和初始化导致线程安全问题
- 活跃性问题
- 需要用到线程安全的场景
- 访问共享变量或资源
- 依赖时序的操作
- 不同数据之间存在绑定关系
- 对方没有声明自己是线程安全的
- 总结
- 尾语
串行和并行
提到多线程这里要说两个概念,就是串行和并行,搞清楚这个我们才能更好的理解多线程。
串行其实是相对于单条线程来执行多个任务来说的,我们就拿下载文件来举个例子,我们下载多个文件,在串行中它是按照一定的顺序去进行下载的,也就是说必须等下载完A之后,才能开始下载B,它们在时间上是不可能发生重叠的。
并行:下载多个文件,开启多条线程,多个文件同时进行下载,这里是严格意义上的在同一时刻发生的,并行在时间上是重叠的。
线程安全常见错误
线程安全是Java面试中的常客,而在Java中有一些类本身是线程安全的,这些类就是线程安全类,例如ConcurrentHashMap。但是有时候错误地使用线程安全类反而会出现线程不安全的情况。
对象线程安全的定义是:当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行问题,也不需要进行额外的同步,而调用这个对象的行为都可以获得正确的结果。
如果某个对象是线程安全的,那么对于使用者而言,在使用时就不需要考虑方法间的协调问题,比如不需要考虑不能同时写入或读写不能并行的问题,也不需要考虑任何额外的同步问题。
在拥有共享数据的多条线程并行执行的程序中,线程安全的代码会通过同步机制如自己加 synchronized 锁,保证各个线程都可以正常且正确的执行,不会出现数据污染等意外情况。
而我们在实际开发中经常会遇到线程不安全的情况,那么一共有哪 3 种典型的线程安全问题呢?
- 运行结果错误;
- 发布和初始化先后顺序混乱导致线程安全问题;
- 活跃性问题,例如死锁和活锁。
运行结果错误
首先,来看多线程同时操作一个变量导致的运行结果错误。
public class WrongResult {
volatile static int i;
public static void main(String[] args) throws InterruptedException {
Runnable r = new Runnable() {
@Override
public void run() {
for (int j = 0; j < 10000; j++) {
i++;
}
}
};
Thread thread1 = new Thread(r);
thread1.start();
Thread thread2 = new Thread(r);
thread2.start();
thread1.join();
thread2.join();
System.out.println(i);
}
}
理论上得到的结果应该是20000,但实际结果呢?
你可以去试验10000次,应该没有一次是20000。
结果可能是16326,也可能是12362,每次的结果都还不一样,这是为什么呢?
是因为在多线程下,CPU 的调度是以时间片为单位进行分配的,这也意味着在短时间内很有可能有多个线程对某一个变量进行操作。
i++ 操作,表面上看只是一行代码,但实际上它并不是一个原子操作,它的执行步骤主要分为三步,而且在每步操作之间都有可能被打断。
- 从内存中读取变量;
- 增加变量;
- 将变量进行保存。
线程 1 首先拿到 i=1 的结果,然后进行 i+1 操作,但此时 i+1 的结果并没有保存下来,线程 1 就被切换走了,于是 CPU 开始执行线程 2,它所做的事情和线程 1 是一样的 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,这样就发生了线程安全问题,导致了数据结果错误,这也是最典型的线程安全问题。
发布和初始化导致线程安全问题
第二种是对象发布和初始化时导致的线程安全问题。如果我们发布和初始化的顺序没有同步,容易导致线程安全问题。
public class WrongInit {
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;
}
public static void main(String[] args) throws InterruptedException {
WrongInit multiThreadsError6 = new WrongInit();
System.out.println(multiThreadsError6.getStudents().get(1));
}
}
在类中,定义一个类型为 Map 的成员变量 students,Integer 是学号,String 是姓名。然后在构造函数中启动一个新线程,并在线程中为 students 赋值。
- 学号:1,姓名:张三;
- 学号:2,姓名:李四;
- 学号:3,姓名:周五;
- 学号:4,姓名:赵六。
只有当线程运行完 run() 方法中的全部赋值操作后,4 名同学的全部信息才算是初始化完毕,可是我们看在主函数 mian() 中,初始化 WrongInit 类之后并没有进行任何休息就直接打印 1 号同学的信息。
试想这个时候程序会出现什么情况?实际上会发生空指针异常。
Exception in thread "main" java.lang.NullPointerException
at lesson6.WrongInit.main(WrongInit.java:32)
这又是为什么呢?
因为 students 这个成员变量是在构造函数中新建的线程中进行的初始化和赋值操作,而线程的启动需要一定的时间,但是我们的 main 函数并没有进行等待就直接获取数据,导致 getStudents 获取的结果为 null,这就是在错误的时间或地点发布或初始化造成的线程安全问题。这说明初始化和发布操作顺序必须一致,才能避免此类问题。
活跃性问题
第三种线程安全问题统称为活跃性问题,最典型的有三种,分别为死锁、活锁和饥饿。
什么是活跃性问题呢,活跃性问题就是程序始终得不到运行的最终结果,相比于前面两种线程安全问题带来的数据错误或报错,活跃性问题带来的后果可能更严重,比如发生死锁会导致程序完全卡死,无法向下运行。
死锁
最常见的活跃性问题是死锁,死锁是指两个线程之间相互等待对方资源,但同时又互不相让,都想自己先执行,如代码所示。
public class MayDeadLock {
Object o1 = new Object();
Object o2 = new Object();
public void thread1() throws InterruptedException {
synchronized (o1) {
Thread.sleep(1000);
synchronized (o2) {
System.out.println("线程1成功拿到两把锁");
}
}
}
public void thread2() throws InterruptedException {
synchronized (o2) {
Thread.sleep(1000);
synchronized (o1) {
System.out.println("线程2成功拿到两把锁");
}
}
}
public static void main(String[] args) {
MayDeadLock mayDeadLock = new MayDeadLock();
new Thread(new Runnable() {
@Override
public void run() {
try {
mayDeadLock.thread1();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
try {
mayDeadLock.thread2();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
}
}
首先,代码中创建了两个 Object 作为 synchronized 锁的对象,线程 1 先获取 o1 锁,sleep(1000) 之后,获取 o2 锁;线程 2 与线程 1 执行顺序相反,先获取 o2 锁,sleep(1000) 之后,获取 o1 锁。
假设两个线程几乎同时进入休息,休息完后,线程 1 想获取 o2 锁,线程 2 想获取 o1 锁,这时便发生了死锁,两个程序既不会主动停止申请,也不会释放自己本身持有的资源,处于循环等待的过程中。
在死锁发生时,必然存在一个“进程-资源环形链”,即:{p0,p1,p2,…pn},进程p0(或线程)等待p1占用的资源,p1等待p2占用的资源,pn等待p0占用的资源。(最直观的理解是,p0等待p1占用的资源,而p1而在等待p0占用的资源,于是两个进程就相互等待)
这种死锁的出现解决方案是什么?
申请锁的时候执行顺序要一致,每个线程的申请顺序都固定为先申请o1,再申请o2。这样可以避免死锁情况的发生。
活锁
活锁指的是任务或者执行者没有被阻塞,由于某些条件没有满足,导致一直重复尝试—失败—尝试—失败的过程。
假设有一个消息队列,队列里放着各种各样需要被处理的消息,而某个消息由于自身被写错了导致不能被正确处理,执行时会报错,可是队列的重试机制会重新把它放在队列头进行优先重试处理,但这个消息本身无论被执行多少次,都无法被正确处理,每次报错后又会被放到队列头进行重试,周而复始,最终导致线程一直处于忙碌状态,便发生了活锁问题。
饥饿
饥饿是指线程需要某些资源时始终得不到。在 Java 中有线程优先级的概念,Java 中优先级分为 1 到 10,1 最低,10 最高。如果我们把某个线程的优先级设置为 1,这是最低的优先级,在这种情况下,这个线程就有可能始终分配不到 CPU 资源,而导致长时间无法运行。或者是某个线程始终持有某个文件的锁,而其他线程想要修改文件就必须先获取锁,这样想要修改文件的线程就会陷入饥饿,长时间不能运行。
需要用到线程安全的场景
访问共享变量或资源
第一种场景是访问共享变量或共享资源的时候,典型的场景有访问共享对象的属性,访问 static 静态变量,访问共享的缓存,等等。因为这些信息不仅会被一个线程访问到,还有可能被多个线程同时访问,那么就有可能在并发读写的情况下发生线程安全问题。比如多线程同时 i++ 的例子:
/**
* 描述: 共享的变量或资源带来的线程安全问题
*/
public class ThreadNotSafe1 {
static int i;
public static void main(String[] args) throws InterruptedException {
Runnable r = new Runnable() {
@Override
public void run() {
for (int j = 0; j < 10000; j++) {
i++;
}
}
};
Thread thread1 = new Thread(r);
Thread thread2 = new Thread(r);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(i);
}
}
如代码所示,两个线程同时对 i 进行 i++ 操作,最后的输出可能是 15875 等小于20000的数,而不是我们期待的20000,这便是非常典型的共享变量带来的线程安全问题。
依赖时序的操作
第二个需要我们注意的场景是依赖时序的操作,如果我们操作的正确性是依赖时序的,而在多线程的情况下又不能保障执行的顺序和我们预想的一致,这个时候就会发生线程安全问题
if (map.containsKey(key)) {
map.remove(obj)
}
代码中首先检查 map 中有没有 key 对应的元素,如果有则继续执行 remove 操作。
此时,这个组合操作就是危险的,因为它是先检查后操作,而执行过程中可能会被打断。
如果此时有两个线程同时进入 if() 语句,然后它们都检查到存在 key 对应的元素,于是都希望执行下面的 remove 操作,随后一个线程率先把 obj 给删除了,而另外一个线程它刚已经检查过存在 key 对应的元素,if 条件成立,所以它也会继续执行删除 obj 的操作,但实际上,集合中的 obj 已经被前面的线程删除了,这种情况下就可能导致线程安全问题。
不同数据之间存在绑定关系
第三种需要我们注意的线程安全场景是不同数据之间存在相互绑定关系的情况。
有时候,我们的不同数据之间是成组出现的,存在着相互对应或绑定的关系,最典型的就是 IP 和端口号。有时候我们更换了 IP,往往需要同时更换端口号,如果没有把这两个操作绑定在一起,就有可能出现单独更换了 IP 或端口号的情况,而此时信息如果已经对外发布,信息获取方就有可能获取一个错误的 IP 与端口绑定情况,这时就发生了线程安全问题。在这种情况下,我们也同样需要保障操作的原子性。
对方没有声明自己是线程安全的
第四种值得注意的场景是在我们使用其他类时,如果对方没有声明自己是线程安全的,那么这种情况下对其他类进行多线程的并发操作,就有可能会发生线程安全问题。举个例子,比如说我们定义了 ArrayList,它本身并不是线程安全的,如果此时多个线程同时对 ArrayList 进行并发读/写,那么就有可能会产生线程安全问题,造成数据出错,而这个责任并不在 ArrayList,因为它本身并不是并发安全的,正如源码注释所写的:
Note that this implementation is not synchronized.
If multiple threads access an ArrayList instance concurrently,
and at least one of the threads modifies the list structurally, it must be synchronized externally.
这段话的意思是说,如果我们把 ArrayList 用在了多线程的场景,需要在外部手动用 synchronized 等方式保证并发安全。
所以 ArrayList 默认不适合并发读写,是我们错误地使用了它,导致了线程安全问题。所以,我们在使用其他类时如果会涉及并发场景,那么一定要首先确认清楚,对方是否支持并发操作。
总结
线程安全问题是多线程并发的重中之重,这里只是讲了几个有可能存在线程安全的例子,就此也打开了线程并发的学习。接下来将讲述一些关于Java中的一些锁机制和案例,敬请期待。