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

Yujiaao

上次修改时间:2020年4月17日

作者 亚历杭德罗·乌加特

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中创建不可变类的最简单方法是声明所有字段为 privatefinal ,且不提供 setter:

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

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

此外,如果 MessageService 实际上是可变的,但是多个线程仅对其具有只读访问权限,那么它也是线程安全的。

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

4. 线程私有 (ThreadLocal) 字段

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

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

通过简单地在 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;
 
    // 标准的构造函数和 getter
}

我们可以很容易地使其成为线程局部(ThreadLocal)变量,如下所示:

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. 同步集合类

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

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

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 中,多个线程可以获取不同 Map 段上的锁,因此多个线程可以同时访问 Map

由于并发线程访问的先天优势,并发集合类具备远超同步集合类更好的性能

值得一提的是,同步集合和并发集合仅使集合本身具有线程安全性,而不使content变得线程安全

7.原子化对象

使用Java提供的一组原子类(包括 AtomicIntegerAtomicLongAtomicBooleanAtomicReference )也可以实现线程安全。

原子类使我们能够执行安全的原子操作,而无需使用同步。原子操作在单个机器级别的操作中执行。

要了解解决的问题,让我们看下面的 Counter 类:

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

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

从理论上讲, counter 字段的最终值为2。但是我们不确定结果如何,因为线程在同一时间执行同一代码块,并且增量不是原子的。

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

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

这是线程安全的,因为在++增量执行多个操作的同时, 增量和获取 是原子的

8. 同步方法

尽管较早的方法对于集合和基元非常有用,但有时我们需要的控制权要强于此。

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

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

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

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

我们通过与前缀的方法签名创建一个同步方法 synchronized 关键字。

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

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

在多线程上下文中,术语 monitor 是指对关联对象执行锁的角色,因为它强制对一组指定的方法或语句进行排他访问。

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

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

9. 同步语句

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

为了说明这个用例,让我们重构 increascountCounter 方法:

public void incrementCounter() {
    // 此处可有额外不需同步的操作
    // ...
    synchronized(this) {
        counter += 1; 
    }
}

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

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

同步非常昂贵,因此使用此选项,我们尽可能只同步方法的相关部分

9.1 其他对象作为锁

我们可以通过将另一个对象用作监视器锁定,来稍微改善 Counter 类 的线程安全实现。

这不仅可以在多线程环境中提供对共享资源的协调访问,还可以使用外部实体来强制对资源进行独占访问

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

我们使用一个普通的 Object 实例来强制相互排斥。此实现稍好一些,因为它可以提高锁定级别的安全性。

将 this 用于内部锁定时,攻击者可能会通过获取内部锁定并触发拒绝服务(DoS)条件来导致死锁。

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

9.2 注意事项

即使我们可以将任何Java对象用作内部锁定,也应避免将 _Strings_用于锁定目的:

public class Class1 {
    private static final String LOCK  = "Lock";
 
    // 使用 LOCK 作为内部锁
}
 
public class Class2 {
    private static final String LOCK  = "Lock";
 
    // 使用 LOCK 作为内部锁
}

乍一看,这两个类似乎将两个不同的对象用作其锁。但是,intern,这两个“ Lock”值实际上可能引用字符串池上的同一对象。也就是说, Class1Class2 共享相同的锁!

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

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

10. volatile 修饰的域

同步的方法和块非常适合解决线程之间的可变可见性问题。即使这样,常规类字段的值也可能会被CPU缓存。因此,即使是同步的,对特定字段的后续更新也可能对其他线程不可见。

为了避免这种情况,我们可以使用 volatile 修饰的类字段:

public class Counter {
 
    private volatile int counter;
 
    // 标准构造函数、getter
    
}

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

此外,使用 volatile 变量可确保也将从主内存中读取给定线程可见的所有变量

让我们考虑以下示例:

public class User {
 
    private String name;
    private volatile int age;
 
    // 标准构造函数、getter
    
}

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

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

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

11. 重入锁

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();
        }
    }
    
    // 标准构造函数、getter...
    
}

ReentrantLock 的构造函数有一个可选的 公平 _boolean_ 参数。如果设置为 true ,并且多个线程正试图获取锁,则 JVM 将优先考虑等待时间最长的线程,并授予对该锁的访问权限

12. 读/写锁

我们可以用来实现线程安全的另一种强大机制是使用 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();
        }
    }
 
    // 标准构造函数...
   
}

13. 结论

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

像往常一样,本文中显示的所有代码示例都可以在GitHub上获得

阅读 584

并行计算
并行计算里编程技巧与模型

https://bixuebihui.com

11.9k 声望
3.7k 粉丝
0 条评论

https://bixuebihui.com

11.9k 声望
3.7k 粉丝
宣传栏