Java并发编程实战 10 | 线程安全问题

什么是线程安全?

《Java并发实践》的作者 Brian Goetz 对线程安全的定义是:当多个线程访问同一个对象时,如果无需考虑这些线程在运行时的调度策略和交替执行顺序,也不需要进行额外的同步处理,仍然能够得到正确的结果,那么这个对象就是线程安全的。

简单来说,线程安全意味着:无论有多少线程同时访问业务中的某个对象或方法,都不需要做额外的处理(就像编写单线程程序一样),程序依然能够正常运行,不会因多线程的并发访问而出现错误。

什么是线程不安全?

当多个线程同时访问一个对象时,如果一个线程正在更新该对象的值,而另一个线程也在同时修改或读取这个对象的值,就有可能导致得到不正确的数据,这就是线程不安全。

这种情况下,为了确保结果的正确性,我们需要采取额外的措施,比如使用 synchronized 关键字对这部分代码进行同步,以确保同一时间只有一个线程能够访问或修改该对象。

为什么不是所有的程序都设计成线程安全的?

这主要出于程序性能、设计复杂度成本等考虑。

  • 性能开销: 设计和实现线程安全的代码通常需要额外的同步机制,如锁(synchronized)、显式锁(ReentrantLock)、或其他并发工具。这些同步机制虽然能确保线程安全,但也会引入性能开销,例如线程上下文切换、锁竞争和死锁的风险等。因此,在一些对性能要求极高的场景下,可能会选择避免过度同步,以提高程序的效率。

  • 复杂性增加: 使程序线程安全通常会增加代码的复杂性。线程安全的设计需要考虑多个线程之间的交互,避免竞争条件、死锁等问题,这会增加开发和维护的难度。在一些情况下,开发人员可能会选择简化设计,而不是一开始就实现线程安全的解决方案。

线程安全问题的分类

运行结果不正确

当多个线程同时操作一个共享变量时,可能会导致运行结果出现意料之外的错误。比如,假设我们有两个线程,每个线程都对一个count变量进行10000次递增操作。

public class ResultError {

    static int count;

    public static void main(String[] args) throws InterruptedException {

        Runnable runnable = () -> {
            for (int i = 0; i < 10000; i++) {
                count++;
            }
        };

        Thread thread1 = new Thread(runnable);
        Thread thread2 = new Thread(runnable);  
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(count);
    }
}
//输出情况1:
14088

理论上,最终count的值应该是20000(10000 + 10000),但实际输出的结果往往小于20000,而且每次运行的结果可能都不一样。这是为什么呢?

这是因为在多线程场景下,CPU 的调度是以时间片为单位进行分配的,每个线程可以获得一定量的时间片。但是如果线程所拥有的时间片耗尽,就会被挂起,将 CPU 资源让给其他线程,这样可能会产生线程安全问题。

例如,虽然 count++ 操作看起来是一行代码,但它实际上并不是一个原子操作。count++ 操作分为三个主要步骤:

  • 读取:读取 count 变量的当前值。
  • 增加:将读取的值加 1。
  • 保存:将增加后的值写回 count 变量。

那么让我们看看线程不安全是如何发生的。

让我们按顺序分析一下这个过程:

  1. 线程1 先读取 count 的值,假设当前 count = 1。

  2. 接着,线程1 计算 count + 1,但还没有将结果保存回 count。

  3. 此时,线程1 被挂起,CPU 开始执行 线程2。线程2 也读取了 count 的值,这时 count 仍然是 1,因为线程1 的更新还没有保存到 count 中。

  4. 线程2 进行和线程1 相同的操作:计算 count + 1。由于线程2 读取到的 count 仍然是 1,它也将计算结果加 1。

  5. 此时,线程2 被挂起,CPU执行线程1 ,线程1 将其计算结果(即 count + 1=2)写回 count,然后线程1 结束,线程2 重新被调度执行。

  6. 线程2 继续执行,将其计算结果(即 count + 1=2)写回 count,此时线程1 和线程2 都进行了相同的操作,覆盖了其中一次 count 的更新。

由于线程1 的更新结果在线程2 执行之前并没有被保存,这导致线程2 和线程1 都基于相同的旧值 count = 1 进行操作,最终的结果只反映了最后一个线程的更新。因此,count 的实际值小于理论值,这就是线程安全问题的根源。

如何解决?

为了解决这个问题,我们可能需要这样的解决方案:当有多个线程对共享变量进行操作时,需要保证同一时刻只有一个线程在操作这个变量,当一个线程正在执行的时候其他线程必须等待,直到当前线程处理完数据。

这种解决方案的一种实现方式是互斥锁(mutex),互斥锁是一种可以确保互斥访问的工具。也就是说,当一个线程获取了锁并访问共享数据时,其他线程必须等待,直到这个线程释放锁。

在Java中,我们可以通过 synchronized 关键字来实现这一点。synchronized 可以修饰方法或代码块,以保证在同一时刻只有一个线程能够执行被修饰的代码段。这样可以确保线程安全,避免多个线程同时操作共享变量时发生冲突。

示例代码如下:

public class ResultErrorResolution {

    static int count;

    public static void main(String[] args) throws InterruptedException {

        Runnable runnable = () -> {
            synchronized (ResultErrorResolution.class) {
                for (int i = 0; i < 10000; i++) {
                    count++;
                }
            }
        };

        Thread thread1 = new Thread(runnable);
        Thread thread2 = new Thread(runnable);
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(count);
    }
}
//输出:
20000

输出结果已经符合我们的预期。

关于synchronized关键字,我们将在后面的文章中详细介绍。目前你只需要了解它可以确保同一时刻最多只有一个线程可以执行这段代码(需要持有对应的锁,本例中为ResultErrorResolution.class),从而实现并发安全控制。

线程活跃性问题

第二类线程安全问题统称为活跃性问题。那么,什么是活跃性问题呢?

活跃性问题是指程序在运行时永远无法达到预期的最终结果。

活跃性问题最常见的三种类型是:死锁、活锁和饥饿。由于这些内容涉及的细节较多,后面会有单独的文章介绍这个主题。

这类问题的后果通常比其他类型的错误更加严重。例如,死锁可能导致程序完全停滞,无法继续执行。

对象发布和初始化期间的安全问题

最后,我们来讨论对象发布和初始化过程中可能引发的线程安全问题。创建对象并将其发布和初始化,以供其他类或对象使用,是一种常见的操作。然而,如果在不恰当的时机或错误的位置执行这些操作,就可能导致线程安全问题。

让我们看一个例子:

public class InitError {

    private Map<Long, String> students;

    public InitError() {
        new Thread(() -> {
            students = new HashMap<>();
            students.put(1L, "Tom");
            students.put(2L, "Bob");
            students.put(3L, "Victor");
        }).start();
    }

    public Map<Long, String> getStudents() {
        return students;
    }

    public static void main(String[] args) throws InterruptedException {
        InitError initError = new InitError();
        System.out.println(initError.getStudents().get(1L));
    }
}

在这个例子中,我们定义了一个Map类型的成员变量students,其中Map的key是学号,value是学生姓名。在构造函数中,我们启动了一个新的线程,并在该线程中将学生信息赋给students变量。

然而,问题在于新启动的线程只有在执行完run()方法中的所有操作后,students才算完全初始化完成。但在main函数中,初始化完InitError类之后,程序并没有等待线程完成,而是直接尝试获取1号学生的信息。

想象一下此时程序会发生什么情况?我们来看看结果:

可以看出程序会发生空指针异常。为什么会出现这种情况呢?

这是因为students成员变量是在构造函数中通过一个新建的线程进行初始化和赋值的,而线程的启动和执行需要一定的时间。如果main函数不等待线程完成初始化,直接尝试获取数据,就很可能会遇到getStudents方法返回null的情况。

当然,也有运行成功的可能,但概率不大,取决于线程调度的结果。

因此,确保对象在发布和初始化过程中的线程安全非常重要,不正确的操作可能导致其他线程在对象尚未完全初始化时就访问它,从而引发各种错误和不稳定性。

5.哪些场景需要特别注意线程安全问题?

1. 访问共享变量或资源

第一种场景是访问共享变量或共享资源,例如访问静态变量、共享缓存等。这些资源通常会被多个线程同时访问,从而可能导致线程安全问题。例如,我们之前提到的count++操作就是一个典型的案例。

另外,在对共享变量进行“检查并执行”操作时,也可能出现问题。由于这种操作不是原子的,可能会在执行过程中被中断,而在恢复执行时,之前检查的结果可能已经失效或过期。这样就会导致不一致的状态或错误的行为。

例如:

if (count == 10) {
    count = count * 10;
}

可能会有多个线程同时满足条件count == 10,因此会多次执行count = count * 10,导致结果不正确。在这种情况下,我们需要使用加锁等保护措施,以避免出现线程安全问题。

2. 不同数据之间存在绑定关系

第二种场景是当不同的数据之间存在关联时。某些情况下,不同的数据是成组出现的,它们相互对应或绑定,最典型的例子就是 IP 和端口号。例如,当我们更改 IP 时,通常需要同时更改端口号。如果这两个操作没有绑定在一起,就可能会出现只修改了 IP 或端口号之一的情况。

在这种情况下,如果信息已经被发布,接收方可能会获得一个错误的 IP 和端口号组合,导致线程安全问题。因此,为了避免这种情况,我们需要确保这些操作是原子的,即要么全部成功执行,要么全部失败。

3. 所依赖的类没有声明自身是线程安全的。

第三种场景是在使用其他类时,如果该类没有声明自己是线程安全的,那么在对其进行并发的多线程操作时,可能会引发线程安全问题。例如,ArrayList本身并不是线程安全的,如果多个线程同时访问和操作一个ArrayList实例,就可能导致数据错误或不一致。

需要注意的是,这种情况下问题的责任并不在于ArrayList,因为它本身并不保证线程安全,正如其源代码注释中所描述的那样。

  • 16
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值