解决线程不安全问题的方法

解决线程不安全问题:

一、原子性

synchronized关键字的特性(监视锁)

1、synchronized的互斥性

通过特殊手段,让count++变成原子操作

举例:上厕所,人进入后上锁,用完了出来解锁,期间只有自己可以使用这个厕所。

解决线程不安全也是类似的,在count++之前上锁,在count++完之后解锁,在加锁和解锁期间,进行修改,这个期间其他线程想要修改,是修改不了的,只能阻塞等待(线程状态:BLOCKED)。

Java中使用synchronized关键字来加锁

锁的特性:具有独特性,如果当前锁没人来加,加锁操作就成功,如果已经被加上,加锁操作就会阻塞等待。

1.1基础使用:synchronized关键字修饰一个普通方法

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-LYwJPHJO-1677157201827)(https://picgo-char.oss-cn-beijing.aliyuncs.com/202302222130097.png)]

通过这样一个简单的操作,我们就可以保证代码是正确的结果:

image-20230222214709594

image-20230222215753575

本来线程调度,是随机的过程~

一旦两个组load add save交织在一起,就会产生线程安全问题;

现在使用锁,就使这两组load add save能够串行执行

注意:即使t1加锁以后,CPU可能进行调度切换,去执行其他线程,即使t1不在CPU中执行,但是t1仍然是加锁状态,t2依旧是BLOCK状态,无法在CPU上运行。

小思考:

一个线程加锁,一个线程不加锁,这个时候会咋样?线程安全能否保证?

线程安全问题,不是加锁了就一定安全,而是通过加锁,让并发修改同一个变量最后形成的效果是串行修改同一个变量,才是安全的。

加锁的方式,位置不正确,不一定能解决线程安全问题,也可能因为解决了线程安全问题而导致因为线程来回调度的销毁,并发编程的速度不如串行执行。

只给一个线程加锁,没有用!!!加锁的目的是让两个线程可以产生锁竞争!只有一个加了,另外一个不加就不会有锁竞争!

1.2synchronized修饰代码块

如果一个方法中只是有些代码需要加锁,有些不需要,就可以使用这种修饰代码块的方式进行了。

image-20230222221924975

注意:

1、synchronized()这里的括号,里面填的东西,就是你要针对哪个对象加锁(被加锁的对象就叫做锁对象)

2、使用锁的时候,一定要明确,当前是针对哪个对象加锁**(关键)**

3、一个synchronized只能锁一个对象

知道了上面的注意点以后我们再来了解锁对象可以是哪些。

1.2.1锁对象是this

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-y0KgTvdG-1677157201830)(https://picgo-char.oss-cn-beijing.aliyuncs.com/202302222223095.png)]

针对当前对象加锁,谁调用了increase方法谁就是this。

image-20230222222545313

1.2.2专门创建Locker对象作为锁对象

任意对象都可以在synchronized里面作为锁对象!!!

我们写多线程代码的时候,不关心这个锁对象是谁,是哪种形态,只关心两个线程是否锁同一个对象,锁同一个对象就有竞争反之亦然。

image-20230222223146811

因为两个线程使用的都是同一份count对象,所以他们count里面的locker对象也是同一份的。

image-20230222223737700


总结:

如果synchronized直接修饰方法,就相当于锁对象是this,大部分情况下,直接写this作为锁对象,一般都是可以的,不行的时候再讨论。

更加广义的锁对象:

Java 中的任何一个对象都可以作为锁对象!!!(成员变量, 局部变量,静态变量,类对象…)

而且这些不同形态的对象,作为锁对象的时候没有任何区别!!!锁对象只是用来控制线程之间的互斥的~~是针对同一个对象加锁,就会出现互斥.是针对不同对象加锁就不会互斥

我们写多线程代码的时候,不关心这个锁对象是谁,是哪种形态,只关心两个线程是否锁同一个对象,锁同一个对象就有竞争反之亦然。

锁对象是类对象时候值得注意:

类对象只有一个

image-20230222225132022

1.3修饰静态方法

image-20230222225645941

修饰静态方法时候相当于锁对象是类对象

2、synchronized的可重入性

整队一个线程,连续对一把锁加锁两次,就可能造成死锁

例如这样的代码:

image-20230222231351555

什么叫做死锁呢?
假设一个线程针对一把锁加锁两次,第一次加锁能够加锁成功,第二次加锁会加锁失败(锁已经被占用)就会在第二次加锁这里阻塞等待,等到第一把锁解锁,但是第一步锁想要解锁则需要执行完第二次加锁里面对应的代码块,也就是要求第二把锁加锁成功才第一把锁才能解锁,这样的死循环就叫做死锁。

针对上诉情况,不会产生死锁的话,这样的锁叫做**“可重入锁”**
针对上诉情况,会产生死锁,这个锁就叫做**“不可重入锁”**

我们学习的synchronized是可重入的。

【八股文】如何实现一个可重入锁?

1、入锁过程

可重入锁底层实现,是很简单的.
只要让锁里面记录好,是哪个线程持有的这把锁

例如t 线程尝试针对 this 来加锁~~ this 这个锁里面就记录了 是 t 线程持有了它;
第二次进行加锁的时候,锁一看,还是 t 线程, 就直接通过了,没有任何负面影响,不会阻塞等待!!!

2、解锁过程:

image-20230222232149693

引入一个计数器~~
每次加锁,计数器 ++
每次解锁,计数器 –
如果计数器为 0,此时的加锁操作才真加锁同样计数器为 0,此时的解锁操作才真解锁

总结:

可重入锁的实现要点:
1、让锁里持有线程对象,记录是谁加了锁

2、维护一个计数器,用来衡量啥时候是真加锁,啥时候是真解锁,啥时候是直接放行

二、两个线程同时修改同一个变量

通过修改代码的顺序来避免两个线程同时修改同一个变量。

三、内存可见性问题

假设,读操作非常频繁:

image-20230222234112094

count2.count == 2 ==> 读内存(LOAD),进行比较(CMP)

这个while循环会循环的非常非常快!频繁进行多次LOAD(读取内存)和CMP(比较寄存器的值是否是2)

在计算机中LOAD消耗的时间比CMP满3-4个数量级,慢太多了

这个时候编译器就开始优化:既然你频繁执行LOAD,并且你的LOAD结果还一样,干脆就执行一次LOAD 就得了.后续进行CMP就不再重新读内存了。

因为在这个t1线程中,没有人修改Count.count的值,编译器就认为读到的结果都是固定的,也就做了一个大胆的决定,只读一次,后面不读了,可以大大提升效率.

但是此时我们加入一个线程2修改整个值,会怎么样?

image-20230222210430398

执行的结果:

image-20230222211433462

运行的时候会出现问题,即使我们t2线程把值修改了,因为编译器优化,t1线程没感知到**(内存可见性问题)**编译器还是认为它没有修改

编译器优化,不应该是保持代码逻辑不变的前提下,才能进行优化嘛??
这里的优化不是让逻辑变了嘛??想让他结束,结束不了,就是bug!!

结论:编译器优化,在多线程环境下可能存在误判!!!

既然编译器自己的判定不准了把不该优化的给优化了,就可以让程序员显式的提醒编译器,这个地方不要优化。

也就是volatile 关键字的作用!!

image-20230222234736756

1、volatile

volatile起到的作用是**“保证内存可见性”**

TIps:volatile不保证原子性

针对的场景:

一个线程读操作,一个线程修改,使用volatile最合适

2、JMM

谈到了volatile,一定少不了JMM(Java Memory Model(java内存模型)

volatile禁止了编译器优化,避免了直接读取CPU寄存器(工作内容/工作存储区/work memory)中缓存的数据,而是每次都重新读内存(主内存/主存储区/main memory)。

主内存才是我们熟悉的内存,工作内存是缓存!

这套说法是Java里面的,目的是为了实现Java跨平台性/通用性。(Java初衷:程序员可以避开硬件的特性,这些特性Java帮我们搞定)

重点:

站在JMM的角度来看待volatile:

正常程序执行的过程中,会把主内存的数据,先加载到工作内存中,再进行计算处理

编译器起到的优化效果就是让程序每次都不是真正的读取主内存的数据,而是直接读取工作内存中的缓存数据(可能导致内存可见性)

volatile起到的效果就是保证程序每次读取内存都是主内存重新读取

四、抢占式执行问题

多线程很讨厌:抢占式执行,调度过程是随机的,很多时候,我们又希望多个线程按照一个预期的顺序来进行执行

wait和notify就是用来调配线程执行的顺序的

1、wait

wait是Object的方法.Object作为Java所有类的祖宗;一次就可以使用任意的类的实例都能调用wait方法。

image-20230223195714693

线程执行到wait,就会发生阻塞;直到另外一个线程,调用notify把这个wait唤醒,才会继续往下走。

我们使用上面的尝试启动t1线程,会发现报错:

image-20230223010051726

解释一下这个报错是什么意思:

image-20230223194356752

使用wait前必须要先加锁!!!

image-20230223195739963

此时程序运行正常~

image-20230223194706976

使用wait时候,本质上做了三件事:

1、释放当前锁

2、进行等待通知

3、满足一定条件的时候(别人调用notify)被唤醒,然后尝试重新获取锁

等待通知的前提,是要先释放锁!而释放锁的前提,是你得加了锁(加上锁,才能谈释放锁),这件事为什么之前不加锁的时候使用wait会报错。

2、notify

同样的,notify也是要包含在synchronized里面的

image-20230223195902266

线程1没有释放锁的话,线程2也就无法调用到notify(因为阻塞等待)

线程1调用wait,在wait里面就释放锁了,这个时候虽然线程1代码在阻塞状态,但是此时锁还是释放的状态,线程2就才能拿到锁。

注意点:

其他线程想要调用notify,就必须得先上锁,调了notify就会唤醒wait,调用wait的线程就会尝试重新获取到锁,但是notify所在线程也得先释放锁,调用wait的线程才能重新获取成功到锁。

不好理解?来一个现场图:

image-20230223201356348

image-20230223201801977

注意事项:

1、要保证加锁的对象,和调用wait的对象得是同一个对象;
还要保证,调用wait的对象和调用notify的对象也是同一个对 象。

image-20230223201823965

调用notify也得和调用wait的是同一个对象:

image-20230223201941906

2、如果t1先调用了wait,t2后调用notify,此时notify会唤醒wait

如果t2先执行了notify,t1后执行了wait,此时并不会有什么影响,错过了就错过了(即使没人调用wait,调用notify并不会有异常,副作用)

3、Java中还有一个notifyAll();notifyAll全都唤醒。

注意:即使唤醒了所有的wait,这些wait需要重新竞争锁,重新竞争锁的过程仍然是串行的。

五、指令重排序

可以使用volatile解决,具体介绍在单例模式中。

扩展:Java 标准库中的线程安全类

Java 标准库中很多都是线程不安全的. 这些类可能会涉及到多线程修改共享数据, 又没有任何加锁措施.。

  • ArrayList
  • LinkedList
  • HashMap
  • TreeMap
  • HashSet
  • TreeSet
  • StringBuilder

有一些是线程安全的,使用了锁的机制:

  • Vector (不推荐使用)
  • HashTable (不推荐使用)
  • ConcurrentHashMap
  • StringBuffer

StringBuffer 的核心方法都带有 synchronized .

还有String引用类型,因为它不涉及到修改

原因:

1、String类型里面的char[]数组是private修饰的,并且没有提供修改的方法(根本)

2、char[]数组使用了final修饰,保证char[]引用的对象不能被修改

3、string类被final修饰,不能被继承。

image-20230223204112659

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

XY枫晨

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值