复习上节内容(部分-掌握程度不够的)
加锁,解决线程安全问题。
synchronized关键字,对锁对象进行加锁。
锁对象,可以是随便一个Object对象(或者其子类的对象),需要关注的是:是否是对同一个锁对象进行加锁(锁竞争)
进入代码块,加锁;
离开代码块,解锁。
synchronized修饰普通方法,相当于给this加锁(锁对象this);
synchronized修饰静态方法,相当于给类对象加锁
从导致线程安全问题的原因,进行解决。
synchronized(续 上一篇)
synchronized特性。
- synchronized加锁效果具有互斥性。
- 可重入
拿到锁的线程再次对该锁对象进行加锁,不会阻塞;
[ 代码示例 ]
Thread t = new Thread(()->{
synchronized(locker){
synchronized(locker){
System.out.println("hello");
}
}
});
解释:
(1)上述代码,可以正常打印hello。
(2)原因:
这两次加锁,是在同一个线程进行的。
当前由于是同一个线程,此时锁对象,就知道了第二次加锁的线程,就是持有锁的线程。第二次操作,就可以直接放行通过,不会出现阻塞。 ——可重入(所以这个代码并不会出现锁冲突)
(3)好处:
a.可以避免上述代码出现死锁的情况。(Java中的锁,都是可重入锁。)
如果没有这个特性——比如C++,用的std::mutex锁,就是不可重入的,一旦以上代码出现阻塞,无法自动恢复,所以这个线程就卡死了 ~ ~(这里出现的卡死,就称为“死锁”)
b.其他容易出现这种死锁的情况:
方法/函数的调用关系复杂,加锁的代码比较隐蔽。如下例:
void func1(){
加锁
func2();
解锁;
}
void func2(){
func3();
}
void func3(){
func4();
}
void func4(){
加锁;
……
解锁;
}
以上示例的代码,直观上看,每个地方都是只加了一次锁。
但是由于复杂的调用关系,就可能导致,加锁重复了。
(4)注意:双重加锁,本身就是代码写的有问题(是有问题的代码逻辑,本来就不应该这样写),所以也没有应用场景 ~ ~
可重入这个特性,就是为了防止咱们在“不小心”中引入问题,就算你不小心了,也没事!!!(即:写错了也能正常运行)
(5)原理:如何能实现可重入性
可重入锁:
内部持有两个信息:
a.当前这个锁是被哪个线程持有的
b.加锁次数计数器
(
初始值为0,加锁一次,+1一次,第一次加锁——也就是为0的时候加锁,会同时记录线程是谁;
判定当前加锁线程是否上持有锁的线程,如果不是同一个线程,阻塞;如果是同一个线程,就只是让计数器++,即可 ~ 没有别的操作了 ~ ~
)
注意:
a.synchronized嵌套多层也可以保证在正确的时机解锁
b.计数器是真正用来识别解锁时机的关键要点 ~ ~
这个源码在JVM中,C++代码实现出来的,在idea中是看不到的
(
【源码】:
Java标准库的源码:这个是通过Java代码写的,idea中都能看到,虽然是.class的,但是idea上你看到的是idea自动帮你反编译的
JVM里的源码:C++写的,在Java层看不到,需要额外下载jvm的源码来看 ~ ~
)
c.最外层的{进行加锁,最外层的}进行解锁
一共只有一把锁(一个锁对象,只有一把锁)
d.锁的加锁次数和线程,不能通过函数进行获取(由JVM封装好了,我们知道就行,不必去干预)
e.(接d.)jconsole可以查看到的是线程的状态,能一定程度上反应出锁的状态,但是并不能获取锁的这两个信息(属性)
PS:【计数器】,这种处理方式,很多地方都会使用到,可以理解为一种处理技巧。
死锁
——多线程代码中的一类经典问题 ~ ~ 也是经典面试题
(加锁是可以解决线程安全问题,但是如果加锁方式不当,就可能产生死锁。)
死锁,属于程序中最严重的一类bug。一旦出现死锁,线程就“卡住了”,无法继续工作。(所以,要想办法避免 ~ ~)
【死锁的三种典型场景】
-
一个线程,一把锁
刚才的代码中,如果是不可重入锁,并且只有一个线程对这把锁加锁两次,就会出现死锁。 -
两个线程,两把锁
线程1 获取到 锁A
线程2 获取到 锁B
接下来,1尝试获取B,2尝试获取A。
就同样出现死锁了!!!举例:
(“互不相让,不懂合作,僵持不前”,执行完run,线程才是结束,这里是僵持住们无法结束了)
运行这个代码,打开jconsole进行查看:
(上边这个例子中,如果约定加锁顺序,先对A加锁,后对B加锁 ~ ~此时,死锁仍然可以解决 ~ ~) -
N个线程M把锁
哲学家就餐问题
(注意,上图中,每个滑稽,都只能拿挨着他的两只筷子)
以上,描述完“哲学家就餐问题”(吃面条——去CPU上运行;思考人生——放下CPU被调度走;拿筷子——加锁)
要想解决死锁问题,就要能够了解原因
↓↓↓
【产生死锁的四个必要条件 ~ ~】
-
互斥使用。(最基本的特性,不太好破坏)
获取锁的过程是互斥的。一个线程拿到了这把锁,另一个线程也想获取,就需要阻塞等待。 -
不可抢占。(锁最基本的特性,不太好破坏)
一个线程拿到了锁之后,只能主动解锁,不能让别的线程强行把锁抢走 ~ ~ -
请求保持。(代码结构,不一定能破坏,要看实际需求 ~有时候代码就是需要两个锁都拿到)
一个线程拿到了锁A之后,在(一直)持有A(没有释放)的前提下,(总是)尝试获取B。 -
循环等待/环路等待(代码结构相关,最容易破坏 ~ ~只需要制定一定的规则,就可以有效的避免循环等待!!!比如:指定加锁顺序 ~ ~)
解决死锁问题,核心思路:破坏上述的必要条件之一,就搞定!!!
【解决死锁】从原因入手(第四条,最容易突破,有很多种方案 ~ ~)
1)引入额外的筷子
2)去掉一个线程
3)引入计数器,限制最多同时多少个人同时吃面
==》1)2)3),这三个方案虽然不复杂,但是,普适性不高,有的时候用不了 ~ ~
4)引入加锁顺序的规则(普适性高,方案容易落地)
5)“银行家算法”
能解决死锁问题,但是这个方案太复杂了!!!理论可行,实践中并不推荐。实际开发中千万不要这么做。先不谈解决死锁问题,很可能你写的银行家算法本身就存在bug。
【问题】“可不可以给‘哲学家’编号,反正每次只能有一位哲学家吃,让他们按编号用餐?”
答:不行。
线程调度的大前提是“随机调度”。
想办法让某个线程 先加锁,违背了“随机调度”根本原则。可行性是不高的。
(而约定加锁顺序,在写代码的层面上,是非常容易做到的 ~ ~)
Java标准库中的线程安全类
标准库有很多 集合类
——这些类,都线程不安全。
多个线程,尝试修改同一个上述的对象,就很容易出现问题!!!
(注意:这里“很容易出现问题”,而不是100%,也可能这个代码写出来后,是没有问题的,具体代码具体分析。多线程的代码,稍微变化一点,就可能有不一样的结果)
这几个类,自带锁了 ~ ~
在多线程环境下的时候,能好点儿 ~ ~
但是,也不是100%不出问题!!只是概率比上面小很多。具体代码,具体分析!!!
(多线程的代码,稍微变化一点,就可能有不一样的结果)
注意:这几个类,都是标准库即将弃用的 ~ ~现在暂时还保留着,未来某一天新版本的jdk可能就把这些内容删了 ~ ~所以,在写新的代码的时候,就尽量别用了,不推荐 ~ ~
拓展:【jdk版本升级】
之前用的一直是jdk8这个经典版本,2014年发行。之后要学到Spring,Spring升级到3之后,不支持jdk8了,最低也需要jdk17.
(另外,Spring升级了,旧版本的Spring虽然仍然能使用,但是修改起来非常麻烦,所以还是建议采用用jdk17这种方案。)
jdk的版本升级虽然快,但是新版本的新东西不算多 ~ ~
【更改方法】
下载安装jdk17,然后把idea设置一下,使用jdk17即可 ~ ~(并不费事,不要退缩!!!)
继续讲解引起线程安全问题的原因
内存可见性
如果一个线程写,一个线程读,也是可能有线程安全问题的。
代码示例:
//这个代码中,预期通过t2线程输入的整数,只要输入的不为0,就可以使t1线程结束。
public static int flag = 0;//public 类中的成员变量
public static void main(){
Thread t1 = new Thread(()->{
while(flag == 0){
//循环体里,啥都不写
}
System.out.println("t1 线程结束!");
});
Thread t2 = new Thread(()->{
System.out.println("请输入 flag 的值:");
Scanner sc = new Scanner(System.in);
flag = sc.nestInt();
});
t1.start();
t2.start();
}
【预期】通过t2线程输入的整数,只要输入的不为0,就可以使t1线程结束。
【问题】实际输入非0的时候,发现t1并没有真的结束!!!
以上代码出现问题的原因:
(1)JVM对代码做出了优化。
t1的循环体内,什么都没有写,核心指令就只有两条:a.load读取内存中flag的值,到CPU寄存器里。b.拿着寄存器的值和0进行比较(条件跳转指令 ~ ~)
所以,上述循环执行速度就会非常快!!!(a、b两条指令,快速、反复的执行)
这样,在这个执行过程中,有两个关键要点:
①load操作执行的结果,每次都是一样的!!!(想要输入,也是过几秒才能输入,人并没有那么快。在这几秒之内,已经执行了不知道多少次循环,上百亿次 ~ ~)
②load操作,它的开销远远超过条件跳转!!
访问寄存器的速度,远远超过访问内存!!
频繁执行load和条件跳转,load的开销大,并且load的结果又没有变化(真正出现变是好几秒之后的事:用户输入)。
此时,JVM就产生怀疑:这里的load操作是否真的有存在的必要???——JVM就可能做出代码优化 ~ ~
JVM把上述load操作,给优化掉!!!(只有前几次执行load,后续发现,load反正都一样,静态分析代码,也没看到哪里改了flag,因此,就直接激进的把load操作,给干掉了)
load操作被干掉之后,就相当于不再重复读内存,而直接使用寄存器中之前“缓存”的值 ~ ~大幅地提高了循环的执行速度!!!
不过,也因此,导致t2修改了flag的内容,但是t1没有看到这个内存的变化**==》内存可见性问题**
(2)t2修改了内存,但是t1没有看到这个内存的变化(所谓:内存可见性)
以上两条原因,(1)导致了(2),进而导致程序出现了问题。
【拓展:JVM代码优化功能】
(其他)编译器/JVM,都非常厉害。
很多地方都会涉及到代码优化。
确实存在有些程序猿代码写的不太行。因此,设计JVM和编译器的大佬就引入这样的优化能力,在优化的加持下,就能让你即使写不出太高效的代码,最终的执行效率也不会太差 ~ ~ ~
有没有优化,差别非常大。(比如:有服务器,开启优化,10分钟完成启动;关闭优化,30min+)
虽然我们写的只是一份代码,但是编译器和JVM就能只能分析出:当前这份代码哪里不太合理,然后对代码进行调整 ~ ~保证了,在原有逻辑不变的前提下,提高程序效率 ~ ~
很多主流语言的编译器,都有这样的能力(对代码进行不合理分析,调整,逻辑不变,效率提升)
但是!!!原有逻辑不变这点,编译器是没有那么容易正确保持的。(单线程下,还好;多线程下,很容易出现误判——这个可以视作bug)
【对“多线程代码,稍微变化一点,就可能有不一样的结果”的一点例子,帮助理解】
//其实就是对刚才的代码略加改动:在循环体中,加入sleep语句;
Thread t1 = new Thread(()->{
while(flag==0){
try{
Thread.sleep(10);
//不加sleep,一秒钟循环上百亿次
//load操作的开销非常大,所以优化的迫切程度就更高
//加了sleep,一秒钟循环1000次
//load整体开销就没那么大了,优化的迫切程度就降低了。
//所以可知:编译器什么时候触发优化,不一定。进而,什么时候出现“内存可见性问题”,也就不一定了。(代码稍微改动一点,结果就截然不同。)
}catch(InterruptedException e){
e.printStackTrace();
}
}
System.out.println("t1线程结束!");
});
解决内存可见性问题【volatile关键字】
——由上述文字可知:需要解决“是否选择启用优化”。
【volatile】强制关闭优化/或称:强制读取内存。
(可以确保示例代码中,每次循环都会重新从内存中读取数据)
这样做,开销是大了,效率是低了,但是数据的准确性、逻辑的正确性,都提高了。
(更多时候,快没有准重要,就加volatile;确实有时候需要快而不要求准,就不加volatile。根据场景需求,作取舍)
这样volatile关键字,就把是否启用优化 的 选择权 ,交给了程序猿自己。
【volatile功能】
-
保证内存可见性(核心功能之一)
/关于 内存可见性,有两种表述/
/(1)前边说过的:
上述代码,编译器发现,每次循环都要读取内存,开销太大。于是就把读取内存操作优化成读取寄存器操作,提高效率。/
/(2)JMM(Java Memory Model)模型(一个抽象的 概念)
上述代码,编译器发现,每次循环都要读取“主内存”,就会把数据从“主内存”中复制到“工作内存”中,后续每次都是读取“工作内存”/
工作内存——不是真正的内存,而是CPU寄存器 或者 CPU的缓存(L1,L2,L3,三级缓存),称为“工作存储区”。
主内存——也就是内存。
、、
引入“工作内存”这个概念,而不直接说“CPU寄存器”,主要是为了“跨平台”。并且不用像说“CPU寄存器或缓存中”这样拗口。 -
禁止指令重排序