Java多线程-线程安全问题的原因和解决方案

目录

1. 线程安全的概念

2. 线程不安全的原因

修改共享数据

原子性

可见性

代码顺序性

如何解决上述的线程不安全问题

使用synchronized 关键字

synchronized 的特性

synchronized 使用示例

Java 标准库中的线程安全类

volatile 关键字


 

1. 线程安全的概念

想给出一个线程安全的确切定义是复杂的,但我们可以这样认为: 如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线 程安全的

 

2. 线程不安全的原因

给大家看一段代码,思考一下它为什么不安全?

static class Counter {
    public int count = 0;
    void increase() {
        count++;
   }
}
public static void main(String[] args) throws InterruptedException {
    final Counter counter = new Counter();
    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 50000; i++) {
            counter.increase();
       }
   });
    Thread t2 = new Thread(() -> {
        for (int i = 0; i < 50000; i++) {
            counter.increase();
       }
   });
    t1.start();
    t2.start();
    t1.join();
    t2.join();
    System.out.println(counter.count);
}

运行结果:

d7d0e6431c0745e18ec688d19609d499.png

 为什么它的结果不是100000呢?

修改共享数据

上面的线程不安全的代码中, 涉及到多个线程针对 counter.count 变量进行修改. 此时这个 counter.count 是一个多个线程都能访问到的 "共享数据"

counter.count 这个变量就是在堆上. 因此可以被多个线程共享访问.

原子性

什么是原子性?

我们把一段代码想象成一个房间,每个线程就是要进入这个房间的人。如果没有任何机制保证,A进入 房间之后,还没有出来;B 是不是也可以进入房间,打断 A 在房间里的隐私。这个就是不具备原子性的。

那我们应该如何解决这个问题呢?是不是只要给房间加一把锁,A 进去就把门锁上,其他人是不是就进 不来了。这样就保证了这段代码的原子性了。

附:有时也把这个现象叫做同步互斥,表示操作是互相排斥的。

一条 java 语句不一定是原子的,也不一定只是一条指令

如果一个线程正在对一个变量操作,中途其他线程插入进来了,如果这个操作被打断了,结果就可能是错误的。这点也和线程的抢占式调度密切相关. 如果线程不是 "抢占" 的, 就算没有原子性, 也问题不大。

可见性

可见性是指, 一个线程对共享变量值的修改,能够及时地被其他线程看到.

Java 内存模型 (JMM): Java虚拟机规范中定义了Java内存模型. 目的是屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果.

5d6b43675d4d4a3ea8407d5095e501f9.png

1. 线程之间的共享变量存在 主内存 (Main Memory).

2. 每一个线程都有自己的 "工作内存" (Working Memory) .

3. 当线程要读取一个共享变量的时候, 会先把变量从主内存拷贝到工作内存, 再从工作内存读取数据.

4. 当线程要修改一个共享变量的时候, 也会先修改工作内存中的副本, 再同步回主内存.

由于每个线程有自己的工作内存, 这些工作内存中的内容相当于同一个共享变量的 "副本". 此时修改线程 1 的工作内存中的值, 线程2 的工作内存不一定会及时变化.

 (1) 初始情况下, 两个线程的工作内存内容一致.

2af376ac4dba42d997b1f840ec159cc4.png

(2)  一旦线程1 修改了 a 的值, 此时主内存不一定能及时同步. 对应的线程2 的工作内存的 a 的值也不一定 能及时同步. 

16fa218f08fb4b4bbdd02641bf606cc0.png

这个时候代码中就容易出现问题.

 

代码顺序性

什么是代码重排序 一段代码是这样的:

1. 去前台取下 U 盘

2. 去教室写 10 分钟作业

3. 去前台取下快递

如果是在单线程情况下,JVM、CPU指令集会对其进行优化,比如,按 1->3->2的方式执行,也是没问 题,可以少跑一次前台。这种叫做指令重排序

编译器对于指令重排序的前提是 "保持逻辑不发生变化". 这一点在单线程环境下比较容易判断, 但 是在多线程环境下就没那么容易了, 多线程的代码执行复杂程度更高, 编译器很难在编译阶段对代 码的执行效果进行预测, 因此激进的重排序很容易导致优化后的逻辑和之前不等价. 

如何解决上述的线程不安全问题

家人们,接下来让我们看看如何解决上述出现的问题吧

使用synchronized 关键字

synchronized 的特性

互斥

synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到同一个对象 synchronized 就会阻塞等待.

进入 synchronized 修饰的代码块, 相当于加锁

退出 synchronized 修饰的代码块, 相当于解锁

57d35d3919a7491f9072c5e47ac0be4d.png

 synchronized用的锁是存在Java对象头里的。

可以粗略理解成, 每个对象在内存中存储的时候, 都存有一块内存表示当前的 "锁定" 状态(类似于厕 所的 "有人/无人"). 如果当前是 "无人" 状态, 那么就可以使用, 使用时需要设为 "有人" 状态. 如果当前是 "有人" 状态, 那么其他人无法使用, 只能排队

针对每一把锁, 操作系统内部都维护了一个等待队列. 当这个锁被某个线程占有的时候, 其他线程尝 试进行加锁, 就加不上了, 就会阻塞等待, 一直等到之前的线程解锁之后, 由操作系统唤醒一个新的 线程, 再来获取到这个锁

当上一个线程解锁之后, 下一个线程并不是立即就能获取到锁. 而是要靠操作系统来 "唤醒". 这 也就是操作系统线程调度的一部分工作. 假设有 A B C 三个线程, 线程 A 先获取到锁, 然后 B 尝试获取锁, 然后 C 再尝试获取锁, 此时 B 和 C 都在阻塞队列中排队等待. 但是当 A 释放锁之后, 虽然 B 比 C 先来的, 但是 B 不一定就能 获取到锁, 而是和 C 重新竞争, 并不遵守先来后到的规则.

刷新内存

synchronized 的工作过程:

1. 获得互斥锁

2. 从主内存拷贝变量的最新副本到工作的内存

3. 执行代码

4. 将更改后的共享变量的值刷新到主内存

5. 释放互斥锁

可重入

synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题;

如何自己死锁?

一个线程没有释放锁, 然后又尝试再次加锁.

如以下代码:

// 第一次加锁, 加锁成功
lock();
// 第二次加锁, 锁已经被占用, 阻塞等待. 
lock();

这样的锁称为不可重入锁.

Java 中的 synchronized 是 可重入锁, 因此没有上面的问题.

在下面的代码中, increase 和 increase2 两个方法都加了 synchronized, 此处的 synchronized 都是针对 this 当前 对象加锁的. 在调用 increase2 的时候, 先加了一次锁, 执行到 increase 的时候, 又加了一次锁. (上个锁还没释 放, 相当于连续加两次锁) 这个代码是完全没问题的. 因为 synchronized 是可重入锁.

static class Counter {
    public int count = 0;
    synchronized void increase() {
        count++;
   }
    synchronized void increase2() {
        increase();
   }
}

在可重入锁的内部, 包含了 "线程持有者" 和 "计数器" 两个信息. 如果某个线程加锁的时候, 发现锁已经被人占用, 但是恰好占用的正是自己, 那么仍然可以继续获取 到锁, 并让计数器自增. 解锁的时候计数器递减为 0 的时候, 才真正释放锁. (才能被别的线程获取到)

synchronized 使用示例

synchronized 本质上要修改指定对象的 "对象头". 从使用角度来看, synchronized 也势必要搭配一个具 体的对象来使用.

直接修饰普通方法: 锁的 SynchronizedDemo 对象

public class SynchronizedDemo {
    public synchronized void methond() {
   }
}

修饰静态方法: 锁的 SynchronizedDemo 类的对象

public class SynchronizedDemo {
    public synchronized static void method() {
   }
}

修饰代码块: 明确指定锁哪个对象.

锁当前对象

public class SynchronizedDemo {
    public void method() {
        synchronized (this) {
            
       }
   }
}

锁类对象

public class SynchronizedDemo {
    public void method() {
        synchronized (SynchronizedDemo.class) {
       }
   }
}

我们重点要理解,synchronized 锁的是什么. 两个线程竞争同一把锁, 才会产生阻塞等待.

Java 标准库中的线程安全类

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

ArrayList

LinkedList

HashMap

TreeMap

HashSet

TreeSet

StringBuilder

还有的虽然没有加锁, 但是不涉及 "修改", 仍然是线程安全的

String

volatile 关键字

volatile 能保证内存可见性

代码在写入 volatile 修饰的变量的时候,

改变线程工作内存中volatile变量副本的值

将改变后的副本的值从工作内存刷新到主内存

代码在读取 volatile 修饰的变量的时候,

从主内存中读取volatile变量的最新值到线程的工作内存中

从工作内存中读取volatile变量的副本

前面我们讨论内存可见性时说了, 直接访问工作内存(实际是 CPU 的寄存器或者 CPU 的缓存), 速度 非常快, 但是可能出现数据不一致的情况. 加上 volatile , 强制读写内存. 速度是慢了, 但是数据变的更准确了.

在这个代码中

创建两个线程 t1 和 t2

t1 中包含一个循环, 这个循环以 flag == 0 为循环条件.

t2 中从键盘读入一个整数, 并把这个整数赋值给 flag.

预期当用户输入非 0 的值的时候, t1 线程结束.

static class Counter {
    public int flag = 0;
}
public static void main(String[] args) {
    Counter counter = new Counter();
    Thread t1 = new Thread(() -> {
        while (counter.flag == 0) {
            // do nothing
       }
        System.out.println("循环结束!");
   });
    Thread t2 = new Thread(() -> {
        Scanner scanner = new Scanner(System.in);
        System.out.println("输入一个整数:");
        counter.flag = scanner.nextInt();
   });
    t1.start();
    t2.start();
}
// 执行效果
// 当用户输入非0值时, t1 线程循环不会结束. (这显然是一个 bug)

如果给 flag 加上 volatile

static class Counter {
    public volatile int flag = 0;
}
// 执行效果
// 当用户输入非0值时, t1 线程循环能够立即结束. 

好了,本节就到这里了,希望家人们多多支持!

 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

马可波罗.

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

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

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

打赏作者

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

抵扣说明:

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

余额充值