Java 入门指南:Java 并发编程 —— 线程隔离技术 ThreadLocal
线程隔离技术
线程隔离是一种多线程编程技术,它可以将数据或资源在不同线程之间进行隔离,保证每个线程使用的数据或资源是独立的,不会互相干扰。线程隔离通常应用于高并发场景下,可以有效提升系统性能并提高并发能力。
实现方式
线程隔离的实现方式通常有以下几种:
-
每个线程使用自己的拷贝:每个线程单独维护一份数据或资源的拷贝,不与其他线程共享,从而实现隔离。
-
每个线程使用自己的命名空间:每个线程使用自己的命名空间,通过命名空间来隔离数据或资源,从而实现隔离。
-
线程局部变量(thread-local variable):线程局部变量是一种特殊的变量,在每个线程中都有自己的副本,不会与其他线程共享,从而实现隔离。Java 中提供了
ThreadLocal
类来实现线程局部变量。
缺陷
线程隔离虽然能够有效保证每个线程使用的数据或资源是独立的,但同时也会带来一些问题:
-
内存消耗增大:每个线程需要使用独立的数据或资源拷贝,因此会带来额外的内存消耗。
-
数据一致性问题:由于线程之间相互隔离,因此可能会引起数据一致性问题。
-
程序复杂度增加:线程隔离需要对数据或资源进行额外的管理和维护,程序复杂度可能会增加。
线程隔离与同步技术
线程隔离
和 同步
是两种不同的技术,它们解决的问题和应用场景也有所不同:
-
线程隔离主要关注的是隔离数据或资源,确保每个线程操作的是独立的数据或资源,从而提高系统的性能。
-
同步主要关注的是多线程并发访问共享数据时的线程安全性,避免出现数据竞争和数据不一致的情况。
在某些情况下,线程隔离和同步可以结合使用,以确保在并发环境下既能保证数据隔离又能保证数据的一致性和安全性。例如,可以使用线程局部变量ThreadLocal
实现线程隔离,再结合使用同步机制,如 synchronized 或 ReentrantLock 锁,来保证对共享数据的同步访问。
线程本地存储
线程本地存储(Thread Local Storage,TLS)是一种机制,允许每个线程在使用时维护自己的私有数据副本。它提供了一种有效的方式来在多线程环境下封装线程特定的数据,使得每个线程都可以独立地访问和修改其自己的数据副本,而不会受到其他线程的干扰。
线程安全问题的核心在于多个线程会对同一个临界区的共享资源进行访问,如果每个线程都拥有自己的“共享资源”,独立操作自身的数据,互不影响,避免共享资源的竞争,就可以防止线程安全问题了。
在Java中,线程本地存储是通过 ThreadLocal
类来实现的。
ThreadLocal
是一个线程局部变量,它为每个线程提供了一个独立的变量副本。每个线程可以通过 get()
方法获取变量值,通过 set()
方法设置变量值。每个线程对该变量的操作都只会影响到自己的变量副本,不会影响其他线程的副本。
ThreadLocal
ThreadLocal
是 Java 中的一个线程本地存储类,位于 java.lang
包中。它的主要作用是提供一个线程的本地变量,每个线程拥有该变量独立的一个对象副本,每个线程都可以访问到自己线程的变量副本,保证线程之间的数据隔离性,避免共享资源的竞争。
这是一种“空间换时间”的思想,每个线程拥有自己的“共享资源”,虽然内存占用变大了,但由于不需要同步,也就减少了线程可能存在的阻塞问题,从而提高时间上的效率。
应用场景
ThreadLocal
对象通常用于在多线程环境下保持某些对象的状态,被应用最多的场景是 Session
管理 和 Connection
数据库链接管理,以避免线程安全问题。
-
用于保存用户登录信息,这样在同一个线程中的任何地方都可以获取到登录信息。
-
用于保存数据库连接、
Session
对象等,这样在同一个线程中的任何地方都可以获取到数据库连接、Session
对象等。 -
用于保存事务上下文,这样在同一个线程中的任何地方都可以获取到事务上下文。
-
用于保存线程中的变量,这样在同一个线程中的任何地方都可以获取到线程中的变量。
常用方法
ThreadLocal
类中的常用方法:
-
get()
: 该方法用于获取与当前线程关联的 ThreadLocal 变量的值。如果当前线程还没有在线程本地存储中设置值,则返回null
。当调用
get()
方法时,如果当前线程还没有在线程本地存储中设置值,则会调用setinitialValue()
方法以懒初始化的方式来为该线程本地变量创建一个初始值(默认为 null),然后将该值保存在线程本地存储中,仅在实际需要特定于线程的值时才创建这些值。 -
set(T value)
: 该方法用于将给定的值设置为当前线程的 ThreadLocal 变量的值。确保了每个线程都有自己的变量副本。 -
remove()
: 该方法用于从当前线程的线程本地存储中删除与线程本地变量相关联的值。等价于将线程本地变量设置为null
。 -
initialValue()
: 该方法是一个protected
方法,用于创建线程本地变量的初始值(默认为 null),ThreadLocal 子类可以重写该方法根据需要选取合适的初始值。 -
withInitial(Supplier<? extends T> supplier)
: 该方法是 Java 8 新增的,它允许以更简单的方式创建线程本地变量,并为每个线程提供一个初始值,适用于 [[Java Lambda|Lambda]] 表达式。
当在一个线程中调用 ThreadLocal
的 set
方法设置值时,该值只在当前线程内可见;当另一个线程调用 ThreadLocal
的 set
方法设置同一个 ThreadLocal
的值时,它只会修改自己线程内的值,不会影响其他线程的值。
尽管 ThreadLocal
是线程局部变量,但如果没有清理,它们可能会导致内存泄漏或资源泄漏。当线程执行结束后,需要使用 remove()
方法将 ThreadLocal
变量与线程解绑并清理内存。
使用 ThreadLocal
可以解决一些多线程并发访问共享变量的线程安全问题,并提高程序的并发执行效率。但也应该避免过度使用 ThreadLocal
,以免占用过多的内存。
使用步骤
以下是使用 ThreadLocal
的一般步骤:
-
创建一个
ThreadLocal
对象:ThreadLocal<MyObject>
,其中MyObject
是要存储的数据类型。 -
在需要访问变量的线程中,通过
get()
方法获取ThreadLocal
变量的值。 -
如果需要,可以通过
set()
方法将变量的值设置为当前线程的私有副本。 -
在线程结束时,记得通过
remove()
方法清理线程的ThreadLocal
变量,以防止内存泄漏。
线程本地存储非常有用,特别是在一些需要在每个线程中保持上下文、状态或跟踪信息的情况下。例如,在 Web 应用程序中,可以使用线程本地存储来保存用户会话信息,以避免并发访问的问题。
ThreadLocal 示例
下面是一个示例,展示了如何使用 ThreadLocal
来存储和使用一个对象:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ThreadLocalExample {
// 创建一个 ThreadLocal 变量,存储 User 对象
private static final ThreadLocal<User> threadLocal = new ThreadLocal<>();
// 定义 User 类
private static class User {
private String name;
private int age;
public User(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public String toString() {
return "User{" +
"name='" + name + '\'' +
", age=" + age +
'}';
}
}
public static void main(String[] args) {
ExecutorService executorService = Executors.newFixedThreadPool(5);
// 提交多个任务到线程池
for (int i = 0; i < 5; i++) {
final int index = i;
executorService.submit(() -> {
// 设置线程局部变量的值
User user = new User("User " + index, 20 + index);
threadLocal.set(user);
// 在线程中使用该变量
System.out.println("Thread " + Thread.currentThread().getId() + ": " + threadLocal.get());
});
}
// 关闭线程池
executorService.shutdown();
}
}
示例说明:
-
创建 ThreadLocal 变量:
threadLocal
是一个ThreadLocal
对象,用于存储User
类型的线程局部变量。 -
设置和获取变量值:在每个线程中,使用
threadLocal.set(user)
设置线程局部变量的值。使用threadLocal.get()
获取当前线程的局部变量值。 -
User 类:定义了一个简单的
User
类,用于存储用户的姓名和年龄。 -
输出结果:每个线程都会输出自己的局部变量值,可以看到每个线程都有自己独立的
User
对象副本。
ThreadLocalMap
ThreadLocalMap
是 Java 中的一个类,用于实现 ThreadLocal
类的底层数据结构,是 ThreadLocal
的核心。
每个线程都有一个对应的 ThreadLocalMap
对象,它通过 Thread.threadLocals
字段来引用。因此,每个线程可以独立地操作自己的 ThreadLocalMap
对象。不会与其他线程的 ThreadLocal 变量产生冲突。
ThreadLocalMap
是一个哈希桶数组,通过哈希表实现,每个桶都存储一个 Entry
对象,Entry
对象包含了 ThreadLocal
的引用和对应的值。ThreadLocalMap 实际上就是一个以 ThreadLocal 实例为 key,任意对象为 value 的 Entry 数组。
在使用 ThreadLocal
类时,通常会通过 ThreadLocalMap
来实现线程本地变量的存储。为 ThreadLocal
变量赋值时,实际上就是以当前 ThreadLocal 实例为 key,值为 Entry 向其 ThreadLocalMap 中存放。每个线程可以独立地访问和操作自己的线程本地变量,
Entry 对象
ThreadLocalMap
中的键值对是弱引用关联的,这意味着当一个 ThreadLocal
对象的强引用被释放后,对应的键值对可能会被垃圾回收。
内存泄漏问题
Thread
、ThreadLocal
、ThreadLocalMap
、Entry
的关系,实线表示强引用,虚线表示弱引用:
当 ThreadLocal
外部强引用被置为 null(ThreadLocalInstance=null
)时,根据可达性分析,ThreadLocal
实例此时没有任何一条链路引用它,所以系统 GC 的时候 ThreadLocal
会被回收。
实际开发中,线程为了复用是不会主动结束的,由于线程迟迟不结束,这些 key 为 null 的 value 就会一直存在一条强引用链:Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value
,无法回收就会造成内存泄漏。因此像数据库连接池这样过大的线程池可能会增加内存泄漏的风险。
为了避免这个问题,在每次使用完 ThreadLocal
之后,最好明确调用 ThreadLocal
的 remove 方法来删除与当前线程关联的值。这样可以确保线程再次使用时不会存储旧的、不再需要的值。
ThreadLocal 的 hashCode
ThreadLocal
的 hashCode
是通过 nextHashCode()
方法获取的,该方法实际上是用 Atmoic 包的 AtomicInteger 加上 0x61c88647 来实现的。
0x61c88647 是一个魔数,用于 ThreadLocal 的哈希码递增。这个值的选择并不是随机的,是一个特定的质数,具有以下特性:
-
质数:它是一个质数,这意味着它不能被除 1 和它本身之外的任何数字整除。
-
黄金比例:这个数字大约等于黄金比例的 32 位浮点表示的一半。黄金比例具有一些有趣的数学特性,其中之一是与斐波那契数列的关系。
-
递增分布:每当创建新的
ThreadLocal
对象时,都会将此值添加到上一个ThreadLocal
的哈希码中。这个递增的步长有助于在哈希表中均匀地分配ThreadLocal
对象。 -
性能优化:通过使用这个特定的值,算法能够确保哈希码的均匀分布,从而减少哈希冲突的可能性。
解决哈希冲突
由于 ThreadLocalMap
基于哈希表实现,那么在存储数据的过程中就可能出现哈希冲突,降低查找的效率。而 ThreadLocalMap
使用开放寻址法(open addressing),通过线性探测法(linear probing)来处理哈希冲突。
当一个桶已经被占用时,ThreadLocalMap
会尝试寻找下一个空闲的桶,一般是往后顺延搜索。这个过程会一直进行,当到哈希表末尾的时候再从 0 开始,循环,直到找到一个空闲的桶来存储新的键值对或者覆盖已有的键值对。
线性探测的开放寻址法也有一些潜在的问题。当哈希桶数组的装载因子(load factor)过高时,即桶中被占用的比例接近或超过阈值时,会导致哈希冲突的频率增加,进而影响性能。为了解决这个问题,ThreadLocalMap
在内部进行了自动扩容,以保证装载因子在一个合理的范围内,提高查找效率。
set 源码
-
replaceStaleEntry
:向 ThreadLocalMap 添加新数据时,可以检查是否有“脏” Entry(key 为 null 的 Entry),并用新的数据替换它 -
cleanSomeSlots
:在某些操作过程中(例如添加、获取等),通过遍历哈希表,删除 key 为 null 的脏 Entry
扩容机制
同 HashMap
,ThreadLocalMap
初始大小为 16,负载因子(哈希表中已经存放的条目数量与哈希表容量的比例)为
2
3
\frac{2}{3}
32,所以哈希表可用大小为:
16
×
2
3
=
10
16\times\frac{2}{3} = 10
16×32=10,即哈希表可用容量为 10。
当哈希表的 size 大于 threshold
(临界值) 的时候,会通过 resize()
方法进行扩容。
新建一个数组,其大小为原来数组长度的两倍,然后遍历旧数组中的 Entry 并将其插入到新的数组中。在扩容的过程中,将脏 Entry 的 value 设为 null,以便被垃圾回收,解决隐藏的内存泄漏问题。移除脏 Entry后,重新确定 Entry 在新数组的位置,然后进行插入。最后,设置新哈希表的 threshhold
和 size
属性。
remove() 方法
-
通过局部变量
tab
获取ThreadLocalMap
的哈希表数组,并获取数组的长度。 -
通过
key.ThreadLocalHashCode & (len-1)
计算给定ThreadLocal 键的哈希索引。确定索引位置。 -
使用开放寻址法遍历哈希表,通过
nextIndex(i, len)
计算下一个索引以处理哈希冲突。 -
如果找到与给定键匹配的条目(
e.get() == key
),执行以下操作:-
清除键:通过调用
e.clear()
方法,将条目的键置为 null。由于 Entry 是 WeakReference 的子类,clear
方法将断开对ThreadLocal 对象的引用,允许垃圾收集器在需要时回收它。 -
清除值:通过调用
expungeStaleEntry(i)
方法,清除该条目的值并对哈希表进行部分清理。该方法的目的是清除哈希表中的无效元素,即那些其键已被垃圾收集的元素。
-
-
结束删除操作:一旦找到并删除了匹配的条目,方法返回。如果遍历整个哈希表都没有找到匹配的键,则该方法不执行任何操作并正常返回。