目录
1. 什么是线程安全问题
(1) 观察线程不安全
(2) 线程安全的概念
2. 造成线程安全的原因
(1)线程调度的随机性
问题描述
解决方案
(2)修改共享数据&原子性问题
问题描述
解决方案
3.synchronized 关键字
1. synchronized 的特性
(1) 互斥
(2) 可重入
2. synchronized 的使用案例
(1)修饰代码块(明确指定锁哪个对象)
锁任意对象
锁当前对象
(2)直接修饰普通方法:锁的 SynchronizedDemo 对象
(3)修饰静态方法:锁的 SynchronizedDemo类 的对象
4.Java 标准库中的线程安全类
1. 什么是线程安全问题
(1) 观察线程不安全
我们来看如下代码:
代码逻辑:一个线程自增 5w 次,两个线程,总共自增 10w 次,预期结果:count=100000
预期结果与实际结果不符合的原因:t1,t2 和 主线程是并发执行的,调度是随机的。
实际结果 count=0 ,说明主线程是先打印了 count,t1,t2 才对 count 进行调整的 ;
为了让主线程等待 count 调整完毕再打印,我们在主线程中,通过 t1,t2 这两个对象的引用调用 join():
此时,主线程 会在 t1,t2线程创建好后,进入阻塞状态,直到 t1,t2线程全部执行完毕,主线程才可以继续执行。多次运行程序,发现实际结果 与预期结果 又出现不同:
此时,我们修改调用 start() 和调用 join() 的顺序:
上述例子,说明了当前 bug (两个线程同时调整 count,预期结果与实际结果不相同),是由于多线程的并发执行所引起的;
一段代码,在多线程中,如果并发执行产生bug了,那就称为 “线程安全问题” 或者叫做 “线程不安全”;
反之,如果一个代码,在多线程并发执行的环境下,也不会出现类似于上述的bug,这样的代码就叫“线程安全”。
(2) 线程安全的概念
想给出一个线程安全的确切定义是复杂的,但我们可以这样认为:
如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的。
2. 造成线程安全的原因
本文只讨论前三种产生线程安全问题的原因。
(1)线程调度的随机性
问题描述
操作系统对于线程的调度是随机的(抢占式执行)
操作系统对于线程调度是随机的,这是线程安全问题的罪魁祸首;
随机调度使一个程序,在多线程环境下,执行顺序存在很多的变数,因为这些变数,使得原来串行执行的代码,容易出现问题;因此,程序猿必须保证在任意执行顺序下,代码都能正常工作。
解决方案
操作系统对于线程的调度是随机的 (抢占式执行),这是操作系统的底层设定,我们无法左右该设定,所以要解决线程安全问题,我们应该从 产生线程安全问题的 第二,第三个原因 找突破口。
(2)修改共享数据&原子性问题
问题描述
多个线程修改共享数据,并且修改操作不是原子性的
对于多个线程修改共享数据,如当前的这个代码中,两个线程共同执行一个操作,那就是count++;
count++ 看起来是一行代码,但实际上它是对应到的,是三个CPU指令(修改操作不是原子性的);
并且是多个线程,并发执行这三个CPU指令;
换言之,CPU 执行这三条指令的过程中,随时可能触发线程的调度切换。
在 t1,t2 两个线程真正去进行 count++ 操作的时候,两个线程在 CPU 上执行,可能是并发,也可能是并行:
所以多个线程,如果按照并发执行,就类似多个人玩同一台电脑上的MC(多个线程在同一个CPU上运行),每个人创建属于自己的世界种子(每个线程有不同上下文),每个世界种子的存档互不影响(不同线程的上下文彼此互不影响);
既然两个线程有两个不同的上下文,也就是有两组不同的寄存器的值,我们就画两个CPU 寄存器来演示,两个线程并发执行 三步 CPU指令 的过程:
对于这两个线程,并发执行这三个CPU指令,会出现的情况远远不止上图的两种情况;我们发现,在 t1线程执行 save指令 之前,如果 t2线程 执行了 load指令,那么就会出现值覆盖的问题。
对于上述代码,如果出现极端情况,甚至最后 count 的结果会小于 5w:
而两个线程循环 5w 次,是为了让执行时间变长,更直观的查看线程安全问题。
总结:
- 当多个线程去进行并发这个逻辑的时候,产生中间结果会相互干扰
- 一个线程可能加载了一个数据,它并非是另一个线程计算好的结果
- 所以这就导致这两次结果,产生了相互覆盖的情况,所以这样的情况也就是造成线程安全问题的主要原因。
解决方案
操作系统随机调度是操作系统带来的解决不了;
多个线程对一个变量修改,有些可以规避,但有些根据需求无法规避。
所以,我们可以将 非原子性操作 改为 原子性操作,可以通过 synchronized 关键字加锁操作来实现。
锁属于系统提供的一个专门的机制,它能够产生互斥的效果。通过互斥效果,把本来是一个无序的并发执行,变成一个局部上的串行执行,从而进一步解决线程安全问题。
3.synchronized 关键字
1. synchronized 的特性
(1) 互斥
互斥,又称为“锁冲突”或者“锁竞争”。
加锁本质上,就是把并发执行逻辑,给强行调整顺序,变成前后顺序的串行执行。
synchronized 的互斥特性,对强行修改逻辑执行顺序的操作,起到至关重要的作用。
通过 synchronized 的互斥效果,某个线程执行到某个对象的synchronized中时,其他线程如果也执行到同一个对象synchronized 就会阻塞等待.
理解"阻塞等待":
针对每一把锁,操作系统内部都维护了一个等待队列。
当这个锁被某个线程占有的时候,其他线程尝试进行加锁,就加不上了,就会阻塞等待,一直等到之前的线程解锁之后,由操作系统唤醒一个新的线程,再来获取到这个锁。
注意:
- 上一个线程解锁之后,下一个线程并不是立即就能获取到锁;而是要靠操作系统来"唤醒",这也就是操作系统线程调度的一部分工作。
- 假设有ABC三个线程,线程A先获取到锁,然后B尝试获取锁,然后C再尝试获取锁,此时B和C都在阻塞队列中排队等待;
- 但是当A释放锁之后,虽然B比C先来的,但是B不一定就能获取到锁,而是和 C重新竞争,并不遵守先来后到的规则。
synchronized 用的锁是存在Java对象里的 ,而在 Java 中,任意一个对象,都可以作用于锁;
并且,锁对象的类型并不重要,重要的是,是否有多个线程尝试针对同一个对象加锁;
所以我们通常都会定义 Object 类的锁对象:
- 两个线程,针对同一个对象加锁,才会产生互斥效果;
- 一个线程加上锁了,另一个线程就得阻塞等待,等到第一个线程释放锁,另一个线程才能枷锁;
- 如果是不同的锁对象,不会产生互斥效果,线程安全问题,就不能得到解决,比如:
- 两个线程针对两个不同锁对象加锁,此时这个锁等于没加,两个线程的锁并不会出现互斥的效果;
- 或者一个线程加锁,一个线程不加锁,那这个加锁操作也是形同虚设的,这个锁不是写了synchronized,就好使了,而是多个线程针对一个锁对象加锁,才能真正产生配合
(2) 可重入
Java 中的 synchronized 是可重入锁;
在可重入锁的内部,包含了"线程持有者"和"计数器"两个信息。
synchronized是可重入的,每获取一次锁,计数器+1,释放锁时,计数器-1,直到计数器为0,锁才会真正释放。
如果某个线程加锁的时候,发现锁已经被人占用,但是恰好占用的正是自己,那么仍然可以继续获取到锁,并让计数器自增。
解锁的时候计数器递减为0的时候,才真正释放锁。(才能被别的线程获取到)
- 进入synchronized 修饰的代码块(进“ { ”),相当于 加锁;
- 退出synchronized 修饰的代码块(出“ } ”),相当于 解锁。
这样的设定最大的好处是,避免单独写出的unlock,可能会执行不到,或者避免加锁后忘记释放锁的情况;
此时哪怕因为return,或者抛异常,结束的代码块的“ } ”,都能确保 synchronized 这个锁,被及时释放掉。
2. synchronized 的使用案例
(1)修饰代码块(明确指定锁哪个对象)
锁任意对象
锁当前对象
哪个对象调用了 method(),this 就表示哪个对象。
(2)直接修饰普通方法:锁的 SynchronizedDemo 对象
(3)修饰静态方法:锁的 SynchronizedDemo类 的对象
synchronized 修饰 static 方法,相当于针对这个方法当前所在的类进行加锁
4.Java 标准库中的线程安全类
Java 标准库中很多都是线程不安全的。
这些类可能会涉及到,多线程修改共享数据,却没有任何加锁措施:
但是还有一些是线程安全的.使用了一些锁机制来控制
拓展:
- String类 中没有提供 public 修饰的方法,从而实现了“不可变”的特性,确保了线程安全;而 String类中的 final 用来实现 “不可继承” 的特性 。
- String只是针对 对象进行读取操作,不涉及到线程安全问题,所以换句话说,String虽然没有加synchronized,但是天然就是线程安全的;
因此,我们可以在多线程场景下,放心使用String类,这也是 String类 设计成不可修改的重要原因。