杰瑞科技汇

java static 线程安全

核心结论先行

static 本身不保证线程安全。

java static 线程安全-图1
(图片来源网络,侵删)

一个 static 变量或方法是否线程安全,完全取决于它的实现细节,而不是 static 这个修饰符。static 只是意味着这个成员(变量或方法)属于类,而不是类的某个实例。


static 变量的线程安全问题

static 变量(也称为类变量)在内存中只有一份拷贝,被所有该类的实例共享,正是这种“共享”的特性,使得它在多线程环境下成为潜在的线程安全风险。

1 线程不安全的 static 变量

当多个线程同时读写同一个可变的 static 变量时,如果不做任何同步处理,就会发生线程安全问题。

示例:非线程安全的计数器

java static 线程安全-图2
(图片来源网络,侵删)
public class UnsafeCounter {
    // static 变量,所有实例共享
    private static int count = 0;
    public void increment() {
        count++; // 这不是原子操作
    }
    public static int getCount() {
        return count;
    }
    public static void main(String[] args) throws InterruptedException {
        UnsafeCounter counter = new UnsafeCounter();
        // 创建1000个线程,每个线程都增加100次
        Runnable task = () -> {
            for (int i = 0; i < 100; i++) {
                counter.increment();
            }
        };
        List<Thread> threads = new ArrayList<>();
        for (int i = 0; i < 1000; i++) {
            threads.add(new Thread(task));
        }
        for (Thread t : threads) {
            t.start();
        }
        // 等待所有线程执行完毕
        for (Thread t : threads) {
            t.join();
        }
        // 预期结果是 1000 * 100 = 100000,但实际结果通常小于这个值
        System.out.println("Final count: " + UnsafeCounter.getCount());
    }
}

问题分析:

count++ 这个操作看起来是一行代码,但在 JVM 中,它至少包含三个步骤:

  1. 读取:从主内存读取 count 的值到线程的工作内存。
  2. 修改:在工作内存中将 count 的值加 1。
  3. 写入:将工作内存中修改后的值写回到主内存。

在多线程环境下,可能会发生这样的情况:

  • 线程 A 读取 count 值为 100。
  • 线程 B 也读取 count 值为 100。
  • 线程 A 将值加 1 变为 101,并写回主内存。
  • 线程 B 也将值加 1 变为 101,并写回主内存。

结果,count 从 100 增加到了 101,而不是预期的 102,这就是典型的竞态条件

java static 线程安全-图3
(图片来源网络,侵删)

2 线程安全的 static 变量

要使 static 变量变得线程安全,我们需要采用同步机制来保证其操作的原子性。

解决方案 1:使用 synchronized 关键字

public class SafeCounterSync {
    private static int count = 0;
    // 使用 synchronized 修饰方法,保证同一时间只有一个线程能执行此方法
    public synchronized void increment() {
        count++;
    }
    // 同理,get 方法也应该同步,或者使用 volatile(见下文)
    public synchronized static int getCount() {
        return count;
    }
}

synchronized 可以确保 increment() 方法内的代码是原子执行的,从而避免了竞态条件,但它的缺点是性能开销较大,且可能造成锁竞争。

解决方案 2:使用 java.util.concurrent.atomic

对于简单的计数器等场景,使用 AtomicInteger 等原子类是更高效、更推荐的方式。

import java.util.concurrent.atomic.AtomicInteger;
public class SafeCounterAtomic {
    // 使用 AtomicInteger 替代 int
    private static AtomicInteger count = new AtomicInteger(0);
    public void increment() {
        // AtomicInteger 的 incrementAndGet() 是原子操作
        count.incrementAndGet();
    }
    public static int getCount() {
        return count.get();
    }
}

AtomicInteger 底层使用 CAS(Compare-And-Swap)操作,它是一种无锁算法,在性能上通常优于 synchronized,特别是在高并发场景下。

解决方案 3:使用 volatile 关键字

volatile 关键字可以确保变量的可见性禁止指令重排序,但不能保证复合操作的原子性。

  • 可见性:当一个线程修改了一个 volatile 变量,新值会立刻同步到主内存,并且其他线程读取时会从主内存读取,保证了线程间的可见性。
  • 不保证原子性volatile 无法解决 count++ 这样的复合操作问题。

volatile 适用于一个线程写,多个线程读的场景,对于上面的计数器例子,volatile 是不够的。

// 错误用法:volatile 不能保证 count++ 的原子性
private static volatile int count = 0;
public void increment() {
    count++; // 仍然不是线程安全的!
}

static 方法的线程安全问题

static 方法的线程安全性与实例方法类似,关键在于方法内部访问和修改了哪些共享资源。

1 线程不安全的 static 方法

如果一个 static 方法内部修改了可变的 static 变量,那么它就是线程不安全的。

示例:

public class UnsafeStaticMethod {
    private static List<String> names = new ArrayList<>();
    // static 方法,但修改了 static 变量
    public static void addName(String name) {
        names.add(name);
    }
    public static void main(String[] args) {
        Runnable task = () -> {
            for (int i = 0; i < 1000; i++) {
                addName("Thread-" + Thread.currentThread().getId());
            }
        };
        // ... 创建并启动多个线程 ...
        // names 列表的大小可能会小于预期,因为 ArrayList 的 add 操作不是线程安全的
    }
}

2 线程安全的 static 方法

一个 static 方法是线程安全的,如果它满足以下任一条件:

  1. 不依赖任何共享的可变状态:方法内部只使用局部变量或参数,局部变量存储在各自线程的栈帧中,不在线程间共享,所以天然是线程安全的。

    public class MathUtils {
        // 这个方法是线程安全的,因为它只依赖于参数,没有修改任何共享状态
        public static int add(int a, int b) {
            return a + b;
        }
    }
  2. 所有对共享可变状态的访问都经过了同步:比如使用 synchronized 块或 synchronized 方法来保护共享资源。

    public class SafeStaticMethod {
        private static List<String> names = new ArrayList<>();
        // 使用 synchronized 修饰 static 方法,锁的对象是 SafeStaticMethod.class
        public static synchronized void addName(String name) {
            names.add(name);
        }
    }

特殊情况:static final 常量

static final 修饰的变量是常量,如果这个常量的值是一个不可变对象,那么它就是线程安全的。

  • 基本类型常量:如 public static final int MAX_AGE = 100;,这个值在编译时就确定了,并且不可更改,所以绝对线程安全。
  • 引用类型常量:如 public static final String APP_NAME = "MyApp";String 是不可变类,所以这个引用和它指向的对象都是安全的。

如果 static final 引用的是一个可变对象,那么它本身引用是安全的,但对象的内容是不安全的!

public class UnsafeConstant {
    // final 引用指向一个可变对象
    public static final List<String> NAMES = new ArrayList<>();
    public static void main(String[] args) {
        // 不能重新赋值 NAMES = new ArrayList<>(); // 编译错误
        // 但是可以修改 NAMES 指向的对象内容
        NAMES.add("Alice");   // 线程不安全!
        NAMES.remove("Alice"); // 线程不安全!
    }
}

为了解决这个问题,通常的做法是让 static final 引用的对象本身也是不可变的,或者使用线程安全的集合。

// 安全的做法:使用不可变集合
public static final List<String> NAMES = Collections.unmodifiableList(new ArrayList<>());
// NAMES.add("Alice"); // 现在会抛出 UnsupportedOperationException
// 或者使用线程安全的集合
public static final List<String> NAMES = new CopyOnWriteArrayList<>();

总结与最佳实践

场景 线程安全? 原因 解决方案
static 基本类型变量 多线程读写导致竞态条件 synchronizedAtomicInteger
static 引用类型变量 多线程读写或修改对象内容导致竞态条件 synchronizedConcurrentHashMapCopyOnWriteArrayList
static final 基本类型常量 值在编译时确定,且不可变 无需特殊处理
static final 引用类型常量 引用不可变,但指向的对象内容可能可变且被并发修改 使用不可变对象(String, Collections.unmodifiableXXX)或线程安全对象
无状态的 static 方法 不依赖或修改任何共享可变状态 无需特殊处理,这是最佳实践
有状态的 static 方法 依赖或修改了共享可变状态 对共享状态进行同步(synchronized)或使用并发工具类

核心思想:

  1. static 是一把双刃剑:它方便了共享数据和全局访问,但也引入了线程安全问题。
  2. 不变性是王道:尽量将 static 变量设计为 final 且不可变,这是解决线程安全问题最简单、最有效的方法。
  3. 最小化共享状态:如果必须共享,尽量让共享的范围尽可能小。
  4. 同步是最后的手段:当必须共享可变状态时,优先使用 java.util.concurrent 包中的并发工具(如 AtomicInteger, ConcurrentHashMap),它们比 synchronized 更高效、更易用,只在必要时才使用 synchronized
分享:
扫描分享到社交APP
上一篇
下一篇