目录
2.4 解决线程不安全问题1 -- synchronized关键字
一.线程状态
- NEW: 当前Thread对象虽然有了,但是内核的线程还没有(还没调用start)。即安排工作了,还未开始行动
- RUNNABLE:就绪状态,正在cpu上运行 / 随时可以去cpu上运行。即可工作的,又可以分为正在工作和即将开始工作
- BLOCKED:因为 锁竞争,引起的阻塞,暂时不能参与cpu执行。几个都表示排队等着其他事情
- WAITING:没有超时间的阻塞等待 join的无参版本 / wait。几个都表示排队等着其他事情
- TIMED_WAITING:有超时间的阻塞等待 比如sleep / join的带参数版本。几个都表示排队等着其他事情
- TERMINATED:线程结束终止。即工作完成
上述线程状态,也可以通过jconsole来观察
学习线程状态,主要就是为了调试。比如,遇到某个代码功能没有执行,就可以观察对应线程的状态,看是否是因为一些原因阻塞了
二.线程安全
2.1 观察线程不安全
public class Conflict {
private static int count = 0;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
count++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
count++;
}
});
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(count);
}
}
运行结果:
预期结果:100000
很明显,这段代码出现了bug !!! 因为多个线程并发执行,引起的bug,称为“线程安全问题”或“线程不安全”
上面的 count++ 操作,其实在 cpu 视角看来,是3个指令:
- 把内存中的数据,读取到 cpu 的寄存器里(load)
- 把 cpu 寄存器里的数据 +1(add)
- 把寄存器里的值写回 cpu(save)
(这里的load add save指令 只是通俗的表达方式,由于不同架构的cpu有不同的指令集,不同的指令集里都有不同的指令,针对这三个操作,不同 cpu 里对应的指令名称肯定是不同的。 x86 的 cpu 和 arm 的 cpu 和 mips 的 cpu 和risc-V 的 cpu 都会有对应的操作,但是具体指令的名字会有差别.....)
cpu 调度执行线程的时候,是“随机调度,抢占式执行”的,说不上啥时候就会把线程给切换走。
指令 是 cpu 执行的 最基本单位,要调度执行,至少把当前指令执行完,不会执行一半就调度走。
但是由于count++是三个指令,可能会出现 cpu 执行了其中1个或2个或3个指令....调度走的情况(无法预测)
基于上面情况,两个线程同时对 count进行++,就容易出现bug
这个画法的含义,就是 t1 先执行load,add,save,然后 t2 执行load,add,save。按照这个执行顺序,输出的结果与串行执行没有区别,不会有bug。
但是,上述执行顺序,只是一种可能的调度顺序。由于调度过程是“随机”的,因此就会产生很多其他执行顺序。
上述过程中,明明是++了两次,但是最终结果还是1。这两次加的过程中,结果出现了“覆盖”。
由于循环5w 次的过程中,不知道有多少次执行的顺序是按照完整的 3 个++操作指令执行的,因此最终的结果 是不确定的值,而且这个值一定小于 10w。
但这个值是否一定大于5w? 答:不一定
如果是下面这种调度情况,完全可能出现 t1 ++ 一次的过程中 t2 ++ 两次。应该最终得到 3,实际上只得到 1。(这种情况比较少)
2.2 线程安全的概念
2.3 线程不安全的原因
a.线程调度是“随机”的
对于多线程代码来说,最大的困难,就在于“随机调度,抢占式执行”(多线程编码的“罪魁祸首,万恶之源”) 程序员必须保证 在任意执行顺序下,代码都能正常工作。
b.多个线程修改共享数据
如果只有一个线程,是没有问题的;如果多个线程 同时 修改同一个变量,此时就会涉及到线程安全问题。
上面线程不安全问题中,涉及到多个线程对 count 变量进行修改。此时,这个 count 是一个多个线程都能访问到的“共享数据”。
c.操作不是 “原子” 的
原子性:在计算机中,原子性指一个操作在执行过程中 不会被中断 的特性。即这个操作在执行完毕之前,不会被任何其他操作打断,其执行过程是连续不可分割的。在多线程环境中,原子性是保证数据一致性和避免竞态条件的重要概念。
以下是关于Java中原子性的几个要点:
1.基本类型变量的读取和写入
- 在Java中,对基本数据类型(如int,float,double等)的读取和写入操作是原子的,这意味着对于这些类型的单个变量,操作将不会被线程调度机制所中断。
2.复合操作的非原子性
- 尽管基本类型的操作是原子的,但对于复合操作(如自增 i++,自减 i--,if,while语句....)都不是原子的。
在 cpu 的视角,一条指令 就是 cpu 上的不可分割的最小单元。cpu 在进行调度切换的时候,势必会确保执行完一条完整的指令(取指令,解析指令,执行指令)。
d.可见性
可见性指,一个线程对共享变量值的修改,能够及时地被其他线程看到。
e.Java内存模型(JMM)
Java虚拟机规范中定义了Java内存模型。目的是 屏蔽掉各种 硬件 和 操作系统 的 内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果。
- 线程之间的共享变量存在“主内存”(Main Memory)
- 每一个线程都有自己的“工作内存”(Working Memory)
- 当线程要读取一个共享变量的时候,会先把变量从“主内存”拷贝到“工作内存”,再从工作内存读取数据。
- 当线程要修改一个共享变量的时候,也会先修改工作内存中的副本,再同步回主内存
由于每个线程有自己的工作内存,这些工作内存中的内容相当于同一个共享变量的“副本"。此时修改 线程1 的工作内存中的值,线程2 的工作内存不一定会及时变化。
此时引入了两个问题:
- 为啥要整这么多内存?
- 为啥要这么麻烦的拷来拷去?
1)为啥要整这么多“内存”?
- 实际上,并没有这么多“内存”,这只是 Java 规范中的一个术语,属于“抽象”的说法。所谓的“主内存”才是真正硬件角度的“内存”;而所谓的“工作内存”,则是指 cpu 的 寄存器 和 高速缓存
2)为啥要这么麻烦的拷来拷去?
- 因为 cpu 访问自身寄存器 的速度 以及 高速缓存 的速度,远远超过 访问内存 的速度(快了 3-4 个数量级,也就是几千倍,上万倍)
比如某个代码中要连续10次读取某个变量的值,如果 10 次都从内存中读,速度是很慢的。但是,如果只是第一次从内存中读,读到的结果缓存到 cpu 的某个寄存器中,那么后9次就不必直接访问内存了。
f.指令重排序
什么是代码重排序?
一段代码是这样的:
- 去前台取下U盘
- 去教室写10min作业
- 去前台取下快递
如果是在单线程的情况下,JVM、CPU指令集会对其进行优化,比如,按1->3->2的方式执行,也是没问题,可以少跑一次前台。这种叫做指令重排序。
编译器对于 指令重排序 的前提是“保持逻辑不发生变化"。这一点在单线程环境下比较容易判断,但是在多线程环境下就没那么容易了。多线程的代码执行复杂程度更高,编译器很难在编译阶段对代码的执行效果进行预测,因此激进的重排序很容易导致优化后的逻辑和之前不等价。
2.4 解决线程不安全问题1 -- synchronized关键字
a. synchronized的介绍
解决线程安全问题,最主要的办法就是把“非原子”代码变成“原子”代码 -->“加锁”
此处的加锁,并不是真的让 count++ 变为原子的,也没有干预到线程的调度,只不过是通过加锁的这种方式,使一个线程在执行 count++ 的时候,其他线程的 count++ 不能插队进来。
类似于上厕所锁门一样:
public class Lock1 {
private static int count = 0;
private static Object locker = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
//对count++操作加锁
synchronized(locker){
count++;
}
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
synchronized (locker){
count++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count="+count);
}
}
运行结果:
t1 和 t2 都对 locker对象 进行加锁。t1 先加锁,代码块中的内容处于 lock 状态,当 t2 想要执行加锁的代码块时,t1的 lock 产生阻塞,直到 t1 出代码块,状态变为 unlock,t2 才能 lock,并执行代码块。
synchronized是关键字,不是函数:
( ) 并非是参数,而是需要指定一个“锁对象”,通过“锁对象”来进行后续的判定(这里的“锁对象”可以指定任何对象)
{ } 里面的代码,就是要打包到一起的代码,可以放任何代码,包括调用别的方法啥的,只要使合法的Java代码都可以。进入代码块,就会进行加锁;出了代码块,就会进行解锁。
此处的 synchronized 是 JVM 提供的功能,synchronized 底层实现就是 JVM 中,通过C++来实现,即依靠 “操作系统” 提供的 API 实现加锁,操作系统 的 API 则是来自于 cpu 上支持的特殊指令来实现的。
系统原生加锁API实际上是两个函数:加锁是一个函数,解锁是一个函数。这种做法最大的问题,在于 unlock 可能执行不到。不仅仅原生API 是这样的,很多编程语言也是类似的封装模式(C++/Python)。而Java中的synchronized是进入代码块就加锁,出了代码块就解锁。因此,在加锁的代码块中无论 执行return 还是 抛出异常,出了代码块都会自动解锁——有效避免没有执行解锁的情况。像 Java 这种通过synchronized关键字来 同时完成 加锁 和 解锁,是比较少见的。
- 注:锁对象,是用来区分多个线程之间是否是针对 “同一个对象” 加锁。如果针对同一个对象加锁,此时就会出现 “阻塞”(锁竞争/锁冲突);如果不是针对“同一个对象”加锁,此时不会出现“阻塞”,两个线程仍然是随机调度的并发执行。 锁对象 填哪个对象不重要;重要的是,多个线程是否是 同一个 锁对象!!
本质上是把随机的并发执行过程,强制变成了串行,从而解决了刚才的线程安全问题。
注: 此处加锁后的代码,本质上比 join() 的 “串行执行” 效率高。join() 是“整体串行”,而加锁是代码“部分串行”。
加锁,就是变成 “串行执行” ,那是否就没必要使用多线程了?当然不是的!加锁,只是把线程中的一小部分逻辑,变成“串行执行”,剩下的其他部分,仍然是可以并发执行的!!比如,这里的 for循环 比较,i++操作都是可以并发执行的。只有锁里面的 count++ 是串行,外面的仍然并发执行。
刚才看到的情况,是两个线程针对一个锁加锁。如果是更多线程呢?
如果是 3 个线程对同一个对象加锁,其中某个线程先加上锁,另外两个线程 阻塞等待(但是哪个线程会先拿到锁,是不可预期的...)
b. synchronized的使用
1)修饰代码块:明确指定锁哪个对象
锁任意对象:
public class Lock2 {
private Object locker = new Object();
private void method(){
synchronized (locker){
}
}
}
锁当前对象:
public class Lock3 {
public void method(){
synchronized (this){
}
}
}
2)直接修饰普通方法:锁的 当前类 this对象
public class Lock4 {
public synchronized void method(){
}
}
3)修饰静态方法:锁的 当前类对象,与this无关
public class Lock5 {
public synchronized static void method(){
}
}
总结:
- 理解锁对象的作用:可以把任意的Object/Object子类的对象 作为锁对象,但对象是啥不重要,重要的是,两个线程的锁对象 是否是同一个 —— 是同一个,才会出现 阻塞/锁竞争;不是同一个,不会出现 阻塞/锁竞争 的。
- synchronized几种使用方式:1)synchronized 修饰代码块,圆括号指定锁对象 2)synchronized 修饰一个普通方法,相当于针对 this 加锁 3)synchronized 修饰一个静态的方法,相当于对 对应的类对象 加锁