什么是线程安全?如何实现?

5,594 阅读11分钟

翻译自:www.baeldung.com/java-thread…

1.概述

Java支持多线程。这意味着,通过多线程并发运行字节码,JVM能够提高应用程序的性能。

尽管多线程是一个功能强大的特性,但它也是有代价的。在多线程环境中,我们需要以线程安全的方式编写实现。

这意味着不同的线程可以访问相同的资源,而不会暴露出错误的行为或产生不可预知的结果。

这种编程方法被称为“线程安全”。

在本教程中,我们将研究实现这一目标的不同方法。

2. 无状态实现

在大多数情况下,多线程应用程序中的错误是由于多个线程之间共享状态不正确造成的。因此,我们要研究的第一种方法是使用无状态实现线程安全。

为了更好地理解这种方法,让我们考虑一个简单的实用程序类,它有一个计算数字阶乘的静态方法:

public class MathUtils {
     
    public static BigInteger factorial(int number) {
        BigInteger f = new BigInteger("1");
        for (int i = 2; i <= number; i++) {
            f = f.multiply(BigInteger.valueOf(i));
        }
        return f;
    }
}

factorial()方法是无状态的确定性函数。给定一个特定的输入,它总是产生相同的输出。

该方法既不依赖于外部状态,也不维护状态。因此,它被认为是线程安全的,可以被多个线程同时安全地调用。 所有的线程都可以安全地调用 factorial()方法,并且不相互干扰,任何线程都能获得预期的结果。

因此,无状态实现是实现线程安全的最简单方法

3. 不可变实现

如果我们需要在不同的线程之间共享状态,我们可以通过使它们不可变来创建线程安全的类。

不变性是一个强大的、与语言无关的概念,它在 Java 中相当容易实现。 简单地说,当一个类实例的内部状态在构造之后不能被修改时,它就是不可变的

在 Java 中创建不可变类最简单的方法是声明所有字段 private 和 final,不提供 setter:

public class MessageService {
     
    private final String message;
 
    public MessageService(String message) {
        this.message = message;
    }
     
    // standard getter
     
}

Messageservice 对象实际上是不可变的,因为它的状态在构造后不能更改。 因此,它是线程安全的。

此外,如果MessageService实际上是可变的,但是多个线程只能对其进行只读访问,那么它也是线程安全的。

因此,不变性是实现线程安全的另一种方式

4. 线程本地变量

在面向对象程序设计中,对象实际上需要通过字段维护状态,并通过一个或多个方法实现行为。

如果我们实际上需要维护状态,我们可以创建线程安全的类,通过使它们的字段线程本地化,这样这些类就不会在线程之间共享状态

我们可以通过简单地在 Thread类中定义私有字段来轻松地创建字段是线程本地的类。

例如,我们可以定义一个 Thread 类来存储一个整数数组:

public class ThreadA extends Thread {
     
    private final List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);
     
    @Override
    public void run() {
        numbers.forEach(System.out::println);
    }
}

而另一个可能包含一个字符串数组:

public class ThreadB extends Thread {
     
    private final List<String> letters = Arrays.asList("a", "b", "c", "d", "e", "f");
     
    @Override
    public void run() {
        letters.forEach(System.out::println);
    }
}

在这两个实现中,类都有自己的状态,但不与其他线程共享。 因此,类是线程安全的。

类似地,我们可以通过将 ThreadLocal实例分配给字段来创建线程本地字段。

让我们考虑一下,例如,下面的 StateHolder 类:

public class StateHolder {
     
    private final String state;
 
    // standard constructors / getter
}

我们可以很容易地使它成为一个线程本地变量,如下所示:

public class ThreadState {
     
    public static final ThreadLocal<StateHolder> statePerThread = new ThreadLocal<StateHolder>() {
         
        @Override
        protected StateHolder initialValue() {
            return new StateHolder("active");  
        }
    };
 
    public static StateHolder getState() {
        return statePerThread.get();
    }
}

线程本地字段非常类似于普通类字段,除了每个线程通过 setter / getter 访问它们独立初始化的字段副本,这样每个线程都有自己的状态。

5. 同步集合

通过使用集合框架中包含的一组同步包装器,我们可以轻松地创建线程安全的集合。

例如,我们可以使用这些同步包装器之一来创建线程安全的集合:

Collection<Integer> syncCollection = Collections.synchronizedCollection(new ArrayList<>());
Thread thread1 = new Thread(() -> syncCollection.addAll(Arrays.asList(1, 2, 3, 4, 5, 6)));
Thread thread2 = new Thread(() -> syncCollection.addAll(Arrays.asList(7, 8, 9, 10, 11, 12)));
thread1.start();
thread2.start();

让我们记住,同步集合在每个方法中使用内部锁定(我们将在后面讨论内部锁定)。 这意味着方法在某一时刻只能被一个线程访问,而其他线程将被阻塞,直到该方法被第一个线程解锁。 因此,由于同步访问的底层逻辑,同步的性能会受到影响。

6. 并发集合

除了同步集合,我们还可以使用并发集合来创建线程安全的集合。

Java提供了Java.util.concurrent 包,它包含几个并发集合,比如 ConcurrentHashMap:

Map<String,String> concurrentMap = new ConcurrentHashMap<>();
concurrentMap.put("1", "one");
concurrentMap.put("2", "two");
concurrentMap.put("3", "three");

与同步集合不同,并发集合通过将数据划分为段来实现线程安全性。 例如,在 ConcurrentHashMap中,多个线程可以获取不同段上的锁,因此多个线程可以同时访问。

由于并发线程访问的固有优势,并发集合比同步集合具有更高的性能。 值得一提的是,同步和并发集合只使集合本身成为线程安全的,而不是内容

7. 原子对象

还可以使用 Java 提供的一组原子类(包括 AtomicInteger、 AtomicLong、 AtomicBoolean 和 AtomicReference)实现线程安全。

原子类允许我们在不使用同步的情况下执行线程安全的原子操作。 原子操作在单个机器级别的操作中执行。 为了理解这个问题,让我们看看下面的 Counter 类:

public class Counter {
     
    private int counter = 0;
     
    public void incrementCounter() {
        counter += 1;
    }
     
    public int getCounter() {
        return counter;
    }
}

假设在竞争条件下,两个线程同时访问 incrementCounter()方法。 理论上,计数器字段的最终值是2。但是我们只是不能确定结果,因为线程在同一时间执行相同的代码块,并且递增不是原子的。

让我们使用 AtomicInteger 对象来创建 Counter 类的线程安全实现:

public class AtomicCounter {
     
    private final AtomicInteger counter = new AtomicInteger();
     
    public void incrementCounter() {
        counter.incrementAndGet();
    }
     
    public int getCounter() {
        return counter.get();
    }
}

这是线程安全的,因为虽然 incrementation, ++, 需要多个操作,但 incrementAndGet 是原子的。

8. 同步方法

虽然早期的方法非常适合集合和原语,但是我们有时需要更多的控制。

因此,我们可以用来实现线程安全的另一种常见方法是实现同步方法。

简单地说,一次只有一个线程可以访问同步方法,同时阻止其他线程访问该方法。 其他线程将保持阻塞状态,直到第一个线程完成或该方法抛出异常。 我们可以用另一种方式创建一个线程安全的 incrementCounter ()版本,方法是使它成为一个同步方法:

public synchronized void incrementCounter() {
    counter += 1;
}

通过在方法签名前加上 synchronized 关键字,我们创建了一个 synchronized 方法。 因为一次一个线程可以访问一个同步方法,所以一个线程将执行 incrementCounter ()方法,而其他线程也将执行同样的操作。 不会发生任何重叠的执行。

同步方法依赖于“内部锁”或“监视锁”的使用。内部锁是与特定类实例关联的隐式内部实体。 当线程调用同步方法时,它将获得内部锁。在线程完成方法的执行之后,它释放锁,从而允许其他线程获取锁并访问该方法。

我们可以在实例方法、静态方法和语句(同步语句)中实现同步。

9. 同步语句

有时候,如果我们只需要使方法的一个部分是线程安全的,那么整个方法的同步可能就不需要了。

为了举例说明这个用例,让我们重构 incrementCounter ()方法:

public void incrementCounter() {
    // additional unsynced operations
    synchronized(this) {
        counter += 1; 
    }
}

这个例子很简单,但是它展示了如何创建一个同步语句。

假设该方法现在执行一些不需要同步的附加操作,那么我们只需将相关的状态修改部分包装在一个 synchronized 块中即可实现同步。与 synchronized 方法不同,synchronized 语句必须指定提供内部锁的对象,通常是 this 引用。

同步开销很大,因此有时只需同步相关部分。

10. Volatile

对于解决线程之间的可变可见性问题,同步的方法和块非常方便。 即便如此,常规类字段的值也可以由 CPU 缓存。 因此,对特定字段的后续更新,即使它们是同步的,也可能对其他线程不可见。

为了避免这种情况,我们可以使用 volatile:

public class Counter {
 
    private volatile int counter;
 
    // standard constructors / getter
     
}

使用 volatile 关键字,我们指示 JVM 和编译器将计数器变量存储在主内存中。 这样,我们可以确保每次 JVM 读取计数器变量的值时,它实际上将从主内存而不是 CPU 缓存中读取它。 同样,每次 JVM 写入计数器变量时,值都将写入主内存。

此外,Volatile变量的使用确保了给定线程可见的所有变量也将从主存中读取。 让我们考虑下面的例子:

public class User {
 
    private String name;
    private volatile int age;
 
    // standard constructors / getters
     
}

在这种情况下,每次 JVM 将年龄 这个Volatile变量写入主内存时,也会将非Volatile变量name写入主内存。 这保证了这两个变量的最新值都存储在主内存中,因此对变量的后续更新将自动对其他线程可见。 类似地,如果一个线程读取一个 Volatile变量的值,线程可见的所有变量也将从主存中读取。 Volatile 变量提供的这种扩展保证称为完全 volatile 可见性保证

11. 外部锁定

我们可以通过使用外部监视器锁而不是内部监视器来稍微改进 Counter 类的线程安全实现。

外部锁还提供对多线程环境中共享资源的协调访问,但它使用外部实体来强制对资源的独占访问:

public class ExtrinsicLockCounter {
 
    private int counter = 0;
    private final Object lock = new Object();
     
    public void incrementCounter() {
        synchronized(lock) {
            counter += 1;
        }
    }
     
    // standard getter
     
}

我们使用一个普通的 Object 实例来创建外部锁。 这种实现稍微好一点,因为它在锁级别上提高了安全性。

使用内部锁定,同步方法和同步块依赖于这个引用,攻击者可能会通过获取内部锁并触发分布式拒绝服务攻击状态(DoS)条件而导致死锁。

不像与之相对的内部锁,外在锁使用一个私有实体,这是不能从外部访问的。 这使得攻击者更难获得锁来导致死锁。

12. Reentrant Locks

Java 提供了一组改进的 Lock 实现,其行为比上面讨论的内部锁稍微复杂一些。

对于内部锁,锁获取模型相当严格: 一个线程获取锁,然后执行一个方法或代码块,最后释放锁,这样其他线程就可以获取并访问该方法。

没有检查排队的线程和优先访问最长等待线程的底层机制。

Reentrantlock 实例正好允许我们这样做,因此可以防止排队的线程遭受某些类型的资源衰竭:

public class ReentrantLockCounter {
 
    private int counter;
    private final ReentrantLock reLock = new ReentrantLock(true);
     
    public void incrementCounter() {
        reLock.lock();
        try {
            counter += 1;
        } finally {
            reLock.unlock();
        }
    }
     
    // standard constructors / getter
     
}

Reentrantlock 构造函数接受一个可选的布尔参数。 当参数设置为 true 时,并且多个线程试图获取锁时,JVM 将优先考虑等待时间最长的线程,并授予对锁的访问权。

13. 读 / 写锁

我们可以用来实现线程安全的另一个强大的机制是 ReadWriteLock 实现的使用。

Readwritelock 锁实际上使用一对相关联的锁,一个用于只读操作,另一个用于写操作。

因此,可以实现只要没有线程写入资源,就可以有许多线程读取该资源,否则将阻止其他线程读取该资源

我们可以使用如下的 ReadWriteLock 锁:

public class ReentrantReadWriteLockCounter {
     
    private int counter;
    private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
    private final Lock readLock = rwLock.readLock();
    private final Lock writeLock = rwLock.writeLock();
     
    public void incrementCounter() {
        writeLock.lock();
        try {
            counter += 1;
        } finally {
            writeLock.unlock();
        }
    }
     
    public int getCounter() {
        readLock.lock();
        try {
            return counter;
        } finally {
            readLock.unlock();
        }
    }
 
   // standard constructors
    
}

14. 结论

在本文中,我们了解了 Java 中的线程安全,并深入研究了实现它的不同方法。