线程安全:线程安全问题的发现与解决-CSDN博客
Java中所使用的并发机制依赖于JVM的实现和CPU的指令。 所以了解并掌握深入Java并发编程基础的前提知识是熟悉JVM的实现了解CPU的指令。
1.volatile简介
在多线程并发编程中,有两个重要的关键字:synchronized和volatile,译为
volatile是轻量级的synchronized,它在多线程开发中确保了共享内存变量的"可见性"。
什么叫做可见性?简要的概述其实很简单:
当一个线程修改一个共享变量的话,另一个线程能知道并读到这个修改后的值。
如果volatile变量修饰符使用得当的话,会比synchronized的使用和执行成本更低,因为它不会引起
线程的上下文切换和调度。
1.1volatile的定义与使用
volatile的定义:Java语言允许线程共享变量,为了确保共享变量能被准确和一致的更新,线程应该确保通过排他锁单独获得这个变量。Java语言提供了volatile,在某些情况下比锁更加方便。如果一个字段被声明成volatile,Java线程内存模型确保所有线程看到这个变量的值是一致的。
上面我们提到了,可见性,volatile,synchronized还有排他锁这几个新鲜的概念,我们来逐个讨论一下,等到了最后volatile关键字也就理解的差不多了。
1.内存可见性
谈到可见性,一般都是内存可见性,内存可见性的问题是由于编译器优化导致的,
正如我们开头展示的笔记那样,一个Java文件想要被cpu所执行,需要经历重重编译,转化等等
而在程序员这个圈子里,水平各个参差不齐,总的来说还是菜鸟更多,大佬更少,怎么样在这种情况让菜鸟也能写出来优秀的代码呢?大佬们就在编译器上动了手脚,加入了优化机制这样一来,即使初学者写出的代码不够高效,编译器也能在背后“兜底”,生成更高性能的执行代码,从而实现“写出来的代码比人本身更聪明”的目标。编译器编译的时候自动分析代码的逻辑,在保持代码逻辑不变的前提下,自动修改代码的内容,从而让代码变得更高效。
在这个案例中,我们希望通过线程2来控制线程1的循环条件,从而控制线程1的结束。
从线程1的循环中,我们可以看到,每次循环的条件,再假设线程2不能控制的前提下,都是为真的,编译器就发现了
1. 这里的isRunning每次读到的都是相同的值,仅仅1s足够让循环执行上万次,重复无效的代码了
2. 编译器查看循环条件和循环体并没有发现需要修改的地方
对于编译器而言,它无法静态分析出这个修改何时发生、是否会发生,甚至是否发生在同一个内存空间(因为线程间的可见性并不总是成立)
并且,这段代码的执行,依靠了,读内存操作,比较和跳转操作
通过读内存操作从内存中读取isRunning的值到CPU寄存器,通过比较寄存器存放的值和true是否相同,如果相同就继续执行否则使用跳转语句到指定的位置。
通过优化后变为
-
从内存读取变量值:通过“load”操作,将共享变量
isRunning
的值从主内存读取到 CPU 寄存器 或说是线程工作内存中。 -
寄存器中进行比较:循环条件判断时,CPU 不会每次都访问主内存,而是直接比较寄存器中或者说是工作内存中的值是否为
true
。 -
分支跳转指令执行控制流:
-
如果等于
true
,程序继续执行循环体; -
如果等于
false
,程序跳转到循环之后的位置,退出循环
-
也就是说,编译器此处做了个大胆的决定,把访问内存这步操作在第一次访问后给优化掉了,后续的循环只需要从CPU寄存器或者缓存中读取值即可!
此时如果t2线程即使修改了isRunning的值,t1线程也无法感知到了,t1已经被优化了并没有从内存中读取而是从(寄存器/缓存)工作内存中读取了!
对于多线程中的内存可见性问题,其中一个关键原因就是编译器为了优化性能而对代码进行了重排和缓存。
比如,在一个循环中重复读取某个变量的值,编译器会认为:
“这个变量的值在循环体内没有被修改,而且看上去始终相同,那我就没必要每次都从内存中去读了,直接缓存到寄存器里用就行。
小问题:如果我们此时将While循环中的空代码块加入Thread.sleep(1)后发现
线程1居然神奇的受到了线程2输入的非零数字的影响结束了循环。
难道说Thread.sleep()也能解决内存可见性的问题吗?
我们知道,内存可见性的问题本质上是编译器优化所带来的,但是引入sleep后这个代码中的 ,从内存读取的操作并没有被编译器优化掉
代码的指令大致有
1.从内存中读取数据load
2.cmp通过比较来判断循环条件是否为真
3.sleep方法(背后是很多多的指令)
哪怕是sleep(0)在这里也不会被优化掉
我们在循环体中做各种复杂的操作,都会引起上述的优化失效!
综上内存可见性的问题,我们已经了解的差不多了,可以谈一下volatile关键字了,
如果我们在代码的isRunning变量加上了volatile关键字,就可以解决上述的问题!
有没有觉得很神奇,仅仅只是加了个关键字就解决了,我们谈论那么长时间的内存可见性问题?
总结成一句话来说:
volatile 保证可见性,靠的是底层 JIT 编译器在写操作中生成带
lock
前缀的汇编指令,这个指令通过缓存一致性协议,确保变量修改对所有 CPU 可见。
2.synchronized简介
请注意volatile关键字只能解决内存可见性的问题,对于,多个进程访问修改同一个变量,而造成的线程安全问题是无能为力的只能依靠synchronized
2.1synchronized的定义和使用
在多线程并发编程中,synchronized真是一位远古大能级别的角色,很多人会称呼他为重量级锁,
但是随着JavaSE的各种优化,有些情况下,他就不是那么重了。
synchronized实现同步的基础:Java中每一个对象都可以作为锁。具体表现为以下三种形式:
1.对于普通同步方法,锁是当前的实例对象
2.对于静态同步方法,锁是当前类的Class对象
3.对于同步方法块,锁是synchronized括号中配置的对象
当一个线程试图访问同步代码块时,他首先必须要先得到锁,退出或者抛出异常时必须释放锁。
synchronized(obj){...}中的obj就是在同步代码块中用来加锁的那种对象,JVM会对这个obj对象的监视器(monitor)进行加锁和解锁,从而实现线程之间的互斥。
注意:此处加锁并不是禁止线程调度,而是防止其他线程插队。
该锁块中一共有大概
count++ == >(count = count + 1)
load(从内存读取变量
count
的当前值)add(对值进行+1的操作)
save(将新值写回内存)
三个指令操作,执行上述这些操作指令的时候,是随时会被其他线程插队从cpu上调度走的,如果加了锁就保证了操作的原子性,
因为此时如果其他线程尝试加锁操作,就会产生阻塞,从而避免执行上述指令时被插队的问题。
(使用lock和unlock来代替synchronized的{ 和} )
synchronized的要点
1.进入 { 就是加锁,离开 } 就是解锁
2.加锁操作是为了防止其他线程在本线程执行中插队,而不影响本线程调度
3.锁对象,两个或者多个线程针对同一个对象加锁才会有锁竞争,锁才会生效
对于下面的代码是否存在线程安全问题?
对于两个线程一个加锁,一个没有加锁是会产生线程安全的问题的,
因为在一把锁生效时,原子操作仍然会被打断,另一个线程并没有因为锁而受到限制
对于下面两种加锁的方式,就涉及到锁的粒度
t1线程:
对整个循环操做加锁,锁的粒度大,锁内部代码逻辑复杂
t2线程
每一次循环操作都会加锁,加100次锁,锁的粒度小,锁内部代码逻辑少
由于synchronized的设计
在synchronized(){
}代码块中,
Java 中的
synchronized
关键字由 JVM 保证:无论同步代码块中是正常执行、return
提前返回,还是抛出异常(throw
)提前终止,都会自动执行解锁(unlock)操作。
这一点是很多高级语言设计lock和unlock操作的不足之处
1.可重入锁
对于下面的代码,是否可以正常运行呢?
假设说不存在可重入锁的概念,我们来分析
当线程2进入第一层锁,此时已经加锁成功,如果此时再对同一个对象加第二次锁就会产生死锁,因为第一次加锁的解锁操作需要等到第二次加锁并解决成功,而第二次的加锁操作又得等第一次解锁,就死锁了。
但是Java中存在可重入锁的概念,十分简单:
Java 中的 synchronized
是 可重入锁(Reentrant Lock),其工作机制:
-
每个锁记录:
-
当前持有锁的线程ID
-
当前线程对这把锁的重入次数(计数器)
-
于是:
-
线程 T1 首次获得锁
obj
,线程ID 被记录,重入次数为 1。 -
T1 再次进入
synchronized(obj)
,JVM 检查:锁的持有者仍是 T1,本线程重入,于是允许继续进入,同时 重入计数 +1。 -
等两个
synchronized
代码块都执行完后,T1 每退出一层,重入计数 -1,直到为 0 时,才真正释放锁。
避免了“自己锁死自己”的问题,确保线程可以多次、安全地进入同一把锁控制的临界区。
那么锁到底存在哪里呢?锁里面会存储什么信息呢?
这些就涉及到深入的理解了
synchronized的实现原理与应用&Java对象的内存布局_java synchronized原理java对象内存布局-CSDN博客
3.wait和notify简介
3.1wait的定义和使用
首先需要清楚的是,wait和notify并不是Thread包括任何线程相关类的方法,而是Object基类的方法
在多线程的世界中,线程的调度是随机的,虽然join方法可以简单的控制线程的结束时间,
Thread.join()
是一个同步等待方法,可以让主线程等待子线程执行完毕之后再继续执行。
在main线程中调用t1,join()和t2.join(),main线程会等待t1和t2执行完毕,main才会执行完毕,而且t1和t2的执行完毕顺序也不确定
学习过操作系统课程的一定见过一个很经典的操作,叫PV操作,里面的代码都是手写的,需要我们来分析,等到线程1完成了什么什么条件或者任务就会唤醒线程2的操作等等,但是PV操作和我们的wait和notify操作有着本质的区别
1.PV操作是操作系统底层的操作叫原语,基于“信号量(Semaphore)”,通过计数控制资源访问
2.wait和notify方法是Java语言层面,基于“对象监视器(Monitor)”,通过条件变量进行线程协作
我们学到这里可以把PV操作暂时先忘掉了,虽然二者的很多用法相同,但是为了避免混淆还是不提及
程序中存在t1线程,t2线程
要求t1先执行某个逻辑A 然后t2再执行某个逻辑B
就比如我们生活的例子,只有A球员把球传给B球员,B球员才能完成扣篮的操作
虽然wait方法任何对象都可以直接调用,如果我们直接调用的话会抛出以下异常:
1.在使用前wait也和sleep方法一样需要抛出InterruptedException异常
2.运行后发现抛出了java.lang.IllegalMonitorStateException异常
翻译一下就是非法的监视器状态异常也就是说
你现在没有处于这个对象的监视器锁内部状态,却调用了必须在其中调用的方法。
JVM内部实现synchronized时,使用了形如monitor属性作为变量/方法名,也被称为“监视器锁
wait()
必须在 synchronized(obj)
中使用,否则 JVM 会抛出 IllegalMonitorStateException
,因为你没有持有该对象的 monitor 锁。
就像你去面试一样,你都没有准备去面试呢,就在想以后薪资会给你开多少。
使用wait的时候如果没有被notify就会一直阻塞
在synchronized代码块中一共有三个动作:
1.释放掉当前锁
2.等待其他线程通知,此时处于阻塞状态
3.当通知到达后,从阻塞状态到就绪状态,并重新尝试获取到锁
假如wait一直占着锁,别的线程会一直等待锁,造成死锁
wait如果是无参版本的话,属于是死等,而wait也存在有参数的版本,同sleep一样,等待一定的时间就不会等待了,
同时notify也有一个notifyAll的版本会唤醒所有线程,而notify只是随机唤醒,上述例子中只有两个线程,所以一个wait一个唤醒,如果多个线程就不一定的。
sleep和wait的区别
sleep()
是让线程“暂停”一会儿,wait()
是让线程“等待”别人通知它继续。
对比点 | wait() | sleep() |
---|---|---|
1. 设计目的 | 主要用于线程间通信与协作,通常与 notify() / notifyAll() 搭配使用。 | 主要用于让线程休眠一段时间,是一种简单的阻塞延迟机制。 |
2. 是否释放锁 | 释放锁。wait() 会释放当前对象的监视器锁(monitor)。 | 不释放锁。线程进入休眠时仍然持有锁。 |
3. 是否需要在同步块中调用 | 必须在 synchronized 块中使用,否则抛出 IllegalMonitorStateException 。 | 不需要,可以在任何地方调用。 |
4. 是否可以被唤醒 | 可以被 notify() / notifyAll() 唤醒,也可以被 interrupt() 打断。 | 只能被 interrupt() 打断,无法被 notify() 唤醒。 |
5. 是否抛异常 | 需要处理 InterruptedException 。 | 也需要处理 InterruptedException 。 |
6. 唤醒后行为 | 通常被唤醒后继续参与协作,如再次 wait() 或继续执行临界区。 | 被打断或睡眠时间到后继续执行,不涉及线程间协作。 |