欢迎您访问 最编程 本站为您分享编程语言代码,编程技术文章!
您现在的位置是: 首页

[Java 并发编程 II] 多线程安全问题解决方案

最编程 2024-10-14 08:22:46
...

目录

 

多线程安全

1.synchronized关键字

 synchronized的使用特性

synchronized的使用示例

集合在多线程下的使用

2.volatile关键字

synchronized 也能保证内存可见性

3.wait和notify方法

wait()方法

notify()方法

notifyAll()方法

wait和sleep方法的对比


 

多线程安全

       当我们使用Thread的start方法启动了多个线程之后,这些线程在CPU上的执行先后顺序是不确定的。因为在操作系统内核中,有一个“调度器”模块,这个模块的实现方式是一种类似于“随机调度”的效果。随机调度即抢占式执行,即:

  1. 一个线程,什么时候被调度到cpu上执行,时机是不确定的。
  2. 一个线程,什么时候从cpu上下来,给别人让位,实际也是不确定的。

3c2f915bc6634ce9b643e98e1a74fcf1.png

       我们使用多线程就好比一群滑稽老铁坐在同一张桌子前吃鸡,此时1号滑稽和2号滑稽同时看上了同一只鸡,1号滑稽碰到了鸡翅,2号滑稽碰到了鸡腿, 结果整只鸡被1号滑稽抢走了,此时二号滑稽就会非常生气(出现异常),可能会直接掀桌子(使整个进程被终止)。

线程安全的概念:如果多线程环境下代码运行的结果是符合我们预期的,那么就说这个程序是线程安全的。

指令重排序:指令重排序是编译器和处理器为了优化代码执行效率而进行的一种手段。它可以改变代码中指令的执行顺序,尽管指令重排序可以改善程序的执行效率,但在多线程环境下,指令重排序可能会导致线程安全问题。

线程安全问题:一原子性问题,多个线程修改同一个变量不安全的原因主要是因为修改变量的操作不是原子性的。二可见性问题,一个线程对共享变量的修改可能不会立即被其他线程所看到,导致线程间的数据不一致性。

解决原子性问题我们主要采用加锁(synchronized):对每个滑稽老铁都进行加锁,钥匙只有一把,只能一个人先吃鸡,吃完在给另一个人解锁然后吃鸡。

解决可见性问题我们主要采用给变量添加volatile关键字,下面文章中将会详细介绍它的工作原理。

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

JMM内存结构主要如下所示:

c24fc0ad4a404c14aec2266103b0383d.png

 上述图片中的主内存指的就是内存,而工作内存是CPU寄存器或者CPU的(L1,L2,L3)三级缓存。Java引入JMM这一概念主要是为了解决跨平台的问题,不同的系统,CPU中的寄存器和缓存的状况不同,所以Java用抽象概念工作内存和主内存来表示了。

1.synchronized关键字

这里用synchronized解决一下上篇文章中提到的n++的问题。

static class Counter {
    public int count = 0;
    synchronized 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);
}

 synchronized的使用特性

 synchronized的使用特性有:1.互斥,2.刷新内存,3.可重入。

互斥:
synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到同一个对象 synchronized 就会 阻塞等待。
  • 进入 synchronized 修饰的代码块, 相当于 加锁
  • 退出 synchronized 修饰的代码块, 相当于 解锁

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

刷新内存:
synchronized 的工作过程:
  1. 获得互斥锁
  2. 从主内存拷贝变量的最新副本到工作的内存
  3. 执行代码
  4. 将更改后的共享变量的值刷新到主内存
  5. 释放互斥锁

 可重入:

synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题;即一个线程没有释放锁, 然后又尝试再次加锁。

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

正常来说,按照锁的特性这个时候会出现死锁的情况,因为只有这个线程结束才能释放锁,而第二次加锁会使线程一直堵塞,无法结束,第二个锁无法得到锁对象永远无法解锁,陷入死循环。 

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

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

 

  • 如果某个线程加锁的时候, 发现锁已经被人占用, 但是恰好占用的正是自己, 那么仍然可以继续获取到锁, 并让计数器自增。
  • 解锁的时候计数器递减为 0 的时候, 才真正释放锁。(才能被别的线程获取到)

synchronized的使用示例

synchronized 本质上要修改指定对象的 "对象头". 从使用角度来看, synchronized 也势必要搭配一个具体的对象来使用。 我们重点要理解, synchronized 锁的是什么。两个线程竞争同一把锁, 才会产生阻塞等待。
1. 直接修饰普通方法:锁的 SynchronizedDemo 对象
public class SynchronizedDemo {
    public synchronized void methond() {
   }
}

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

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

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

//锁当前对象
public class SynchronizedDemo {
    public void method() {
        synchronized (this) {
            
       }
   }
}
//锁类对象
public class SynchronizedDemo {
    public void method() {
        synchronized (SynchronizedDemo.class) {
       }
   }
}

集合在多线程下的使用

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

有一些类使用了锁机制去控制,是线程安全的。

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

516cd6f7fda7468c98f524df1d0d81ea.png

 StringBuffer 的核心方法都带有 synchronized。

2.volatile关键字

volatile的核心功能是保证内存的可见性(还有另一个功能,禁止指令重排序此处不详细展开说明)。volatile主要与Java的JMM模型有关:c24fc0ad4a404c14aec2266103b0383d.png

代码在写入 volatile 修饰的变量的时候,
  • 改变线程工作内存中volatile变量副本的值
  • 将改变后的副本的值从工作内存刷新到主内存
代码在读取 volatile 修饰的变量的时候,
  • 从主内存中读取volatile变量的最新值到线程的工作内存中
  • 从工作内存中读取volatile变量的副本
前面我们讨论JMM模型时说了, 直接访问工作内存(实际是 CPU 的寄存器或者 CPU 的缓存), 速度 非常快, 但是可能出现数据不一致的情况。
加上 volatile , 强制读写内存. 速度是慢了, 但是数据变的更准确了。

代码实例: 

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 线程循环能够立即结束.
volatile 和 synchronized 有着本质的区别. synchronized 能够保证原子性, volatile 保证的是内存可见性。

synchronized 也能保证内存可见性

需要注意的是,恰当的使用synchronized也能保证内存可见性。
对上面的代码进行调整:
  • 去掉 flag 的 volatile
  • 给 t1 的循环内部加上 synchronized, 并借助 counter 对象加锁.
static class Counter {
    public int flag = 0;
}
public static void main(String[] args) {
    Counter counter = new Counter();
    Thread t1 = new Thread(() -> {
        while (true) {
            synchronized (counter) {
                if (counter.flag != 0) {
                    break;
               }
           }
            // 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();
}

3.wait和notify方法

由于线程之间是抢占式执行的, 因此线程之间执行的先后顺序难以预知。但是实际开发中有时候我们希望合理的协调多个线程之间的执行先后顺序。
完成这个协调工作, 主要涉及到以下三个方法:
  • wait() / wait(long timeout): 让当前线程进入等待状态.
  • notify() / notifyAll(): 唤醒在当前对象上等待的线程.

wait, notify, notifyAll 都是 Object 类的方法.  

wait()方法

wait 做的事情:
  • 使当前执行代码的线程进行等待. (把线程放到等待队列中)
  • 释放当前的锁
  • 满足一定条件时被唤醒, 重新尝试获取这个锁

 wait 要搭配 synchronized 来使用. 脱离 synchronized 使用 wait 会直接抛出异常.

wait 结束等待的条件:
  • 其他线程调用该对象的 notify 方法.
  • wait 等待时间超时 (wait 方法提供一个带有 timeout 参数的版本, 来指定等待时间).
  • 其他线程调用该等待线程的 interrupted 方法, 导致 wait 抛出 InterruptedException 异常.

 代码实例

public static void main(String[] args) throws InterruptedException {
    Object object = new Object();
    synchronized (object) {
        System.out.println("等待中");
        object.wait();
        System.out.println("等待结束");
   }
}

这样在执行到object.wait()之后就一直等待下去,那么程序肯定不能一直这么等待下去了。这个时候就需要使用到了另外一个方法唤醒的方法notify()。

notify()方法

notify 方法是唤醒等待的线程.
  • 方法notify()也要在同步方法或同步块中调用,该方法是用来通知那些可能等待该对象的对象锁的其它线程,对其发出通知notify,并使它们重新获取该对象的对象锁。
  • 如果有多个线程等待,则有线程调度器随机挑选出一个呈 wait 状态的线程。(并没有 "先来后到")
  • 在notify()方法后,当前线程不会马上释放该对象锁,要等到执行notify()方法的线程将程序执行完,也就是退出同步代码块之后才会释放对象锁。  

代码示例: 

  • 创建 WaitTask 类, 对应一个线程, run 内部循环调用 wait.
  • 创建 NotifyTask 类, 对应另一个线程, 在 run 内部调用一次 notify
  • 注意, WaitTask 和 NotifyTask 内部持有同一个 Object locker. WaitTask 和 NotifyTask 要想配合就需要搭配同一个 Object.
static class WaitTask implements Runnable {
    private Object locker;
    public WaitTask(Object locker) {
        this.locker = locker;   
}
    @Override
    public void run() {
        synchronized (locker) {
            while (true) {
                try {
                    System.out.println("wait 开始");
                    locker.wait();
                    System.out.println("wait 结束");
               } catch (InterruptedException e) {
                    e.printStackTrace();
               }
           }
       }
   }
}
static class NotifyTask implements Runnable {
    private Object locker;
    public NotifyTask(Object locker) {
        this.locker = locker;
   }
    @Override
    public void run() {
        synchronized (locker) {
            System.out.println("notify 开始");
            locker.notify();
            System.out.println("notify 结束");
       }
   }
}
public static void main(String[] args) throws InterruptedException {
    Object locker = new Object();
    Thread t1 = new Thread(new WaitTask(locker));
    Thread t2 = new Thread(new NotifyTask(locker));
    t1.start();
    Thread.sleep(1000);
    t2.start();
}

notifyAll()方法

notify方法只是唤醒某一个等待线程. 使用notifyAll方法可以一次唤醒所有的等待线程。
 
代码示例:在上述代码基础上进行修改
//创建 3 个 WaitTask 实例. 1 个 NotifyTask 实例.
static class WaitTask implements Runnable {
 // 代码不变
}
static class NotifyTask implements Runnable {
 // 代码不变
}
public static void main(String[] args) throws InterruptedException {
    Object locker = new Object();
    Thread t1 = new Thread(new WaitTask(locker));
    Thread t3 = new Thread(new WaitTask(locker));
    Thread t4 = new Thread(new WaitTask(locker));
    Thread t2 = new Thread(new NotifyTask(locker));
    t1.start();
    t3.start();
    t4.start();
    Thread.sleep(1000);
    t2.start();
}

//修改 NotifyTask 中的 run 方法, 把 notify 替换成 notifyAll
public void run() {
    synchronized (locker) {
        System.out.println("notify 开始");
        locker.notifyAll();
        System.out.println("notify 结束");
   }
}
注意 : 虽然是同时唤醒 3 个线程, 但是这 3 个线程需要竞争锁。所以并不是同时执行, 而仍然是有先有后的执行。

wait和sleep方法的对比

  1.  wait 和 sleep一个是用于线程之间的通信的,一个是让线程阻塞一段时间,唯一的相同点就是都可以让线程放弃执行一段时间。
  2. wait 需要搭配 synchronized 使用. sleep 不需要.
  3. wait 是 Object 的方法 sleep 是 Thread 的静态方法.

❤️????????????????????????????????????????????????????????????????????

????我是小皮侠,谢谢大家都能看到这里!!

????主页已更新Java基础内容,数据结构基础,数据库,算法,Java并发编程,Redis相关内容。

????未来会更新Java项目,SpringBoot,docker,mq,微服务以及各种Java路线会用到的技术。

????求点赞!求收藏!求评论!求关注!

????‍♀️谢谢大家!!!!!!!!

 

推荐阅读