当前位置: 首页 > news >正文

多线程(四) --- 线程安全问题

目录

         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.小结

              以上,就是我对线程安全问题相关知识的介绍。这部分知识还是比较难的,大家学到这里一定要弄清每一个小细节,清楚每个概念的意思。下期博客我会接着介绍线程安全遗留的一些问题,包括什么是死锁,指令重排序等等,大家可以期待一下。

http://www.lryc.cn/news/615444.html

相关文章:

  • 使用 Ansys Discovery 进行动态设计和分析
  • js零基础入门
  • HashTable, HashMap, ConcurrentHashMap
  • Java 8 特性
  • 力扣(删除有序数组中的重复项I/II)
  • 20250808组题总结
  • 力扣 hot100 Day70
  • 力扣-35.搜索插入位置
  • SwiftUI 登录页面键盘约束冲突与卡顿优化全攻略
  • AI推理的“灵魂五问”:直面2025算力鸿沟与中国的破局之路
  • Java基础语法全面解析:从入门到掌握
  • MySQL 复制表详细说明
  • 三极管在电路中的应用
  • SpringSecurity过滤器链全解析
  • 工具箱许愿墙项目发布
  • Redis 事务机制
  • Mysql笔记-系统变量\用户变量管理
  • 机器学习 K-Means聚类 无监督学习
  • 数据结构初阶(7)树 二叉树
  • BGP笔记
  • 机器学习DBSCAN密度聚类
  • 讯飞晓医-讯飞医疗推出的个人AI健康助手
  • 复杂环境下车牌识别准确率↑29%:陌讯动态特征融合算法实战解析
  • 编译技术的两条演化支线:从前端 UI 框架到底层编译器的智能测试
  • Office安装使用?借助Ohook开源工具?【图文详解】微软Office产品
  • 每周算法思考:栈与队列
  • 【数据结构入门】栈和队列
  • 物理AI与人形机器人:从实验室到产业化的关键跨越
  • day15_keep going on
  • [激光原理与应用-202]:光学器件 - 增益晶体 - Nd:YVO₄增益晶体的制造过程与使用过程