admin管理员组

文章数量:1441159

什么是线程安全以及如何实现它

1. 概述

Java 支持开箱即用的多线程。这意味着通过在单独的工作线程中并发运行字节码,JVM能够提高应用程序性能。

虽然多线程是一个强大的功能,但它是有代价的。在多线程环境中,我们需要以线程安全的方式编写实现。这意味着不同的线程可以访问相同的资源,而不会暴露错误行为或产生不可预知的结果。这种编程方法称为“线程安全”。

在本教程中,我们将介绍实现它的不同方法。

2. 无状态实现

在大多数情况下,多线程应用程序中的错误是由于在多个线程之间错误地共享状态而导致的。

因此,我们将要研究的第一种方法是使用无状态实现实现线程安全。

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

代码语言:javascript代码运行次数:0运行复制
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;
    }
}
Copy

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

该方法既不依赖于外部状态,也不维护状态。因此,它被认为是线程安全的,可以同时由多个线程安全地调用。

所有线程都可以安全地调用factorial() 方法,并且将获得预期的结果,而不会相互干扰,也不会更改该方法为其他线程生成的输出。

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

3. 不可变的实现

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

不变性是一个强大的、与语言无关的概念,在 Java 中很容易实现。

简单地说,当类实例的内部状态在构造后无法修改时,它就是不可变的。

在 Java 中创建不可变类的最简单方法是将所有字段声明为私有最终字段,并且不提供 setter:

代码语言:javascript代码运行次数:0运行复制
public class MessageService {
    
    private final String message;

    public MessageService(String message) {
        this.message = message;
    }
    
    // standard getter
    
}Copy

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

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

正如我们所看到的,不变性只是实现线程安全的另一种方式。

4. 线程本地字段

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

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

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

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

代码语言:javascript代码运行次数:0运行复制
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);
    }
}Copy

同时,另一个可能保存一个字符串数组

代码语言:javascript代码运行次数:0运行复制
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);
    }
}Copy

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

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

让我们考虑以下StateHolder类:

代码语言:javascript代码运行次数:0运行复制
public class StateHolder {
    
    private final String state;

    // standard constructors / getter
}Copy

我们可以很容易地使其成为线程局部变量:

代码语言:javascript代码运行次数:0运行复制
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();
    }
}Copy

线程本地字段与普通类字段非常相似,不同之处在于通过 setter/getter 访问它们的每个线程都会获得该字段的独立初始化副本,以便每个线程都有自己的状态。

5. 同步集合

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

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

代码语言:javascript代码运行次数:0运行复制
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();
Copy

请记住,同步集合在每个方法中使用内部锁定(稍后我们将介绍内部锁定)。

这意味着方法一次只能由一个线程访问,而其他线程将被阻塞,直到第一个线程解锁该方法。

因此,由于同步访问的底层逻辑,同步在性能上会受到影响。

6. 并发集合

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

Java 提供了java.util.concurrent包,其中包含多个并发集合,例如ConcurrentHashMap

代码语言:javascript代码运行次数:0运行复制
Map<String,String> concurrentMap = new ConcurrentHashMap<>();
concurrentMap.put("1", "one");
concurrentMap.put("2", "two");
concurrentMap.put("3", "three");
Copy

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

由于并发线程访问的固有优势,并发集合的性能比同步集合高得多。

值得一提的是,同步和并发集合仅使集合本身线程安全,而不是内容。

7. 原子对象

也可以使用Java提供的原子类集来实现线程安全,包括AtomicInteger,AtomicLong,AtomicBooleanAtomicReference

原子类允许我们执行原子操作,这些操作是线程安全的,而无需使用同步。原子操作在一个计算机级操作中执行。

为了理解这解决了什么问题,让我们看一下下面的Counter类:

代码语言:javascript代码运行次数:0运行复制
public class Counter {
    
    private int counter = 0;
    
    public void incrementCounter() {
        counter += 1;
    }
    
    public int getCounter() {
        return counter;
    }
}Copy

假设在竞争条件下,两个线程同时访问incrementCounter() 方法。

理论上,计数器字段的最终值将为 2。但是我们无法确定结果,因为线程同时执行相同的代码块,并且增量不是原子的。

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

代码语言:javascript代码运行次数:0运行复制
public class AtomicCounter {
    
    private final AtomicInteger counter = new AtomicInteger();
    
    public void incrementCounter() {
        counter.incrementAndGet();
    }
    
    public int getCounter() {
        return counter.get();
    }
}Copy

这是线程安全的,因为增量 ++ 需要多个操作,增量和获取是原子的。

8. 同步方法

早期的方法非常适合集合和基元,但我们有时需要比这更好的控制。

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

简单地说,一次只有一个线程可以访问同步方法,同时阻止其他线程访问此方法。其他线程将保持阻塞状态,直到第一个线程完成或该方法引发异常。

我们可以通过使incrementCounter() 成为同步方法,以另一种方式创建线程安全版本:

代码语言:javascript代码运行次数:0运行复制
public synchronized void incrementCounter() {
    counter += 1;
}Copy

我们通过在方法签名前面加上同步关键字来创建同步方法。

由于一次一个线程可以访问同步方法,因此一个线程将执行incrementCounter() 方法,反过来,其他线程将执行相同的操作。不会发生任何重叠执行。

同步方法依赖于使用“内部锁”或“监视器锁”。内部锁是与特定类实例关联的隐式内部实体。

在多线程上下文中,术语监视器只是对锁在关联对象上执行的角色的引用,因为它强制对一组指定的方法或语句进行独占访问。

当线程调用同步方法时,它将获取内部锁。线程完成方法执行后,它会释放锁,这允许其他线程获取锁并访问该方法。

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

9. 同步语句

有时,如果我们只需要使方法的一部分是线程安全的,则同步整个方法可能是矫枉过正的。

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

代码语言:javascript代码运行次数:0运行复制
public void incrementCounter() {
    // additional unsynced operations
    synchronized(this) {
        counter += 1; 
    }
}Copy

该示例很简单,但它演示了如何创建同步语句。假设该方法现在执行一些不需要同步的其他操作,我们仅通过将相关的状态修改部分包装在同步块中来同步该部分。

与同步方法不同,同步语句必须指定提供内部锁的对象,通常是this引用。

同步的成本很高,因此使用此选项,我们只能同步方法的相关部分。

9.1. 其他对象作为锁

我们可以通过利用另一个对象作为监视器锁来稍微改进Counter类的线程安全实现,而不是这个

这不仅提供了对多线程环境中共享资源的协调访问,而且还使用外部实体来强制对资源的独占访问:

代码语言:javascript代码运行次数:0运行复制
public class ObjectLockCounter {

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

我们使用纯对象实例来强制实施互斥。此实现稍微好一些,因为它提高了锁级别的安全性。

将其用于内部锁定时,攻击者可以通过获取内部锁定并触发拒绝服务 (DoS) 条件来造成死锁。

相反,当使用其他对象时,该私有实体无法从外部访问。这使得攻击者更难获取锁并导致死锁。

9.2. 注意事项

即使我们可以使用任何 Java 对象作为内在锁,我们也应该避免将字符串用于锁定目的:

代码语言:javascript代码运行次数:0运行复制
public class Class1 {
    private static final String LOCK  = "Lock";

    // uses the LOCK as the intrinsic lock
}

public class Class2 {
    private static final String LOCK  = "Lock";

    // uses the LOCK as the intrinsic lock
}Copy

乍一看,这两个类似乎使用两个不同的对象作为它们的锁。但是,由于字符串实习,这两个“Lock”值实际上可能引用字符串池上的同一对象。也就是说,类 1和类2共享同一个锁!

反过来,这可能会导致并发上下文中的一些意外行为。

除了字符串之外,我们还应该避免使用任何可缓存或可重用的对象作为内部锁。例如,Integer.valueOf() 方法缓存小数字。因此,调用Integer.valueOf(1) 即使在不同的类中也会返回相同的对象。

10. 易失性字段

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

为了防止这种情况,我们可以使用易失性类字段:

代码语言:javascript代码运行次数:0运行复制
public class Counter {

    private volatile int counter;

    // standard constructors / getter
    
}Copy

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

此外,使用易失变量可确保从主内存中读取对给定线程可见的所有变量。

让我们考虑以下示例:

代码语言:javascript代码运行次数:0运行复制
public class User {

    private String name;
    private volatile int age;

    // standard constructors / getters
    
}Copy

在这种情况下,每次 JVM 将年龄易失变量写入主内存时,它也会将非易失性名称变量写入主内存。这可确保两个变量的最新值存储在主内存中,因此对变量的后续更新将自动对其他线程可见。

同样,如果线程读取易失变量的值,则线程可见的所有变量也将从主内存中读取。

易失性变量提供的这种扩展保证称为完全易失性可见性保证。

11. 可重入锁

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

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

没有检查排队线程并提供对最长等待线程的优先访问的基础机制。

ReentrantLock实例允许我们做到这一点,防止排队的线程遭受某些类型的资源匮乏:

代码语言:javascript代码运行次数:0运行复制
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
    
}Copy

构造函数采用可选的公平性布尔参数。当设置为true 并且多个线程正在尝试获取锁时,JVM 将优先考虑最长的等待线程并授予对该锁的访问权限。

12. 读/写锁

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

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

因此,只要没有线程写入资源,就可以让许多线程读取资源。此外,线程写入资源将阻止其他线程读取它。

以下是我们如何使用读写锁锁

代码语言:javascript代码运行次数:0运行复制
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
   
}
本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。 原始发表:2023-02-22,如有侵权请联系 cloudcommunity@tencent 删除java教程同步线程线程安全

本文标签: 什么是线程安全以及如何实现它