Java 还为线程安全提供了一些工具类,如 ThreadLocal 类,它代表一个线程局部变量,通过把数据放在 ThreadLocal 中就可以让每个线程创建一个该变量的副本,从而避免并发访问的线程安全问题。除此之外,Java 5 还新增了大量的线程安全类。
ThreadLocal 类
早在 JDK 1.2 推出之时,Java 就为多线程编程提供了一个 ThreadLocal 类;从 Java 5.0 以后, Java 引入了泛型支持,Java 为该 ThreadLocal 类增加了泛型支持,即:ThreadLocal<T>。通过使用 ThreadLocal 类可以简化多线程编程时的并发访问,使用这个工具类可以很简捷地隔离多线程程序的竞争资源。
ThreadLocal 是 Thread Local Variable (线程局部变量)的意思,也许将它命名为 ThreadLocalVar 更加合适。线程局部变量(ThreadLocal)的功用其实非常简单,就是为每一个使用该变量的线程都提供一个变量值的副本,使每一个线程都可以独立地改变自己的副本,而不会和其他线程的副本冲突。从线程的角度看,就好像每一个线程都完全拥有该变量一样。
ThreadLocal 类的用法非常简单,它只提供了如下三个 public 方法。
- T get():返回此线程局部变量中当前线程副本中的值。
- void remove():删除此线程局部变量中当前线程的值。
- void set(T value):设置此线程局部变量中当前线程副本中的值。
下面程序将向读者证明 ThreadLocal 的作用。
class Account{ /*定义一个ThreadLocal类型的变量,该变量将是一个线程局部变量 每个线程都会保留该变量的一个副本*/ private ThreadLocal<String> name = new ThreadLocal<String>(); //定义一个初始化name属性的构造器 public Account(String name){ this.name.set(name); //下面代码看到输出“初始名” System.out.println("------" + this.name.get()); } //定义了name属性的setter和getter方法 public String getName(){ return name.get(); } public void setName(String str){ this.name.set(str); } } class MyTest extends Thread{ //定义一个Account属性 private Account account; public MyTest(Account account, String name){ super(name); this.account = account; } public void run(){ //循环10次 for (int i = 0 ; i < 10 ; i++){ //当i == 6时输出将账户名替换成当前线程名 if (i == 6){ account.setName(getName()); } //输出同一个账户的账户名和循环变量 System.out.println(account.getName()+ " 账户的i值:" + i); } } } public class ThreadLocalTest{ public static void main(String[] args) { //启动两条线程,两条线程共享同一个Account Account at = new Account("初始名"); /* 虽然两条线程共享同一个账户,即只有一个账户名 但由于账户名是ThreadLocal类型的,所以两条线程将 导致有同一个Account,但有两个账户名的副本,每条线程 都完全拥有各自的账户名副本,所以从i == 6之后,将看到两条 线程访问同一个账户时看到不同的账户名。 */ new MyTest(at , "线程甲").start(); new MyTest(at , "线程乙").start(); } }
上面 Account 类中的三行粗体字代码分别完成了创建 ThreadLocal 对象、从 ThreadLocal 中取出线程局部变量、修改线程局部变量的操作。由于程序中的账户名是一个 ThreadLocal 变量,所以虽然程序中只有一个 Account 对象,但两个子线程将会产生两个账户名(主线程也持有一个账户名的副本)。两个线程进行循环时都会在 i = 6 时将账户名改为与线程名相同,这样就可以看到两个线程拥有两个账户名的情形,如下图所示。
从上面程序可以看出,实际上账户名有三个副本,主线程一个,另外启动的两个线程各一个,它们的值互不干扰,每个线程完全拥有自己的 ThreadLocal 变量,这就是 ThreadLocal 的用途。
ThreadLocal 和其他所有的同步机制一样,都是为了解决多线程中对同一变量的访问冲突,在普通的同步机制中,是通过对象加锁来实现多个线程对同一变量的安全访问的。该变量是多个线程共享的,所以要使用这种同步机制,需要很细致地分析在什么时候对变量进行读写,什么时候需要锁定某个对象,什么时候释放该对象的锁等。在这种情况下,系统并没有将这份资源复制多份,只是采用了安全机制来控制对这份资源的访问而已。
ThreadLocal 从另一个角度来解决多线程的并发访问, ThreadLocal 将需要并发访问的资源复制多份,每个线程拥有一份资源,每个线程都拥有自己的资源副本,从而也就没有必要对该变量进行同步了。ThreadLocal 提供了线程安全的共享对象,在编写多线程代码时,可以把不安全的整个变量封装进ThreadLocal,或者把该对象与线程相关的状态使用 ThreadLocal 保存。
ThreadLocal 并不能替代同步机制,两者面向的问题领域不同。同步机制是为了同步多个线程对相同资源的并发访问,是多个线程之间进行通信的有效方式;而 ThreadLocal 是为了隔离多个线程的数据共享,从根本上避免多个线程之间对共享资源(变量)的竞争,也就不需要对多个线程进行同步了。
通常建议:如果多个线程之间需要共享资源,以达到线程之间的通信功能,就使用同步机制;如果仅仅需要隔离多个线程之间的共享冲突,则可以使用 ThreadLocal 。
包装线程不安全的集合
ArrayList、 LinkedList、HashSet、TreeSet、HashMap、TreeMap 等都是线程不安全的,也就是说,当多个并发线程向这些集合中存、取元素时,就可能会破坏这些集合的数据完整性。
如果程序中有多个线程可能访问以上这些集合,就可以使用 Collections 提供的类方法把这些集合包装成线程安全的集合。 Collections 提供了如下几个静态方法。
- <T> Collection<T> synchronizedCollection(Collection<T> c ):返回指定 collection 对应的线程安全的 collection 。
- static <T> List<T> synchronizedList(List<T> list ):返回指定 List 对象对应的线程安全的 List 对象。
- static <K , V> Map<K, V> synchronizedMap(Map<K, V> m ) : 返回指定 Map 对象对应的线程安全的Map 对象。
- static <T> Set<T> synchronizedSet(Set<T> s):返回指定 Set 对象对应的线程安全的 Set 对象。
- static <K, V> SortedMap<K, V> synchronizedSortedMap(SortedMap<K, V> m ) : 返回指定 SortedMap 对象对应的线程安全的 SortedMap 对象。
- static <T> SortedSet<T> synchronizedSortedSet(SortedSet<T> s ) : 返回指定 SortedSet 对象对应的线程安全的 SortedSet 对象。
例如需要在多线程中使用线程安全的 HashMap 对象,则可以采用如下代码:
HashMap m = Collections.synchronizedMap(new HashMap());
注意:如果需要把某个集合包装成线程安全的集合,则应该在创建之后立即包装,如上程序所示——当 HashMap 对象创建后立即被包装成线程安全的 HashMap 对象。
线程安全的集合类
实际上从 Java 5 开始,在 java.util.concurrent 包下提供了大量支持高效并发访问的集合接口和实现类这些线程安全的集合类可分为如下两类。
- 以 Concurrent 开头的集合类,如 ConcurrentHashMap、ConcurrentSkipListMap、ConcurrentSkipListSet、ConcurrentLinkedQueue 和 ConcurrentLinkedDeque 。
- 以 CopyOnWrite 开头的集合类,如 CopyOnWriteArrayList、CopyOnWriteArraySet 。
其中以 Concurrent 开头的集合类代表了支持并发访问的集合,它们可以支持多个线程并发写入访问,这些写入线程的所有操作都是线程安全的,但读取操作不必锁定。以 Concurrent 开头的集合类采用了更复杂的算法来保证永远不会锁住整个集合,因此在并发写入时有较好的性能。
当多个线程共享访问一个公共集合时,ConcurrentLinkedQueue 是一个恰当的选择 。ConcurrentLinkedQueue 不允许使用 null 元素。 ConcurrentLinkedQueue 实现了多线程的高效访问,多个线程访问 ConcurrentLinkedQueue 集合时无须等待。
在默认情况下, ConcurrentHashMap 支持16个线程并发写入,当有超过16个线程并发向该 Map 中写入数据时,可能有一些线程需要等待。实际上,程序通过设置 concurrencyLevel 构造参数(默认值为16)来支持更多的并发写入线程。
与前面介绍的 HashMap 和普通集合不同的是,因为 ConcurrentLinkedQueue 和 ConcurrentHashMap 支持多线程并发访问,所以当使用迭代器来遍历集合元素时,该迭代器可能不能反映出创建迭代器之后所做的修改,但程序不会抛出任何异常。
Java 8 扩展了 ConcurrentHashMap 的功能 , Java 8 为该类新增了30多个新方法,这些方法可借助于 Stream 和 Lambda 表达式支持执行聚集操作。 ConcurrentHashMap 新增的方法大致可分为如下三类。
- forEach 系列(forEach,forEachKey,forEachValue, forEachEntry)
- search 系列(search,searchKeys,searchValues,searchEntries)
- reduce 系列 (reduce, reduceToDouble, reduceToLong, reduceKeys, reduceValues)
除此之外,ConcurrentHashMap 还新增了 mappingCount()、newKeySet() 等 方 法 ,增强后的 ConcurrentHashMap 更适合作为缓存实现类使用。
注意:使用 java.util 包下的 Collection 作为集合对象时,如果该集合对象创建迭代器后集合元素发生改变,则会引发 ConcurrentModificationException 异常。
由于 CopyOnWriteArraySet 的底层封装了 CopyOnWriteArrayList,因此它的实现机制完全类似于 CopyOnWriteArrayList 集合。
对于 CopyOnWriteArrayList 集合,正如它的名字所暗示的,它采用复制底层数组的方式来实现写操作。当线程对 CopyOnWriteArrayList 集合执行读取操作时,线程将会直接读取集合本身,无须加锁与阻塞。当线程对 CopyOnWriteArrayList 集合执行写入操作时(包括调用 add()、 remove()、 set() 等方法),该集合会在底层复制一份新的数组,接下来对新的数组执行写入操作。由于对 CopyOnWriteArrayList 集合的写入操作都是对数组的副本执行操作,因此它是线程安全的。
需要指出的是,由于 CopyOnWriteArrayList 执行写入操作时需要频繁地复制数组,性能比较差,但由于读操作与写操作不是操作同一个数组,而且读操作也不需要加锁,因此读操作就很快、很安全。由此可见, CopyOnWriteArrayList 适合用在读取操作远远大于写入操作的场景中,例如缓存等。