多线程(四) --- 线程安全问题
目录
1.一个线程不安全的代码的典型例子
2.引发线程不安全的原因
2.1 根本原因
2.2 代码结构
2.3 直接原因
2.4 其他原因
3.如何解决线程安全问题
3.1 锁的概念以及如何加锁
3.2 加锁的核心规则
3.3 详解加锁的流程
3.4 加锁操作的一些混淆理解
4.小结
今天,我们来谈谈线程安全的相关问题。相信初学者也是第一次听说这个词,那么,到底什么是线程安全呢?最简单的解释就是,如果某个代码,无论是在单个线程下执行,还是多个线程下执行,都不会产生bug,那么这个情况就称为“线程安全”。相反,如果这个代码在单线程下运行正确,但是在多线程下就可能会产生bug,这个情况就称为“线程不安全”或者“存在线程安全问题”。接下来,我会通过一个案例来解释线程安全,以及为什么会有线程安全问题,如何解决线程安全问题等方面来详细讲解线程安全。
1.一个线程不安全的代码的典型例子
我们先来看一段代码:
public class Test {private static int count = 0;public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {for (int i = 0; i < 10000; i++) {count++;}});Thread t2 = new Thread(() -> {for (int i = 0; i < 10000; i++) {count++;}});t1.start();t2.start();t1.join();t2.join();System.out.println(count);} }
可以看到,这段代码写的是,对于一个变量count,它的初始值为0,现在有线程t1和t2,这两个线程分别让count自增10000次。
正常来说,count经过20000次的自增,结果应该是20000。但让我们来看运行结果,结果却出乎我们意料:
可以看到,结果并不是我们推测的20000。而且,随着我们多次运行,结果也是各不相同。这是为什么呢?
count ++这个操作,相当于是count += 1。这个count ++其实是三个CPU指令构成的:
1)load:从内存中读取数据到CPU的寄存器。
2)add:把寄存器中的值加1。
3)save:把寄存器的值写回到内存中。
理解硬件(CPU)的工作过程,对于理解很多软件上的设定是非常有帮助的。
如果是一个线程执行上述的三个指令,当然没有问题。但如果是两个线程,并发的执行上述操作,此时就会存在变数!(线程之间调度的顺序是不确定的!)
我们来看以下可能出现的情况:
情况一:
情况二:
情况三:
情况四:
情况五:
情况六:
情况七:
情况八:
其实这里理应是无数种情况!因为完全有可能在t2执行一次++的时候,t1执行两次++;在t2执行一次++的时候,t1执行三次++,乃至N次++,再算上排列组合,又有很多种。而线程安全就是要保证,在无数种情况下,代码都得是正确的。
这里需要分析清楚,什么样的执行顺序下执行结果是对的,什么顺序下结果不对。
我们具体来分析情况一:
如图所示,首先,t1先将内存中count的值(0)加载到自己的寄存器上,然后再进行自增,将count的值变为1,最后将寄存器中count的值(1)重新写回到内存中去;接着,t2如法炮制,让内存中count的值变为2。所以说,上述执行顺序,运算得到的结果是对的。
注意:由于这两个线程是并行执行还是并发执行,我们是不知道的。但是即使是并发执行,再一个CPU核心上,两个线程有各自的上下文(各自一套寄存器的值,不会相互影响)。情况二与情况一同理,这里不再赘述。
我们再来分析一下情况七:
如图所示,首先,t1将内存中count的值(0)加载到自己的寄存器中,t2也将内存中count的值(0)加载到自己的寄存器中;接着t2进行add操作,将自己寄存器中count的值变为1,然后将寄存器中count的值(1)写回到内存中去;最后,t1在自己的寄存器中进行add操作,将count的值也变为1,同样重新写回内存中去(此时内存中count的值依旧为1)。
明明是两个线程,各自自增一次,最终的结果仍然是1。可见,这里存在bug!
其实最关键的问题在于,得确保第一个线程save了之后,第二个线程再load,这个时候第二个线程load到的才是第一个线程自增后的结果。否则的话,第二个线程load到的就是第一个线程自增前的结果了。
2.引发线程不安全的原因
2.1 根本原因
说到底,引发线程不安全的根本原因在于,操作系统上的线程是“抢占式执行”且“随机调度”的(罪魁祸首/万恶之源)。这就给线程之间的执行顺序带来了很多变数。
所谓的“抢占式执行”,就是一种调度方式。核心特点是操作系统可以主动中断正在运行的进程,将 CPU 资源分配给其他优先级更高或更需要运行的线程。这种 “抢占” 行为由操作系统的内核控制,无需依赖当前线程的主动配合。
“随机调度”指的是,线程的调度是没有规律的的,并不知道谁先去CPU上执行,谁后去CPU上执行。
2.2 代码结构
代码的结构,也是造成线程不安全的一环。
当代码中的多个线程同时修改同一个变量时,就容易造成线程不安全。但如果只是读取变量的内容(固定不变的),就不会有线程安全问题。这也就意味着:
1)一个线程修改一个变量,没有问题。
2)多个线程读取同一个变量,没有问题。
3)多个线程修改不同变量,没有问题。
其实这个原因还不够严谨,后面还会看到一个线程修改,一个线程读,也可能存在问题。
2.3 直接原因
直接原因为,上述多线程修改操作,本身不是“原子的”。
这里我来解释一下什么是“原子的”:
所谓“原子的”,就是分的不能再分。一个 “原子的” 操作在执行过程中,要么完全完成,要么完全不执行,不会出现 “部分完成” 的中间状态,且在执行期间不会被其他操作干扰。
对于count ++这个操作,它是由多个CPU指令(load,add,save)构成的。一个线程执行这些指令,执行到一半,就可能会被调度走,从而给其他线程“可乘之机”。
所以,为了保证线程安全,得确保每个操作都是“原子的”,要么不执行,要么执行完。
2.4 其他原因
此外,造成线程不安全的原因还有:
1)内存可见性问题。
2)指令重排序问题。
这两个情况也可能会导致线程不安全,但是当前写的代码不涉及,我们留到后面详细解释。
3.如何解决线程安全问题
知道了原因,就可以“对症下药”了。针对原因2.1,我们无法做出任何改变。因为系统内部已经实现了抢占式执行,我们干预不了。针对原因2.2,我们要分情况。有的时候,代码结构可以调整,有的时候调整不了。针对原因2.3,乍看起来,count ++生成的几个指令,我们也无从干预。但是,实际上是有办法的。可以通过特殊手段,把这三个指令打包到一起,成为一个“整体”。这便是我接下来要详细介绍的 --- 加锁。
3.1 锁的概念以及如何加锁
在计算机科学和编程中,锁(Lock) 是一种同步机制,用于控制多个线程或进程对共享资源(如变量、文件、数据库记录等)的访问,防止因并发操作导致的数据不一致或冲突。
锁具有“互斥”、“排他”这样的特性。
举个栗子🌰:
假设有一群滑稽老铁排队上厕所。如果这时一个滑稽老铁进了厕所,那么必定要把门给反锁上。这样一来,其他的滑稽老铁就无法进入了,这便是“排他”。
而加锁的目的,就是为了把上述三个操作,打包成一个原子的操作。
在Java中,加锁的方式有很多种。其中最主要的方式,就是使用synchronized关键字。
3.2 加锁的核心规则
可以看到,单有一个synchronized关键字在这里,是不够的。进行加锁的时候,需要先准备好“锁对象”。加锁解锁操作,都是依托于这里的“锁对象”来展开的。在Java中,任何一个对象都可以作为锁对象。
其中加锁的最最核心的规则,就是如果一个线程针对一个对象加上锁之后,其他线程也尝试对这个对象加锁,就会产生阻塞(BLOCKED)。而且,会一直阻塞到前一个线程释放锁为止。以上,也被称之为“锁竞争/锁冲突”。
为了让大家更好地理解锁冲突,我在这里举个栗子🌰:
假设博主追一个喜欢了很多年的女孩,但是这个女孩已经有男朋友了。这里就把女孩理解成是一个锁对象。
也就是说,另外有一个线程(女孩现在的男朋友),已经对女孩加锁了。此时,我要是也想对这个女孩进行加锁,就可能会产生两种情况:
1)阻塞等待(甘愿当备胎),未来的某一天,女孩分手了(锁被释放了),博主就有机会能够加上锁。
2)直接放弃。
Java中的synchronized的做法,就是“甘愿当备胎”。
当博主放弃了之后,我选择去和其他女孩交往。以前的这个女孩被加锁了,影响我去追其他的女孩吗?显然是不影响的。以前的女孩是一个锁对象,而新的女孩是另一个锁对象。
3.3 详解加锁的流程
首先,随便创建一个对象。当代码走到{ }时,就会加锁(lock);出了{ }就会解锁(unlock)。synchronized是调用系统的API进行加锁,而系统API本质是靠CPU上的特定指令完成加锁的。
接着我们来看加锁之后,count ++所包含的三步操作操作发生了哪些变化:
如图所示,t1先执行lock,加锁可以成功。t2后执行lock的时候,由于锁已经被t1给加上了,t2就会阻塞等待。当阻塞到t1线程unlock了之后,t2才能获取到锁。
阻塞过程中,t2暂时无法去CPU上执行。所以接下来t2的三步操作,是无法“插队”的!这样就能确保,t2的load能够在t1的save之后执行,那么结果自然就是正确的。
我们再来看下面这种情况:
如果是针对不同对象加锁,那么上述“锁竞争”就不会发生。没有了锁竞争,得到的结果依旧是错误的。
-------------------------------------------------------------------------------------------------------------------------
那么我来小结一下:
1)如果一个线程加锁,一个线程不加锁,就不会出现锁竞争了,线程安全问题仍然存在。
2)如果两个线程,针对不同对象加锁,也会存在线程安全问题。
3.4 加锁操作的一些混淆理解
如果把count放到一个 Test t 对象中去,再通过下面add方法来进行修改,那么加锁的时候锁对象可以写作this。
这里需要理解好,( )里的是不是同一个对象。
此时线程t1的this是指向t的。
此时线程t2的this也是指向t的。
它们指向同一个对象,所以仍然会存在锁竞争!
-------------------------------------------------------------------------------------------------------------------------
synchronized(this),也可以等价写作,把synchronized加到方法上。
如果synchronized是加到static方法上,就等价于给类对象加锁
所谓的类对象,就是一种特定的数据结构,用来表示加载好的数据。我们都知道,一段Java代码,在执行时会先生成一个.java文件,接着编译成一个.class文件(字节码文件)。JVM执行.class的时候,就要先把这个.class文件读取到内存中,然后才能执行(类加载)。在把.class文件读取到内存中这一步,就需要类对象来表示加载好的这些数据。
通过“类名.class”这种方式,就会得到这个类的类对象。而且,每个类有且只有一个类对象。
类对象里包含了这个类的各种信息:类的名字是什么,有哪些属性;每个属性叫什么名字,是什么类型;有什么方法,每个方法叫什么名字,有什么参数,参数是什么类型,有什么注解;继承自哪个类,实现了哪些接口。
最重要的是,类对象,是反射机制的依据。
4.小结
以上,就是我对线程安全问题相关知识的介绍。这部分知识还是比较难的,大家学到这里一定要弄清每一个小细节,清楚每个概念的意思。下期博客我会接着介绍线程安全遗留的一些问题,包括什么是死锁,指令重排序等等,大家可以期待一下。