Java中ThreadLocal与锁机制解决线程不安全问题的对比分析

[id_670394784]

Java中ThreadLocal与锁机制解决线程不安全问题的对比分析

[id_161070937]

前言

ThreadLocal 翻译为中文是线程本地变量。这意味着它是线程中的私有变量。每个线程只能操作自己的私有变量。因此不会导致线程不安全的问题。

所谓的线程不安全指的是,在同一时刻多个线程对同一个全局变量进行写操作的情况(读操作不会引发线程不安全问题)。如果在这种情况下执行的结果与我们预期的结果不一致,就将其称作线程不安全;反之,如果结果与预期一致,就称之为线程安全。

在 Java 语言中解决线程不安全的问题通常有两种手段:

使用锁(使用 synchronized 或 Lock);

使用 ThreadLocal。

锁的实现方案在于,在多线程写入全局变量时,通过依次排队进行写入,这样就能避免线程不安全的问题。例如,当我们利用线程不安全的 SimpleDateFormat 对时间进行格式化时,若要使用锁来解决线程不安全的问题,其实现流程如下:

从上述图片能够看出,借助加锁这种方式,尽管能够把线程不安全的问题给解决掉,然而也随之带来了新的问题。在使用锁的时候,线程需要依次排队进行执行,正因如此,就会产生一定的性能方面的开销。然而,若采用 ThreadLocal 的方式,就是为每个线程创建一个 SimpleDateFormat 对象,如此便能避免排队执行的问题,其实现流程如下图所示:

创建 SimpleDateFormat 会消耗时间和空间。如果线程频繁复用 SimpleDateFormat,那么使用 ThreadLocal 的优势就比较大。反之,如果复用频率不高,就可以考虑使用锁。

然而,在我们运用 ThreadLocal 时,很容易出现内存溢出的状况。比如下面这个事例。

什么是内存溢出?

内存溢出(Out Of Memory,简称 OOM)指的是无用对象(不再使用的对象)持续占有内存,并且无用对象的内存无法及时得到释放,这样就导致了内存空间被浪费,这种行为被称作内存泄露。

内存溢出代码演示

在演示 ThreadLocal 内存溢出问题之前,我们先通过设置“-Xmx50m”这个参数来对 Idea 进行操作。此参数意味着将程序运行时的最大内存设定为 50m 。当程序的运行超出这个数值时,就会引发内存溢出的问题。其设置方法如下:

设置后的最终效果这样的:

因为我使用的是 Idea 的社区版,所以我的界面可能与你的不同。你只需点击“Edit Configurations...”,然后找到“VM options”选项,设置“-Xmx50m”参数即可。

配置完 Idea 之后,接下来我们来实现一下业务代码。我们在代码里会创建一个大对象,此对象中有一个 10m 大小的数组。接着把这个大对象存储在 ThreadLocal 中。之后使用线程池执行大于 5 次的添加任务。由于设置了最大运行内存为 50m,所以理想状况下,执行 5 次添加操作之后,就会出现内存溢出的问题。实现的代码如下:

importJava 中的 util 包下的 concurrent 类中的 LinkedBlockingQueue
importJava 中的 util 包下的 concurrent 类中的 ThreadPoolExecutor 类。
importjava 中的 java.util.concurrent 包下有 TimeUnit 类。

public class ThreadLocalOOMExample {

/**
* 定义一个 10m 大的类
*/

static class MyTask {
创建一个数组,其大小为 10m。这里的单位转换是 1M 等于 1024KB,而 1KB 又等于 1024B,所以 10m 换算后是 10 乘以 1024 乘以 1024B 的大小。
private byte[] bytes = new byte[10 * 1024 * 1024];
}

// 定义 ThreadLocal
private static ThreadLocal taskThreadLocal = new ThreadLocal<>();

[id_919046058]
public static void main(String[] args) throws InterruptedException {
// 创建线程池
ThreadPoolExecutor 是一个线程池执行器,它可以管理和复用线程,提高程序的性能和效率。通过使用 ThreadPoolExecutor,可以避免频繁创建和销毁线程的开销,同时可以控制线程的数量和并发级别,以满足不同的应用需求。它提供了一系列的方法和参数,用于配置和管理线程池,例如设置线程的数量、队列的大小、线程的优先级等。在使用 ThreadPoolExecutor 时,需要注意线程安全和资源管理等问题,以确保程序的正确性和稳定性。
new ThreadPoolExecutor(5, 5, 60,
TimeUnit.SECONDS, new LinkedBlockingQueue<>(100));
// 执行 10 次调用
for (int i = 0; i < 10; i++) {
// 执行任务
执行任务(线程池执行器)。
Thread.sleep(1000);
}
}

/**
* 线程池执行任务
* @param threadPoolExecutor 线程池
*/

private static void executeTask线程池执行器为 threadPoolExecutor 。 {
// 执行任务
threadPoolExecutor.execute(new Runnable() {
@Override
public void run() {
System.out.println("创建对象");
// 创建对象(10M)
MyTask myTask = new MyTask();
// 存储 ThreadLocal
taskThreadLocal.set(myTask);
// 将对象设置为 null,表示此对象不在使用了
myTask = null;
}
});
}
}

以上程序的执行结果如下:

从上述图片能看出,程序执行到添加对象第 5 次时出现了内存溢出问题。因为设置了最大运行内存为 50m,每次循环会占用 10m 内存,且程序启动会占用一定内存,所以在执行到第 5 次添加任务时,就会出现内存溢出的情况。

原因分析

内存溢出的问题以及其解决方案较为简单,重点在于“原因分析”。我们需要借助内存溢出的问题来弄明白,为何 ThreadLocal 会出现这种情况?又是什么原因致使了内存溢出?

要弄明白这个问题,即内存溢出的问题,就得从 ThreadLocal 源码开始着手。因此,我们首先打开在示例中使用到的 set 方法的源码,具体如下所示:

public void set(T value) {
// 得到当前线程
当前线程为 Thread t 。
// 根据线程获取到 ThreadMap 变量
获取到 map t 后,得到 ThreadLocalMap 类型的 map 。
if (map != null)
map.set(this, value); // 将内容存储到 map 中
else
createMap(t, value); // 创建 map 并将值存储到 map 中
}

从上述代码能看出 Thread、ThreadLocalMap 以及 set 方法的关系:每个线程 Thread 都具备一个数据存储容器 ThreadLocalMap。当执行 ThreadLocal.set 方法时,会把要存储的值放置到 ThreadLocalMap 容器里。所以接下来我们来看一下 ThreadLocalMap 的源码。

static class ThreadLocalMap {
// 实际存储数据的数组
private Entry[] table;
// 存数据的方法
private void set(ThreadLocal key, Object value) {
Entry[] tab = table;
int len = tab.length;
inti 等于 key.threadLocalHashCode 与 (len 减 1) 的按位与运算结果。1);
for (Entry e = tab[i];
e != null;
e 等于 tab 中通过 nextIndex(i, len) 计算得到的下一个索引 i 所对应的元素。
ThreadLocal k = e.get();
// 如果有对应的 key 直接更新 value 值
if (k == key) {
e.value = value;
return;
}
// 发现空位插入 value
if (k == null) {
用指定的 key 和 value 替换索引为 i 的陈旧条目。
return;
}
}
// 新建一个 Entry 插入数组中
tab[i] = new Entry(key, value);
int sz = ++size;
// 判断是否需要进行扩容
if如果一些槽位 i 被清理(cleanSomeSlots(i, sz))并且 sz 大于等于阈值(threshold)
rehash();
}
// ... 忽略其他源码
}

从上述源码能看出:ThreadMap 里有一个 Entry[] 数组,此数组用于存储所有数据。Entry 是一个包含 key 和 value 的键值对,其 key 是 ThreadLocal 本身,value 是要存储在 ThreadLocal 中的值。

从上述内容中,我们能够得出关于 ThreadLocal 相关对象的关系图,其具体如下所示:

它们之间的引用关系如下:Thread 有 ThreadLocalMap,ThreadLocalMap 中有 Entry,Entry 包含 Key 和 Value。当使用线程池存储对象时,由于线程池生命周期长,会一直持有 value 值,导致垃圾回收器无法回收 value,进而使内存一直被占用,最终引发内存溢出问题。

解决方案

ThreadLocal 内存溢出的解决办法较为简单。我们在使用完 ThreadLocal 之后,执行 remove 方法,就能够避免内存溢出问题的出现。例如以下代码:

import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public class App {

/**
* 定义一个 10m 大的类
*/

static class MyTask {
// 创建一个 10m 的数组(单位转换是 1M -> 1024KB -> 1024*1024B)
private byte[] bytes = new byte[10 * 1024 * 1024];
}

// 定义 ThreadLocal
private static ThreadLocal taskThreadLocal = new ThreadLocal<>();

// 测试代码
public static void main(String[] args) throws InterruptedException {
// 创建线程池
ThreadPoolExecutor threadPoolExecutor =
new ThreadPoolExecutor(5, 5, 60,
TimeUnit.SECONDS, new LinkedBlockingQueue<>(100));
// 执行 n 次调用
for (int i = 0; i < 10; i++) {
// 执行任务
executeTask(threadPoolExecutor);
Thread.sleep(1000);
}
}

/**
* 线程池执行任务
* @param threadPoolExecutor 线程池
*/

private static void executeTask(ThreadPoolExecutor threadPoolExecutor) {
// 执行任务
threadPoolExecutor.execute(new Runnable() {
@Override
public void run() {
System.out.println("创建对象");
try {
// 创建对象(10M)
MyTask myTask = new MyTask();
// 存储 ThreadLocal
taskThreadLocal.set(myTask);
// 其他业务代码...
} finally {
// 释放内存
taskThreadLocal.remove();
}
}
});
}
}

以上程序的执行结果如下:

从上述结果能够得知,只要在 finally 里执行 ThreadLocal 的 remove 方法,就不会再出现内存溢出的问题了。

remove的秘密

为什么那 remove 方法会拥有如此大的魔力呢?我们将 remove 的源码打开来查看一下。

public void remove() {
获取当前线程的 ThreadLocalMap m ,即通过 getMap(Thread.currentThread()) 来获取。
if (m != null)
m.remove(this);
}

从上述源码能看出,调用 remove 方法后,会直接把 Thread 里的 ThreadLocalMap 对象移除掉。如此一来,Thread 就不再持有 ThreadLocalMap 对象了。所以,即便 Thread 一直存活,也不会因(ThreadLocalMap)内存占用而引发内存溢出问题。

总结

我们通过代码来演示 ThreadLocal 内存溢出的问题。严格来说,内存溢出并非 ThreadLocal 本身的问题,而是由于没有正确使用 ThreadLocal 而导致的问题。要避免 ThreadLocal 出现内存溢出问题,只需在使用完 ThreadLocal 后调用 remove 方法。然而,通过 ThreadLocal 内存溢出问题,我们能搞清楚 ThreadLocal 的具体实现,这有利于我们日后更好地使用 ThreadLocal 以及更好地应对面试。

你可能想看:
« 上一篇
下一篇 »

相关推荐

java中什么是冒泡,冒泡算法

2026年09月06日

27阅读

java中冒号,java双冒号

2026年09月06日

26阅读

java中的->,java中的是什么意思

2026年09月06日

24阅读

java中的super用法,Java中super的说明

2026年09月06日

28阅读

java中反射的应用,java中反射的应用场景

2026年09月06日

25阅读

java中开关的定义,Java关键字有哪些

2026年09月06日

25阅读