实现线程安全的11种方法,你学会了吗?

开发 前端
尽管多线程是一个强大的特性,但它也有代价。在多线程环境中,我们需要时刻崩着线程安全这根弦,即在不同的线程可以访问相同的资源,而不会暴露错误行为或产生不可预测的结果,这种编程方法被称为“线程安全”。

Java原生支持多线程,意味着通过在独立的线程中并发运行,JVM能够提升应用程序的性能。

尽管多线程是一个强大的特性,但它也有代价。在多线程环境中,我们需要时刻崩着线程安全这根弦,即在不同的线程可以访问相同的资源,而不会暴露错误行为或产生不可预测的结果,这种编程方法被称为“线程安全”。

一、无状态实现

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

因此,我们首先要探讨的方法是:使用无状态实现来达到线程安全。

为了更好地理解这种方法,先创建一个简单的工具类,它有一个静态方法用于计算数字的阶乘:

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()方法,并将获得预期的结果,而不会相互干扰,也不会改变该方法为其他线程生成的输出。

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

二、不可变实现

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

不可变性是一个强大的、与语言无关的概念,在Java中很容易实现。在函数式编程中,很重要的一个技巧就是不可变,参见什么是函数式编程?。

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

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

public class MessageService {

    privatefinal String message;

    public MessageService(String message) {
        this.message = message;
    }

    public String getAndPrint() {
        System.out.println(message);
        return message;
    }

    public String getMessage() {
        return message;
    }
}

一个MessageService对象,在其构造后其状态不能改变,所以是线程安全的。

此外,如果MessageService是可变的,但多个线程对其只有只读权限,它也是线程安全的。

如我们所见,不可变性是实现线程安全的另一种方式。

三、线程局部字段

在面向对象编程(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);
    }
}

另一个Thread类可能持有一个字符串数组:

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 String state;

    public StateHolder(String state) {
        this.state = state;
    }

    public String getState() {
        return state;
    }

    public void setState(String state) {
        this.state = state;
    }
}

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

public class ThreadState {

    public static final ThreadLocal<StateHolder> statePerThread =
            ThreadLocal.withInitial(() -> new StateHolder("active"));

    public static StateHolder getState() {
        return statePerThread.get();
    }
}

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

四、同步集合

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

比如,我们可以创建一个线程安全的集合:

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

请记住,同步集合在每个方法中使用内部锁,这意味着这些方法一次只能被一个线程访问,而其他线程将被阻塞,直到第一个线程释放该方法的锁。

五、并发集合

作为同步集合的替代方案,我们可以使用并发集合来创建线程安全的集合。

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

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

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

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

需要注意的是,无论同步集合还是并发集合,都是集合本身线程安全,其内容并不是。

六、原子对象

我们还可以使用Java提供的原子类集合来实现线程安全,包括AtomicInteger、AtomicLong、AtomicBoolean和AtomicReference。

原子类允许我们执行原子操作,这些操作是线程安全的,而无需使用同步。

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

public class Counter {

    private int counter = 0;

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

    public int getCounter() {
        return counter;
    }
}

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

理论上,counter字段的最终值将为2。但我们不能确定结果,因为线程同时执行相同的代码块,并且递增操作不是原子的。可以参见在多线程中使用ArrayList会发生什么?的说明。

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

public class AtomicCounter {

    private final AtomicInteger counter = new AtomicInteger();

    public void incrementCounter() {
        counter.incrementAndGet();
    }

    public int getCounter() {
        return counter.get();
    }
}

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

七、同步方法

前面的方法对于集合和基本类型非常有用,但有时我们需要更复杂的控制逻辑。

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

简单地说,一次只能有一个线程访问同步方法,同时阻止其他线程访问该方法。其他线程将保持阻塞,直到第一个线程完成或该方法抛出异常。

我们可以通过将incrementCounter()方法变为同步方法来以另一种方式创建其线程安全版本:

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

我们通过在方法签名前加上synchronized关键字创建了一个同步方法。

由于一次只能有一个线程访问同步方法,一个线程将执行incrementCounter()方法,其他线程将依次执行。不会发生任何重叠执行。

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

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

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

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

八、同步语句

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

我们再重构incrementCounter()方法:

public void incrementCounter() {
    // 其他未同步的操作
    synchronized(this) {
        counter += 1; 
    }
}

假设该方法现在执行一些其他不需要同步的操作,我们通过将相关的状态修改部分包装在同步块中来仅同步这部分。

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

同步是有代价的,通过同步代码块,能够仅同步方法的相关部分。

(一)其他对象作为锁

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

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

public class ObjectLockCounter {

    privateint counter = 0;
    privatefinal Object lock = new Object();

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

    public int getCounter() {
        return counter;
    }
}

我们使用一个普通的Object实例来实现互斥,它提高了锁级别的安全性。

当使用this进行内部锁时,攻击者可以通过获取内部锁并触发拒绝服务(DoS)条件来导致死锁。

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

(二)注意事项

尽管我们可以使用任何Java对象作为内部锁,但我们应该避免使用String进行锁定:

public class Class1 {
    privatestaticfinal String LOCK  = "Lock";

    // 使用LOCK作为内部锁
}

publicclass Class2 {
    privatestaticfinal String LOCK  = "Lock";

    // 使用LOCK作为内部锁
}

乍一看,似乎这两个类使用了两个不同的对象作为它们的锁。然而,由于字符串驻留,这两个“Lock”值实际上可能在字符串池中引用同一个对象。也就是说,Class1和Class2共享同一个锁!

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

九、易失性字段

同步方法和块对于解决线程之间的变量可见性问题很方便,即便如此,常规类字段的值可能会被CPU缓存。因此,即使对特定字段进行了同步更新,其他线程可能也看不到这些更新。

为了防止这种情况,我们可以使用易失性类字段(通过volatile关键字标记):

public class Counter {

    private volatile int counter;

    // 标准的构造函数/获取器
}

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

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

比如:

public class User {

    private String name;
    private volatile int age;

    // 标准的构造函数/获取器
}

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

类似地,如果一个线程读取易失性变量的值,该线程可见的所有变量也将从主内存中读取。

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

十、可重入锁

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

对于内部锁,锁获取模型相当严格:一个线程获取锁,然后执行一个方法或代码块,最后释放锁,以便其他线程可以获取它并访问该方法。内部锁没有实现检查排队的线程并优先访问等待时间最长的线程,即属于非公平锁。

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

public class ReentrantLockCounter {

    privateint counter;
    privatefinal ReentrantLock reLock = new ReentrantLock(true);

    public void incrementCounter() {
        reLock.lock();
        try {
            counter += 1;
        } finally {
            reLock.unlock();
        }
    }

    public int getCounter() {
        return counter;
    }
}

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

十一、读写锁

我们可以使用读写锁实现来实现线程安全。读写锁实际上使用一对相关联的锁,一个用于只读操作,另一个用于写入操作。

因此,只要没有线程正在写入资源,就可以有多个线程读取该资源。此外,写入资源的线程将阻止其他线程读取它。

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

public class ReentrantReadWriteLockCounter {

    privateint counter;
    privatefinal ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
    privatefinal Lock readLock = rwLock.readLock();
    privatefinal 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();
        }
    }
}

文末总结

在本文中,我们了解了Java中的线程安全是什么,并深入研究了实现线程安全的11种方法。

责任编辑:武晓燕 来源: 看山的小屋
相关推荐

2024-01-10 07:38:08

2023-10-30 11:40:36

OOM线程池单线程

2022-06-16 07:50:35

数据结构链表

2023-12-11 08:03:01

Java线程线程组

2023-01-28 09:50:17

java多线程代码

2024-10-06 08:20:53

锁定机制编程

2023-12-07 12:29:49

Nginx负载均衡策略

2024-03-12 08:37:32

asyncawaitJavaScript

2024-07-29 10:35:44

KubernetesCSI存储

2022-09-22 12:03:14

网络安全事件

2024-02-02 11:03:11

React数据Ref

2022-07-08 09:27:48

CSSIFC模型

2024-01-02 12:05:26

Java并发编程

2023-08-01 12:51:18

WebGPT机器学习模型

2024-02-04 00:00:00

Effect数据组件

2024-01-19 08:25:38

死锁Java通信

2023-07-26 13:11:21

ChatGPT平台工具

2023-01-10 08:43:15

定义DDD架构

2024-02-29 13:12:30

2024-10-31 09:15:09

点赞
收藏

51CTO技术栈公众号