目录
前言
一、一个线程不安全的例子
二、线程不安全的原因
三、线程安全的解决方案
3.1 原子性的概念
3.2 锁(synchronized)
3.3 synchronized的特性
互斥性
可重入性
3.4 synchronized 使用示例
修饰代码块
修饰静态方法
修饰普通方法
3.5锁对象的意义
3.6 volatile 关键字
由于内存可见性引发线程安全的例子:
volatile一般适用场景
volatile和synchronized的区别
3.7 指令重排序
指令重排序的意义
一个例子
本篇博客主要介绍多线程中经常出现的线程安全问题及其解决方案。
直接上代码:
class Counter{
int count = 0;
Object locker = new Object();
public void add(){
count++;
}
public int getCount(){
return count;
}
}
public class ThreadDemo5 {
public static void main(String[] args) throws InterruptedException {//多个线程修改同一个变量带来的安全问题
Counter counter = new Counter();
Thread t1 = new Thread(()->{
for (int i = 0; i < 10000; i++) {
counter.add();;
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 10000; i++) {
counter.add();
}
});
t1.start();
t2.start();
Thread.sleep(1000);
System.out.println(counter.getCount());
}
}
运行结果:
分析代码:上面的代码我们是让mian线程里面创建了t1和t2两个线程、实例化了Counter对象,让t1和t2对Counter对象里的count值各自进行1w次自加操作,我们的预期结果是输出20000,但是我们执行了三次,三次的结果都不一样,而且都不是20000,这里其实就是因为多线程的无序调度执行引起的线程安全问题。接下来我们来对上面代码一个详细的分析:
add方法底层原理:我们调用add方法对count自加,而count++ 操作本质上是由三个cpu指令构成:①把内存上的值加载到cpu寄存器上我们称为load操作;②在cpu上对count进行加一操作,我们成为add操作;③将cpu寄存器上的值写回内存上我们成为sava操作;由于cpu对线程的调度是无序的,随机的,再加上上面的三步操作是分开的(不是原子性的操作),所以以上的三步操作就会出现个种排列组合的顺序,以下就列出三种情况:其中第一种情况是安全的,其余两种是不安全的,他们各自执行自己的操作,自加操作的数据可能会被覆盖,因此每次得到的数据就会比预期结果小了。但是结果是不可能超过2w的,但是是有可能会小于1w。都是因为那三步操作各种各样的排列组合引起的。
基于上面的线程不安全的例子,我们可以总结出部分导致线程不安全的原因:
1.cpu对线程调度的无序性,也就是线程的抢占式执行
2.多个线程修改同一个变量
3.修改变量的操作是非原子性的
4.内存可见性(上面的例子与这个原因无关)
5.指令重排序(上面的例子与这个原因无关)
上面的代码例子中还涉及到原子性这个名词:那原子性是什么呢?
我们把一段代码想象成一个房间,每个线程就是要进入这个房间的人。如果没有任何机制保证,A进入 房间之后,还没有出来;B 是不是也可以进入房间,打断 A 在房间里的隐私。这个就是不具备原子性的。
那我们应该如何解决这个问题呢?是不是只要给房间加一把锁,A 进去就把门锁上,其他人是不是就进不来了。这样就保证了这段代码的原子性了。
原子性对线程安全还是有很大影响的,很多时候线程安全往往是因为代码不具备原子性而引起的,因此在考虑线程安全的时候,也可以多往原子性的方面考虑一下。
Java中的锁可以使用关键字synchronized来实现。具体如何使用,我们通过代码来演示:
class Counter{
int count = 0;
Object locker = new Object();
public void add(){
synchronized (this) {//对count++进行加锁操作
count++;
}
}
public void sub(){
synchronized(this) {
count--;
}
}
public int getCount(){
return count;
}
}
上面的问题代码我们只要对count++的这个操作进行加锁操作就可以得到我们预期的结果了。对于synchronized();这里的括号中只需要填写一个Object类的实例化对象就可以,然后在后面的花括号加上需要加锁的代码就可以,加锁的代码就可以很好的保证原子性了。
对于synchronized()括号中加的对象的意义:this代表的是当前对象的引用,我们也可以自己创建一个对象作为锁对象,只要锁对象相同,就说明是同一个锁,而同一个锁同一时间只能被一个代码块使用,换言之,如果是上了同一个锁的两段代码块是无法同时执行的。这也就是锁的互斥性
进入 synchronized 修饰的代码块, 相当于加锁
退出 synchronized 修饰的代码块, 相当于解锁
synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题;
Java锁的可重入性指的是同一个线程可以多次获得同一个锁,而不会被阻塞。当一个线程持有一个锁时,如果它再次请求获取同一个锁,那么请求会成功,而不会被阻塞,这种情况下就称为锁的可重入。
可重入锁是一种线程安全的锁,它可以避免死锁,并且能够提高程序的性能。在Java中,synchronized关键字和ReentrantLock类都是可重入锁。
例如,一个方法内部调用另一个需要同步的方法,如果这两个方法都使用了同一个锁,那么在同一个线程中,第二次调用方法时不需要再次获取锁,因为它已经持有了锁,这就是锁的可重入性。
可重入锁的实现需要记录线程持有的锁的数量,因此在使用时需要注意避免重复获取锁,否则可能会导致死锁。
我们需要明确指定锁对象
class Counter{
public void add(){
synchronized (this) {
count++;
}
}
}
锁对象是类对象
class Counter {
public synchronized static void add() {
}
}
通过类名.class获取类对象
class Counter {
public void add() {
synchronized (Counter.class) {//Counter.class获取类对象的写法
}
}
}
对普通方法加锁锁对象是当前类的实例对象的引用就是this
class Counter {
public synchronized void add() {
}
}
锁对象本质上只是起到了一个标识的作用,如果多个代码块或者方法是同一个锁对象,那么才有可能会有锁竞争,如果针对多个代码块,都有各自的锁对象,那么就不会出现锁竞争。如果锁对象是类对象,就相当于是对是对类的某个属性或方法加锁,这个属性或方法就是属于类的,只要是该类的实例化对象调用了该属性或方法,就会对该属性或该方法加锁,就会有锁竞争。
对类对象加锁的代码案例:(这个代码是没问题的)
class MyTest{
static int count = 0;
public void add(){//对类对象加锁,效果就是,无论该类的哪些实例化对象,调用此方法都是会加锁的线程安全的
synchronized (MyTest.class) {
count++;
}
}
public int getCount(){
return count;
}
}
public class ThreadDemo21 {
public static void main(String[] args) throws InterruptedException {
MyTest s = new MyTest();
MyTest s2 = new MyTest();//实例化两个对象
Thread t1 = new Thread(()->{
for (int i = 0; i < 1000; i++) {
s.add();
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 1000; i++) {
s2.add();
}
});
t1.start();
t2.start();
Thread.sleep(1000);
System.out.println(s.getCount());
System.out.println(s2.getCount());
}
}
代码运行结果:
代码分析:
上面的代码我们实例化了两个对象,一般情况下,我们去调用add方法对count进行自加(这里的count是属于类的属性)两个线程如果同时对count++这个操作循环执行1000次,得到的结果一般会小于两千,这是因为有的++操作被覆盖了,这时候我们如果只是对count++操作指定一个一般锁对象进行加锁,这里还是不安全的,这里正确的方法是对类对象进行加锁,才可以得到正确的结果。这里对类对象加锁,本质上就是将count++操作作为了类的操作,只要是该类的实例化对象调用该方法就会加锁,就会有锁竞争。
如果把上面代码的add方法改成不对类对象加锁:
如果改成不对类对象加锁,只是随便指定一个锁对象,这样子对于不同的实例化对象来说,如果同时调用add方法,就无法保证count值的准确性了。
作用:volatile 修饰的变量, 能够保证 "内存可见性".
public class ThreadDemo6 {//内存可见性的线程安全问题
public static int flag = 0;
public static void main(String[] args) {
Thread t1 = new Thread(()->{
while (flag == 0){
}
System.out.println("循环结束,线程t1结束");
});
Thread t2 = new Thread(()->{
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个数:");
flag = scanner.nextInt();
});
t1.start();
t2.start();
}
}
运行结果:
我们发现:我们输入1以后这里的线程t1的循环并没有结束,而是一直执行,这就与我们的预期出现了不一致,我们是想通过在线程t1的外面改变flag的值来控制循环,但是却没有达到效果,这里其实就涉及到了内存可见性的安全问题。
具体分析:这里的while循环是个空循环,所以这个循环只要做两件事,第一是把flag的值从内存上加载到寄存器,第二是判断flag是否满足条件;但是第一步从内存中把flag的值加载到寄存器中这个操作相比于第二步的操作是慢非常多的,所以这时候,编译器就自动优化了。编译器只读了一遍内存中flag的值,然后一直判断,没有重复再去内存中读了。这就导致了我们即使修改了flag的值,循环也无法结束了。
解决办法:
这里我们就可以使用volatile关键字,使编译器停止对变量flag停止优化。加上volatile关键字之后,每次使用到flag这个值就会重新去内存中读取。就可以解决上述问题了。
一般情况下,我们如果需要经常对一个变量进行修改操作,这时候就建议要加上volatile关键字以保证线程安全问题。
volatile 和 synchronized 有着本质的区别. synchronized 能够保证原子性, volatile 保证的是内存可见性.
指令重排序也是编译器对代码的优化,在保证代码执行逻辑不变的情况下通过对代码的一些执行顺序进行适当的调整,进而提高程序的执行速度。
这里假设我们有一个Student类,然后我们去实例化一个对象:
Student s = new Student();
对于上面的实例化对象的语句,一般可以分为三步:①申请内存空间;②调用构造方法,初始化内存数据;③将对象的引用赋值给s;
在单线程的情况下,就算指令重排序了,我们先执行②或者③都是不会出现问题的,但是在多线程下就不一样了。假设我们有两个线程t1和t2,上述语句按1 3 2的顺序执行.当t1执行了1 3即将执行2时,这时候t2开始执行了,此时对象s已经时非空的了,但是还没调用构造方法初始化,假如这时候t2里面调用了s,这时候就会出现我们无法预料的错误了。虽然这种概率很小,但是却是真实存在的,这里很难通过代码来演示。
解决指令重排序的方法:这里可以加上synchronized或者volatile就可以解决。(volatile也可以禁止指令重排序)