• java线程安全问题的解决


    目录

    1.synchronized 关键字

    2.volatile 关键字

    3.wait 和 notify


    1.synchronized关键字

    1.1 特性

    synchronized关键字可翻译为”同步“,它主要的作用是将几个操作”打包“成一个操作以实现操作原子性,进而一定程度上解决线程安全问题。其实可以把他理解成是一把锁,将对象锁了起来,在指令未完成之前其他线程不能干涉。

    1.1.1 互斥性

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

     看到上图,以我们之前的自增操作为例子(如果不了解的可以看一下java线程状态与线程安全问题_invictusQAQ的博客-CSDN博客)这次我们通过synchronized将三条指令打包成了一个整体,这样当我们自增操作开始时,操作对象就会被锁,当线程2也想执行操作时发现对象已经被锁,所以进入了BLOCKED阻塞状态,直到线程1操作完后解锁线程2才能继续进行操作,这就是互斥性。

    可以粗略理解成, 每个对象在内存中存储的时候, 都存有一块内存表示当前的 "锁定" 状态(类似于厕 所的 "有人/无人").

    如果当前是 "无人" 状态, 那么就可以使用, 使用时需要设为 "有人" 状态.

    如果当前是 "有人" 状态, 那么其他人无法使用, 只能排队

    理解 "阻塞等待".

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

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

    1.1.2 刷新内存 

    synchronized 的工作过程:

    1. 获得互斥锁

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

    3. 执行代码

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

    5. 释放互斥锁

    其实重点是2,4步,因为它刷新内存的过程实际上就保证了内存可见性 

    1.1.3 可重入 

    synchronized 同步块对同一条线程来说是可重入的,说人话就是它不会将自己锁死。

    什么叫把自己锁死?一个线程没有释放锁, 然后又尝试再次加锁. 

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

    按照之前对于锁的设定, 第二次加锁的时候, 就会阻塞等待. 直到第一次的锁被释放, 才能获取到第二个锁. 但是释放第一个锁也是由该线程来完成, 结果这个线程已经躺平了, 啥都不想干了, 也就无 法进行解锁操作. 这时候就会死锁

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

    在可重入锁的内部, 包含了 "线程持有者" 和 "计数器" 两个信息.

    如果某个线程加锁的时候, 发现锁已经被人占用, 但是恰好占用的正是自己, 那么仍然可以继续获取到锁, 并让计数器自增.

    解锁的时候计数器递减为 0 的时候, 才真正释放锁. (才能被别的线程获取到)

    1.2 synchronized 使用示例 

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

    1. public class SynchronizedDemo {
    2. public synchronized void methond() {
    3. }
    4. }

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

    1. public class SynchronizedDemo {
    2. public synchronized static void method() {
    3. }
    4. }

    如果说把类看作是房子,那么类的对象就是房子的图纸,我就算把房子图纸锁了但是我房子还是可以进入的。同理我们把类对象加锁并不影响别的线程访问对象。

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

    锁当前对象:

    1. public class SynchronizedDemo {
    2. public void method() {
    3. synchronized (this) {
    4. }
    5. }
    6. }

    锁类对象:

    1. public class SynchronizedDemo {
    2. public void method() {
    3. synchronized (SynchronizedDemo.class) {
    4. }
    5. }
    6. }

    讲了那么多,大家可能对于锁了类还是锁了类对象还是有点懵圈,下面给大家再分析一下。

    java的对象锁类锁

    java的对象锁和类锁在锁的概念上基本上和内置锁是一致的,但是,两个锁实际是有很大的区别的,对象锁是用于对象实例方法,或者一个对象实例上的类锁是用于类的静态方法或者一个类的class对象上的我们知道,类的对象实例可以有很多个,但是每个类只有一个class对象,所以不同对象实例的对象锁是互不干扰的,但是每个类只有一个类锁。但是有一点必须注意的是,其实类锁只是一个概念上的东西,并不是真实存在的,它只是用来帮助我们理解锁定实例方法静态方法的区别的。

    类锁修饰方法和代码块的效果和对象锁是一样的,因为类锁只是一个抽象出来的概念,只是为了区别静态方法的特点,因为静态方法是所有对象实例共用的,所以对应着synchronized修饰的静态方法的锁也是唯一的,所以抽象出来个类锁。那么两个线程都需要获得该对象锁,另一个线程必须等待。

     讲了那么多,其实我们只需要明白一个道理:两个线程竞争同一把锁, 才会产生阻塞等待.

    两个线程分别尝试获取两把不同的锁, 不会产生竞争

    我们看一看我们上次因为线程安全出现问题的代码再加上锁后会发生什么?

    1. // 创建两个线程, 让这俩线程同时并发的对一个变量, 自增 5w 次. 最终预期能够一共自增 10w 次.
    2. class Counter {
    3. // 用来保存计数的变量
    4. public int count;
    5. public synchronized void increase(){
    6. count++;
    7. }
    8. }
    9. public class Demo {
    10. // 这个实例用来进行累加.
    11. // public static Counter counter = new Counter();
    12. public static void main(String[] args) {
    13. Counter counter = new Counter();
    14. Thread t1 = new Thread(() -> {
    15. for (int i = 0; i < 50000; i++) {
    16. counter.increase();
    17. }
    18. });
    19. Thread t2 = new Thread(() -> {
    20. for (int i = 0; i < 50000; i++) {
    21. counter.increase();
    22. }
    23. });
    24. t1.start();
    25. t2.start();
    26. try {
    27. t1.join();
    28. t2.join();
    29. } catch (InterruptedException e) {
    30. e.printStackTrace();
    31. }
    32. System.out.println("count: " + counter.count);
    33. }
    34. }

     

    可喜可贺,在加上锁后我们的答案终于变成我们想要的了。 

    1.3 Java 标准库中的线程安全类 

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

    ArrayList

    LinkedList

    HashMap

    TreeMap

    HashSet

    TreeSet

    StringBuilder

    但是还有一些是线程安全的. 使用了一些锁机制来控制.

    Vector (不推荐使用)

    HashTable (不推荐使用)

    ConcurrentHashMap

    StringBuffer 

    其中不推荐使用的原因是因为已经有了更好的替代品。

    注意:线程安全的代价是会导致效率降低,所以要根据实际情况选择 

    2. volatile 关键字

    volatile 能保证内存可见性

    还记得我们在上一篇文章讲到的内存可见性所引发的线程安全问题吗(链接在上面),这次我们就可以使用volatile关键字来解决这个问题了。

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

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

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

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

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

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

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

    1. import java.util.Scanner;
    2. public class Demo {
    3. static class Counter {
    4. public int flag = 0;
    5. }
    6. public static void main(String[] args) {
    7. Counter counter = new Counter();
    8. Thread t1 = new Thread(() -> {
    9. while (counter.flag == 0) {
    10. // do nothing
    11. }
    12. System.out.println("循环结束!");
    13. });
    14. Thread t2 = new Thread(() -> {
    15. Scanner scanner = new Scanner(System.in);
    16. System.out.println("输入一个整数:");
    17. counter.flag = scanner.nextInt();
    18. });
    19. t1.start();
    20. t2.start();
    21. }
    22. }

     我们这个代码想实现的效果是我们输入一个非零整数线程1就会结束,但实际上因为内存可见性问题我们即使输入了非零数线程1也不会停止运行。这是由于内存可见性引发的线程安全问题导致的

    可以看到线程没有停止,而我们在flag变量前加上volatile后,就能够达到我们想要的效果了。

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

    volatile 不保证原子性

    volatile 和 synchronized 有着本质的区别.

    synchronized 能够保证原子性

    volatile 保证的是内存可见性

    3. wait 和 notify 

    wait叫做等待,调用wait的线程会进入阻塞等待状态(WAITING)

    notify叫做通知,调用notify就可以把对应的wait线程唤醒,恢复到就绪状态

    wait 做的事情:

    使当前执行代码的线程进行等待. (把线程放到等待队列中)

    释放当前的锁 满足一定条件时被唤醒,

    重新尝试获取这个锁.

    注意: 由于我们在调用wait的时候会先释放锁,所以必须结合synchronized使用

    wait和sleep的区别

    1.wait 需要搭配 synchronized 使用. sleep 不需要.

    2.wait是Object方法,sleep是Thread的静态方法 

    然后我们这里简单提一下线程三种阻塞状态的区别:

    WAITING:必须要其他线程主动唤醒

    BLOCKED:其他线程将锁释放后,操作系统负责

    TIME_ WAITING:操作系统计时,时间到后唤醒

    然后由于我们wait在释放当前锁后,满足一定条件才会唤醒,否则会一直处于WAINTING的阻塞状态,所以利用这个特点,我们可以让线程wait来等待合适的时机(满足条件)再继续执行。 比如我们可以看到下面这个例子:

    1. public class Thread_2252 {
    2. // 计数器
    3. private static volatile int COUNTER=0;
    4. //用来当作锁的对象
    5. private static Object lock=new Object();
    6. public static void main(String[] args) {
    7. // 创建三个线程,并指定线程名,每个线程名分别用A,B,C表示
    8. Thread t1 = new Thread(() -> {
    9. // 循环10次
    10. for(int i=0;i<10;i++){
    11. synchronized (lock){
    12. // 每次唤醒后都重新判断是否满足条件
    13. while(COUNTER%3!=0){
    14. try {
    15. lock.wait();
    16. } catch (InterruptedException e) {
    17. e.printStackTrace();
    18. }
    19. }
    20. System.out.print(Thread.currentThread().getName());
    21. COUNTER++;//计数器自增
    22. lock.notifyAll();
    23. }
    24. }
    25. }, "A");
    26. Thread t2 = new Thread(() -> {
    27. for (int i = 0; i < 10; i++) {
    28. synchronized (lock) {
    29. while (COUNTER % 3 != 1) {
    30. try {
    31. //不满足条件则等待
    32. lock.wait();
    33. } catch (InterruptedException e) {
    34. e.printStackTrace();
    35. }
    36. }
    37. System.out.print(Thread.currentThread().getName());
    38. COUNTER++;
    39. lock.notifyAll();//唤醒其他线程
    40. }
    41. }
    42. }, "B");
    43. Thread t3 = new Thread(() -> {
    44. for (int i = 0; i < 10; i++) {
    45. synchronized (lock) {
    46. while (COUNTER % 3 != 2) {
    47. try {
    48. lock.wait();
    49. } catch (InterruptedException e) {
    50. e.printStackTrace();
    51. }
    52. }
    53. // 换行打印
    54. System.out.println(Thread.currentThread().getName());
    55. COUNTER++;
    56. lock.notifyAll();
    57. }
    58. }
    59. }, "C");
    60. // 启动线程
    61. t1.start();
    62. t2.start();
    63. t3.start();
    64. }
    65. }

    我们每次利用一个while循环和计数器来控制线程打印,这里我们要理解的是我们的synchronized每次将线程锁住以后,如果满足while的条件那么wait就会释放锁让这个线程进入WAITING,这样就可以让其他线程去执行操作,直到满足条件(其他满足条件线程不进入while)才唤醒其他线程,重复这样的操作就达到我们想要的效果。

    当然有些细心的同学应该注意到我们上面的代码使用到是notifyAll而不是notify方法,其实他们的差别只是唤醒一个与多个的区别。

    notify方法只是唤醒某一个等待线程. 使用notifyAll方法可以一次唤醒所有的等待线程. 

  • 相关阅读:
    进销存管理对于企业的意义
    vite进行打包时如何把某个静态文件原封不动地拷贝到打包后的文件中
    美汽车工会1.8万人罢工,汽车供应链受重创 | 百能云芯
    现代C++学习指南-类型系统
    虚拟人动作捕捉会成为虚拟人三维动画宣传片的主流吗?
    【JAVA程序设计】基于Springboot+Thymeleaf新闻管理系统
    使用在线的vscode打开github项目
    Ubuntu 20.04 系统最快安装WRF软件手册
    Java构件技术
    QT=> 父界面设置背景图,子界面不受影响解决方案
  • 原文地址:https://blog.csdn.net/weixin_60778429/article/details/125982881