【多线程】(二)线程安全问题与线程同步


一、多线程带来的风险

1.1 观察线程不安全

首先来看一段代码,这段代码的功能是创建两个线程,分别对同一个变量各自增 5w 次,预期的结果应该是 10w。

class Counter{
    public int cnt = 0;
}

public class Test1 {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.cnt++;
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.cnt++;
            }
        });

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println(counter.cnt);
    }
}

多运行几次这段代码,其结果如下:

71794 // 第一次
66446 // 第二次
66572 // 第三次
59366 // 第四次

发现,这几次运行的结果都小于 10 w,原因就是这段代码是多线程运行的,存在线程安全问题。因为多个线程同时对counter.cnt进行写操作的时候,就会发生竞争。具体来说,当线程t1t2同时执行counter.cnt++时,可能发生以下情况:

  1. 假如t1读取counter.cnt的值为10,然后增加到11。
  2. 由于t1还没有将11写回内存的,因此t2读取counter.cnt的值也为10,然后增加到11。
  3. t1将11写回counter.cnt
  4. t2将11写回counter.cnt

当发生这种情况的时候,就相当于少加了一次,因此当各自循环 5w 次的时候,就会少加很多次,所以最后的结果就会比预期的少很多了。

1.2 线程安全概念

简单来说,线程安全就是在多线程环境下,对共享资源的访问和操作能够正确、可靠地进行,不会产生不确定的或者与预期不符合的结果。

在多线程编程中,多个线程同时访问和修改共享数据时,可能会引发以下问题:

  1. 竞态条件(Race Condition):多个线程竞争执行同一段代码,导致执行结果依赖于线程执行的时序,而非代码本身的逻辑。这可能导致不可预期的结果。

  2. 数据竞争(Data Race):多个线程同时读写共享数据,至少有一个线程进行写操作,而且没有适当的同步机制来保护共享数据。这可能导致数据的不一致性或破坏数据完整性。

  3. 死锁(Deadlock):两个或多个线程相互等待对方持有的资源,导致所有线程都无法继续执行。

1.3 线程不安全的原因

1. 修改共享数据

例如在上述线程不安全的例子中,就涉及到两个线程对counter,cnt变量进行修改,此时的counter.cnt就是一个多个线程就能访问和修改的共享数据

  • 在多线程环境中,线程间共享的区域有堆区、方法区以及常量区。而上面的counter对象就存在于堆区,因此由多个线程所共享。
  • 当多个线程同时对共享数据进行读取和修改时,如果没有适当的同步机制保护共享数据,就会导致数据的不一致性。
  • 例如,在一个线程对共享数据进行修改时,另一个线程可能同时读取该数据,导致读取到了不一致的结果。

2. 原子性

什么是原子性:

  • 原子性就是指一个操作在执行过程中不可被中断,要么全部执行成功,要么全部不执行,不会出现执行一部分的情况。
  • 在多线程环境中,原子性是保证多个线程对共享数据进行操作时的一种特性。当一个操作具有原子性时,意味着它在执行过程中不会被其他线程干扰,保证了操作的完整性和一致性。
  • 原子性的特性可以通过不可分割性和独占性来概括。

在Java程序中,一条语句不一定是原子的,同时一条语句也不一定是一条指令,例如上面的counter.cnt++操作,其实是由三步操作组成:

  1. load:将内存中的cnt的值加载到CPU的寄存器当中;
  2. add:对CPU寄存器中的值进行自增操作;
  3. save:将CPU寄存器中运算的结果保存回内存。

因此,如果要保证计算结果的正确性,就必须要保证这三步操作是原子操作,如果不保证其是原子操作就会引发线程安全问题,比如:如果一个线程正在对一个变量操作,中途其他线程插队进来了,将这个操作打断了,最终结果就可能是错误的。

3. 可见性

可见性是指当一个线程对共享数据进行修改后,其他线程能够立即看到最新的修改结果。如果没有适当的同步机制,修改线程对数据的更新可能对其他线程是不可见的,导致其他线程继续使用过期的数据

Java 内存模型 (JMM): Java虚拟机规范中定义了Java内存模型。

  • 线程之间的共享变量存放在 主内存(Main Memory) 中。
  • 每一个线程都有自己的 工作内存(Working Memory)
  • 当线程要读取一个共享变量的时候,会先把变量从主内存中拷贝到自己的工作内存,再从工作内存中读取数据
  • 当线程要修改共享变量的时候,就会先修改自己工作内存中的副本,然后在同步回主内存

由于每个线程有自己的工作内存,这些工作内存中的内容相当于同一个共享变量的 “副本”。此时修改 线程1 的工作内存中的值,线程2 的工作内存不一定会及时变化。例如下面的情况:

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

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

3)如果 线程2 的值还没有同步的时候,在对 线程2 中的值进行修改操作,就会导致最终的结果与预期不符,也就引发的线程安全问题。

4. 代码顺序性

什么是代码重排序:
代码重排序是指在编译器或处理器优化执行的过程中,改变原始程序中语句的执行顺序,以提高性能或满足其他需求。代码重排序可以分为编译器重排序和处理器重排序两种类型。

比如,在执行构造函数的时候,需要执行的顺序如下:

  1. 在内存中为对象开辟一块空间;
  2. 为对象进行初始化操作;
  3. 返回对象的引用给变量。
  • 如果是在单线程情况下,JVM、CPU指令集会对其进行优化,比如,按 1 -> 3 -> 2 的方式执行,我们发现也是没有问题的。这种就叫做指令重排序。

  • 但是如果在多线程的环境中,比如是懒汉模式的单例模式,即单利对象在第一次被调用的时候初始化。此时,线程1 发现单例对象没有被创建,就按照 1 -> 3 -> 2 的顺序创建这个对象,此时当执行完步骤3的时候,因为某些原因都在被切换走了。

  • 此时 线程2 也要使用单例对象,发现该单例对象存在了(因为线程一中返回了该单例对象的引用),但是没有初始化,所有就会引发内存错误。

因此在有些多线程情况下,就需要禁止重排序问题。

1.4 线程安全的解决方法

实现线程安全的解决方法可以包括以下几种常见的技术和策略:

  1. 使用锁机制:使用互斥锁(Mutex Lock)或重入锁(Reentrant Lock)来保证同一时间只有一个线程可以访问共享资源。通过在关键代码块或方法上添加锁来确保互斥访问,防止多个线程同时修改共享数据。

  2. 使用原子操作:使用原子类(Atomic Classes)或volatile关键字来保证特定操作的原子性。原子操作是不可分割的,可以保证在多线程环境下对共享数据的修改是线程安全的。

  3. 使用线程安全的数据结构:使用线程安全的容器和数据结构,如ConcurrentHashMapCopyOnWriteArrayList等,它们内部实现了线程安全的操作机制,可以直接在多线程环境中使用。

  4. 使用并发工具类:使用并发工具类来协调和控制多个线程的执行,例如SemaphoreCountDownLatchCyclicBarrier等,它们提供了灵活的线程同步和线程间通信的方式。

  5. 使用不可变对象:设计不可变对象,确保对象的状态在创建后不能被修改,从而避免多线程环境下的并发问题。不可变对象不需要额外的同步机制,因为它们的状态是固定的,不会被多线程同时修改。

  6. 同步代码块或方法:使用synchronized关键字来对关键代码块或方法进行同步,确保同一时间只能有一个线程执行该代码块或方法,从而保证线程安全。

二、synchronized关键字

synchronized 是 Java 中用于实现线程同步的关键字。它可以用于方法或代码块上,用于保证在同一时间只有一个线程可以访问被标记为 synchronized 的代码。

2.1 synchronized 的特性

1)互斥
synchronized 关键字具有互斥的特性,确保在同一时间只有一个线程可以获取到锁,并执行被保护的代码块或方法。没有获取到锁的其他线程需要阻塞等待,直到持有锁的线程释放锁。这样可以避免多个线程同时访问共享资源而引发的数据竞争问题。

  • 进入 synchronized 修饰的代码块, 相当于 加锁
  • 退出 synchronized 修饰的代码块, 相当于 解锁


这里,synchronized用的锁是counter对象。

2)内存刷新

synchronized 的工作过程大致可以总结为如下情况:

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

当一个线程进入或退出一个 synchronized 块时,它会自动将对共享变量的修改刷新到主内存中,以保证共享变量的可见性。当其他线程获取锁时,它们会从主内存中读取最新的共享变量值,而不是使用线程自己的缓存值。这样可以确保线程之间对共享变量的读写操作具有一致的视图。

3)可重入

可重入锁:

  • 可重入锁(Reentrant Lock)是一种支持重入特性的锁机制。它允许线程在持有锁的情况下再次获取同一个锁,而不会被自己所拥有的锁所阻塞。
  • 可重入锁解决了在递归调用或嵌套代码中对同一个锁的重复获取的问题。当一个线程已经获得了可重入锁的锁对象时,它可以继续多次获取该锁,而不会因为自身已经持有锁而被阻塞。
  • 这样可以确保线程对共享资源的访问是安全的,同时提供了更高的灵活性和方便性。

synchronized 关键字支持可重入,即一个线程在已经持有锁的情况下,可以再次获取同一个锁而不会被自己所拥有的锁所阻塞。这种机制使得线程可以在同一个递归方法或代码块中进行嵌套调用,而不会出现死锁或阻塞的情况。

比如下面这段代码,重复加锁也不会造成阻塞,这就是锁的可重入。

synchronized (counter) {
    synchronized (counter) {
        counter.cnt++;
    }
}

可重入锁的实现原理:

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

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

2.2 synchronized 使用示例

例如文章开头哪个例子,此时我们为两个线程的counter.cnt++操作都加上synchronized关键字:

Thread t1 = new Thread(() -> {
    for (int i = 0; i < 50000; i++) {
        synchronized (counter) {
            counter.cnt++;
        }
    }
});

Thread t2 = new Thread(() -> {
    for (int i = 0; i < 50000; i++) {
        synchronized (counter) {
            counter.cnt++;
        }
    }
});

此时无论运行多少次,其结果都是 10 w 了。

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

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

ArrayList
LinkedList
HashMap
TreeMap
HashSet
TreeSet
StringBuilder

因此,在多线程中使用这些容器的时候就可能需要我们自己进行加锁,或者换用线程安全的容器类。

锁机制来实现线程安全的容器类有:

Vector
HashTable
ConcurrentHashMap
StringBuffer

其中,StringBuffer 的核心方法都带有 synchronized ,所有是线程安全的,另外String类也是线程安全的,虽然没有涉及到加锁操作,但是String类不涉及到修改,所有是线程安全的。

三、volatile关键字

volatile 是 Java 中的关键字,用于声明变量,表示该变量是易变的(volatile variable)。使用 volatile 关键字修饰的变量具有以下特性:

  1. 可见性(Visibility)

  2. 禁止指令重排序(Prevents Instruction Reordering)

在多线程编程中,volatile 关键字可以用于确保对共享变量的安全访问和可见性,但它并不能解决所有的线程安全问题。例如,它不能保证原子性操作和复合操作的线程安全性。对于这些情况,需要使用其他同步机制,如锁(synchronized)或原子类(Atomic classes)。

3.1 保证内存可见性

volatile 保证了变量的修改对所有线程可见。当一个线程修改了一个 volatile 变量的值时,这个修改将立即被写入主内存,并且其他线程在读取该变量时将从主内存中获取最新值,而不是使用线程的本地缓存值。这样可以确保所有线程对于该变量的读写操作具有一致的视图。

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

  • 改变线程工作内存中volatile变量副本的值;
  • 将改变后的副本的值从工作内存刷新到主内存。

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

  • 从主内存中读取volatile变量的最新值到线程的工作内存中;
  • 从工作内存中读取volatile变量的副本。

例如以下代码,展示了volatile关键字的重要性:

在这个代码中

  1. 创建两个线程 t1 和 t2;
  2. t1 中包含一个循环,这个循环以 flag == 0 为循环条件;
  3. t2 中从键盘读入一个整数,并把这个整数赋值给 flag;
  4. 预期当用户输入非 0 的值的时候,t1 线程结束;
static class Flag {
    public int flag = 0;
}

public static void main(String[] args) {
    Flag flag = new Flag();
    Thread t1 = new Thread(() -> {
        while (flag.flag == 0) {
            // do nothing
        }
        System.out.println("循环结束!");
    });
    Thread t2 = new Thread(() -> {
        Scanner scanner = new Scanner(System.in);
        System.out.println("输入一个整数:");
        flag.flag = scanner.nextInt();
    });
    t1.start();
    t2.start();
}

运行这段代码:

输入一个非 0 的值,发现程序并没有终止。因为 t1 读的是自己工作内存中的内容,当 t2 对 flag 变量进行修改,此时 t1 感知不到 flag 的变化。所有 t1 就会一直循环下去了。

如果给 flag 加上 volatile关键字:

volatile static class Flag {
    public int flag = 0;
}

此时,再次运行,就发现能够正常退出了。

3.2 禁止指令重排序

volatile 关键字禁止编译器和处理器对 volatile 变量相关的指令进行重排序。即使在编译器优化或处理器优化的过程中,保证 volatile 变量的读写操作按照代码的顺序执行。

3.2 不保证原子性

尽管 volatile 可以确保变量的可见性和禁止指令重排序,但它并不能保证复合操作的原子性。

如果多个线程同时对一个 volatile 变量进行读取和写入操作,每个线程的操作都是原子的,但是复合操作仍然可能存在竞态条件和数据竞争的问题。例如,对 volatile 变量的自增操作 count++ 并不是一个原子操作,它包括读取变量值、增加值、写回变量的三个步骤,因此在多线程环境中可能导致不一致的结果。

比如,在最开始的代码中,不加锁,只给cnt变量加上volatile关键字:


class Counter {
    volatile public int cnt = 0;
}

public class Test1 {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.cnt++;
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.cnt++;
            }
        });

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println(counter.cnt);
    }
}

此时可以看到,最终 cnt 的值仍然无法保证是 100000。

四、wait和notify方法

由于线程之间的执行是抢占式的,因此多个线程的执行顺序是不确定的。但是在实际开发过程中,往往都需要合理地协调多个线程,控制它们之间的执行顺序。

完成这些线程的协调工作就需要使用到 waitnotify方法:

  • wait:让当前线程进入等待状态。
  • notify:唤醒当前对象上等待的线程。

注意,waitnotify 都是 Object 类中的方法。

4.1 wait方法

在Java中,有三个wait方法:

方法说明
wait()使当前线程进入等待状态,直到被其他线程唤醒。
wait(long timeout)使当前线程进入等待状态,直到被其他线程唤醒或等待超时。
wait(long timeout, int nanos)使当前线程进入等待状态,直到被其他线程唤醒、等待超时或指定的纳秒数。

wait 做的事情:

  • 使当前执行代码的线程进行等待,即把这个线程放到等待队列中;
  • 释放当前的锁;
  • 满足一定条件时被唤醒,重新尝试获取这个锁。

注意,wait 要搭配 synchronized 来使用,脱离 synchronized 使用 wait 会直接抛出异常。因为wait做的一件事之一就是要释放锁,如果没有锁怎么能释放呢。

wait 结束等待的条件:

  • 其他线程调用该对象的 notify 方法;
  • wait 等待时间超时 (使用带有 timeout 参数的wait版本,来指定等待时间);
  • 其他线程调用该等待线程的 interrupted 方法,导致 wait 抛出 InterruptedException 异常。

代码示例:观察wait()方法使用

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

执行这段代码,就会发现结果是一直处于“等待中”,因为没有线程去调用notify去唤醒它。

4.2 notify方法

在Java中,有两个notify方法:

方法说明
notify()随机选择其中一个等待线程进行唤醒。
notifyAll()唤醒在当前对象上等待的所有线程。

notify 方法是唤醒等待的线程:

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

代码示例: 使用notify()方法唤醒线程

  • 创建线程 t1,调用wait方法;
  • 创建线程 t2,调用 notify方法唤醒 t1 线程;
  • 注意,需要先让 t1 线程执行了wait方法,才让 t2 线程执行 notify 方法,因为如果notify先执行,那么后面的wait方法就无法唤醒了。
    public static void main(String[] args) throws InterruptedException {

        Object locker = new Object();

        Thread t1 = new Thread(() -> {
            System.out.println("等待中");
            try {
                synchronized (locker) {
                    locker.wait();
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("等待结束");
        });

        Thread t2 = new Thread(() -> {
            synchronized (locker){
                locker.notify();
            }
        });

        t1.start();
        Thread.sleep(1000);
        t2.start();
    }

4.3 wait 和 sleep 的对比

waitsleep 的区别可以总结如下:

  • wait() 方法和 sleep() 方法都可以使线程暂停执行,但它们的使用场景和效果不同。
  • wait() 方法用于线程间的协作和通信,一般与 notify()notifyAll() 方法一起使用,用于线程的等待和唤醒。
  • sleep() 方法用于线程的暂时休眠,不涉及线程间的通信。调用 sleep() 方法会暂停当前线程的执行,并让出 CPU 时间给其他线程。
  • wait() 方法会释放当前对象的锁,而 sleep() 方法不会释放锁。
  • wait() 方法必须在同步代码块或同步方法中调用,而 sleep() 方法可以在任何地方调用。
  • wait() 方法需要被其他线程通过 notify()notifyAll() 方法唤醒,而 sleep() 方法在指定的时间后会自动恢复执行。
  • wait() 方法可以指定等待的时间,而 sleep() 方法必须指定休眠的时间。
  • 在异常处理上,wait() 方法需要捕获并处理 InterruptedException 异常,而 sleep() 方法不需要捕获此异常(因为 sleep() 方法本身就会抛出 InterruptedException 异常)。

综上所述,wait() 方法和 sleep() 方法在使用方式和效果上有所区别,wait() 方法主要用于线程间的协作和通信,而 sleep() 方法主要用于线程的暂时休眠。

  • 7
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 4
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

求知.

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

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

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

打赏作者

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

抵扣说明:

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

余额充值