何谓线程安全?如何实现线程安全?
本文译自,如有兴趣可查看原文:https://www.baeldung.com/java-thread-safety
1.总览
Java支持开箱即用的多线程功能。这意味着,JVM可在多个线程间,同时运行字节码,提升应用性能。
多线程虽功能强大,但凡事均有代价:在多线程环境下,我们需实现“线程安全”的代码。这就是说,在多个线程进入同一资源时,不会产生错误行为和无法预期的后果。
这种编程方法就是所谓的“线程安全”。
该课程中,我们会讨论几种实现线程安全的方法。
2.无状态实现
多线程应用中的错误,基本源于多线程间,无法正确分享状态。
因此,我们首个实现线程安全的方法,便是使用无状态的代码。
为更好了解该方法,假设有个简单的UTIL类,其中有个静态方法,可计算某数字的阶乘。
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-Local)属性
在面向对象编程中(OOP),对象通过属性维持状态,并使用一或多个方法实现其行为。
如果真的需要维持状态,我们可以让类的属性变成线程专用(Thread-Local),如此一来,类就不会在线程间分享状态。
在Thread类中定义私有的(private)的属性,即可使这些属性变为线程专用属性。
例如,我们可以在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实例,也能让属性变成线程专用(thread-local)。
来看看这个叫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();
}
}
不同线程在访问ThreadLocal属性时,都会通过一个独立初始化的setter/getter方法,获得专属于自己的状态。除此以外,ThreadLocal跟普通属性没什么两样。
5.同步(Synchronized)集合
我们可以使用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.并发(Concurrent)集合
除了同步集合,我们还可以创建并发集合来实现线程安全。
Java提供的java.util.concurrent包里,包含了诸如ConcurrentHashMap内的几个并发集合。
Map<String,String> concurrentMap = new ConcurrentHashMap<>();
concurrentMap.put("1", "one");
concurrentMap.put("2", "two");
concurrentMap.put("3", "three");
与同步集合不同的是,并发集合通过切割数据的方法来实现线程安全(Java1.7之前,1.8已使用其他策略)。例如,在ConcurrentHashMap,不同线程,访问不同的数据碎片时会获得锁。因此,多个线程可以同时访问Map。
因为这种高级的线程访问策略,并发锁的性能比同步锁好得多。
值得一提的是,无论同步还是并发集合,都只会使集合本身变得线程安全,而非其内容。
7.原子操作对象
使用Java提供的原子操作类,同样可以实现线程安全。例如:AtomicInteger,AtomicLong,AtomicBoolean,和AtomicReference。
原子操作类让我们在不使用同步(synchronization)功能时,实现线程安全的原子操作。原子操作,即为一个整体,不可被打断的操作。
为理解原子操作类所解决的问题,请看如下Counter类:
public class Counter {
private int counter = 0;
public void incrementCounter() {
counter += 1;
}
public int getCounter() {
return counter;
}
}
假设两个线程竞争资源,同时进入了incrementCounter()方法。
理论上说,counter属性的最终结果是2。但我们无法肯定——因为两个线程在同一时间执行同一代码,而累加并非原子操作。
现在使用AtomicInteger对象来创造一个线程安全的Counter类:
public class AtomicCounter {
private final AtomicInteger counter = new AtomicInteger();
public void incrementCounter() {
counter.incrementAndGet();
}
public int getCounter() {
return counter.get();
}
}
因为incrementAndGet 是原子操作,所以它变得线程安全。
8.同步(Synchronized)方法
前面的解决方案十分适用于集合和原始类型,但有时,我们也需要控制更多功能。
因此,我们实现线程安全的另一个普通手段,就是使用同步方法。
同步方法的原理为,同一时间,只让一个线程进入方法,阻塞其他意图进入方法的线程,直到第一个线程执行完毕或抛错为止。
我们使用synchronized修饰方法,创建另一个线程安全的incrementCounter()。
public synchronized void incrementCounter() {
counter += 1;
}
因为同一时间只能有一个线程进入方法,所以不会发生诸如并行执行的问题。
同步方法依赖“内在锁”或“监听锁(monitor locks)”实现线程安全,内在锁,是一种与某个关联实例的隐式内部实体。
多线程环境下,“监听”一词,指的是对关联实例的上锁行为,该行为会确保特定方法/代码的独家访问权。
当一个线程调用同步方法,它就会获得内在锁。执行完毕后,它就会释放锁,允许其他线程获得锁并进入方法。
我们可以在实例方法、静态方法和代码块中使用同步功能。
9.同步代码块
有时,我们仅需一部分代码实现线程安全。在这个前提下,同步整个方法,未免铺张浪费。
例如,我们来重写incrementCounter():
public void incrementCounter() {
// 其他无需同步的操作
synchronized(this) {
counter += 1;
}
}
例子很简单,但它展示了创建同步代码块的原理。假设该方法有一些额外的、不需同步的业务,那我们只需用synchronized块包住需要同步的代码即可。
与同步方法不同,同步代码块需要指定内在锁锁定的对象,通常用this即可。
同步是一种昂贵的操作,如果非要使用,可以只同步必须部分。
同步方法和同步代码块,可有效解决线程间的变量可视度问题。即便如此,普通类中的值有可能被CPU缓存。因此,即使使用同步技术,在变量被改变后,其它线程也有可能,无法获取最新的变量值。
我们使用volatile防止这种情况出现:
public class Counter {
private volatile int counter;
// 标准的构造方法/getter
}
volatile关键字告诉JVM和编译器,把counter变量存放在主内存中。这样有效保证JVM每次都会从/向主内存读取/写入counter变量,而非CPU缓存。
使用volatile,确保了对某个线程可见的所有变量,均是从主内存读取而来。
public class User {
private String name;
private volatile int age;
// 标准的构造方法/getter
}
以上例子里,JVM不仅会向主内存写入age变量,还会写入name变量到主内存。这样保证了两个变量的最新数值都保存在主内存中,更新变量的结果对其他线程均可见。
相似的,如果某个线程读取了volatile变量中的值,所有对该线程可见的变量,都会从主内存中读取数值。
volatile提供的这个特性,被称为一变量volatile,全员走内存(full volatile visibility guarantee)。
11.外在锁
我们可以使用外在锁(而非内在锁),稍微改进一下Counter类中的线程安全实现。
外在锁同样保证了多线程环境下,共享资源的访问问题,不同的是,它使用了外在实体来对排他性访问进行上锁。
public class ExtrinsicLockCounter {
private int counter = 0;
private final Object lock = new Object();
public void incrementCounter() {
synchronized(lock) {
counter += 1;
}
}
// 标准的getter
}
我们使用一个普通的Object创造了外在锁,这种实现增加了锁的安全性,比前一种实现稍微好点。
使用内在锁时,synchronized方法和代码块依赖this引用,攻击者可以获得内在锁,然后触发DoS(denial of service)状态,引发死锁。
与内在锁不同的是,外在锁使用了私有实体,无法从外度获取。这加大了攻击者获得锁并引发死锁的难度。
12.可重入锁
Java提供了一系列改进的锁功能,它们的行为,比上面讨论的内在锁要稍微复杂一些。
内在锁的获取/释放机制相当死板:一个线程获得锁,开始执行方法或代码块,最终,锁会被释放,然后被其他线程获取。
内在锁并没有机制去为线程排队,为等待时间最长的线程提供优先权。
可重入锁提供了这些功能,避免等待的线程出现资源饥荒。
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
}
在初始化可重入锁时,能传入一个布尔值参数,表示是否实现公平分配。当设为true时,JVM会为等待时间最长的线程提供优先权。
13.读/写锁
另一个可以实现线程安全的强大机制就是读/写锁。
读写锁是一对关联的锁,一个负责只读操作,另一个负责写操作。
因此,只要没线程写入资源,就可以实现多个线程同时访问资源。在线程写入资源时,会防止其他线程读取资源。
可以像下例那样使用读写锁:
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();
}
}
// 标准的构造方法
}
14.结论
在这篇文章中,我们学习了Java的线程安全,并展示了不同的实现方法。