Java并发编程:线程安全

开发 前端
通俗地说,无论有多少线程访问业务中的一个对象或方法,在编写这段业务逻辑时,无需做任何额外处理(即可以像单线程程序一样编写),程序也能正常运行(不会因多线程而失败),这样的代码就可以称为线程安全的。

1. 什么是线程安全?

《Java 并发编程实战》的作者 Brian Goetz 对线程安全的理解是:当多个线程访问一个对象时,如果不需要考虑这些线程在运行时环境中的调度和交替执行,也不需要额外的同步,调用这个对象的行为都能获得正确的结果,那么这个对象就是线程安全的。

通俗地说,无论有多少线程访问业务中的一个对象或方法,在编写这段业务逻辑时,无需做任何额外处理(即可以像单线程程序一样编写),程序也能正常运行(不会因多线程而失败),这样的代码就可以称为线程安全的。

2. 什么是线程不安全?

当多个线程同时访问一个对象时,如果某个线程正在更新对象的值,而另一个线程同时读取该对象的值,就可能导致获取到错误的值。这种情况下,我们需要采取额外措施(例如使用synchronized关键字同步这部分代码的执行)来确保结果的正确性。

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

主要是出于程序性能、设计复杂度成本等方面的考量。

4. 线程安全问题的分类

4.1 运行结果错误

首先来看多线程同时操作一个变量如何导致运行结果错误。

假设用两个线程对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);
    }
}

输出:

图片图片

理论上结果应为 20000,但实际输出远小于理论值,且每次结果不同。为什么会这样?

这是因为多线程下,CPU 的调度是以时间片为单位分配的,每个线程获得一定时间片后,若时间片耗尽会被挂起并让出 CPU 资源给其他线程,这可能导致线程安全问题。例如,i++看似一行代码,实际并非原子操作,其执行步骤主要分为三步,且每一步操作之间可能被中断:

  1. 读取当前值;
  2. 递增;
  3. 保存结果。

图片图片

假设线程 1 先读取count=1,随后执行count + 1操作,但此时结果尚未保存,线程 1 被切换。CPU 开始执行线程 2,其操作与线程 1 相同。但此时线程 2 读取的count值是多少?由于线程 1 的+1操作未保存结果,线程 2 读取的仍然是count=1。

假设线程 2 执行count + 1后保存结果为 2,随后线程 1 恢复执行,保存其计算结果为 2。虽然两个线程各执行了一次+1,但最终count结果为 2 而非预期的 3。这就是典型的线程安全问题,此时count变量被称为共享变量或共享数据。

如何解决?

解决此类问题需要一种机制:当多个线程操作共享变量时,确保同一时刻仅有一个线程能操作该变量,其他线程必须等待当前线程处理完成。这种方法使用互斥锁(Mutex Lock)实现互斥访问——当共享数据被当前线程加锁时,其他线程只能等待锁释放。

Java 中,用synchronized关键字修饰的方法或代码块可以保证同一时刻仅有一个线程执行。代码如下:

public class ResultErrorResolution {
    staticint 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),从而实现并发安全。

4.2 线程活跃性问题

第二类线程安全问题统称为活跃性问题。活跃性问题指程序无法获得运行的最终结果。相比前文的错误,活跃性问题的后果可能更严重,例如死锁会导致程序完全卡死。

典型的活跃性问题包括死锁(Deadlock)、活锁(Livelock)和饥饿(Starvation)。由于内容较多,后续会单独写篇文章介绍。

4.3 对象初始化时的安全问题

最后是对象初始化过程中引发的线程安全问题。创建对象以供其他类或对象使用是常见操作,但若时机或错误可能导致线程安全问题。

看一个例子:

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));
    }
}

此例中,成员变量students在构造函数的子线程中初始化。但主线程在初始化InitError后未等待子线程完成,直接尝试获取数据,导致问题:

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

运行结果:

Exception in thread "main" java.lang.NullPointerException
    at concurrency.chapter10.InitError.main(InitError.java:25)

原因:

students在构造函数的新线程中初始化,而主线程未等待该线程完成就直接调用getStudents(),此时students可能尚未初始化(返回null),导致空指针异常。

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

5.1 访问共享变量或资源

当访问静态变量、共享缓存等共享资源时,若多线程同时操作(如count++),需确保原子性。例如以下“检查后执行”操作可能被中断:

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

多个线程可能同时满足count == 10,导致多次执行count = count * 10,需通过加锁保证原子性。

5.2 数据间存在绑定关系

当不同数据成组出现且需保持对应关系时(如 IP 和端口号),若修改未绑定为一个原子操作,可能导致信息不一致。例如仅修改 IP 而未同步修改端口号,接收方可能获取错误的绑定结果。

5.3 依赖的类未声明线程安全

若使用的类未声明自身是线程安全的(如ArrayList),在多线程并发操作时可能引发线程安全问题。责任不在该类本身,因其未做任何线程安全保证(源码注释中通常会说明)。

责任编辑:武晓燕 来源: 程序猿技术充电站
相关推荐

2011-12-29 13:31:15

Java

2025-02-17 00:00:25

Java并发编程

2023-10-18 09:27:58

Java编程

2025-01-10 07:10:00

2025-02-06 03:14:38

2024-12-31 09:00:12

Java线程状态

2019-11-07 09:20:29

Java线程操作系统

2023-10-08 09:34:11

Java编程

2025-02-03 08:23:33

2022-03-31 07:52:01

Java多线程并发

2022-11-09 09:01:08

并发编程线程池

2023-10-18 15:19:56

2021-03-05 13:46:56

网络安全远程线程

2019-09-16 08:45:53

并发编程通信

2025-02-03 00:40:00

线程组Java并发编程

2017-01-10 13:39:57

Python线程池进程池

2023-09-26 10:30:57

Linux编程

2017-09-19 14:53:37

Java并发编程并发代码设计

2010-03-16 16:34:06

Java编程语言

2020-12-08 08:53:53

编程ThreadPoolE线程池
点赞
收藏

51CTO技术栈公众号