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

42次阅读

共计 7030 个字符,预计需要花费 18 分钟才能阅读完成。

上次批改工夫: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 提供的一组原子类(包含 AtomicInteger,AtomicLong,AtomicBoolean 和 AtomicReference )也能够实现线程平安。

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

要理解解决的问题,让咱们看上面的 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 上取得。

正文完
 0