Java 面试题:从源码理解 ThreadLocal 如何解决内存泄漏 & ConcurrentHashMap 如何保证并发安全 --xunznux

文章目录

ThreadLocal

ThreadLocal 是 Java 中的一种用于在多线程环境下存储线程局部变量的机制,它可以为每个线程提供独立的变量副本,从而避免多个线程之间的竞争条件。ThreadLocal 在实际应用中,特别是在需要在线程间共享资源的场景下,发挥着重要作用。

ThreadLocal 的基本原理

ThreadLocal 的核心概念是为每个线程维护一个独立的变量副本。当一个线程通过 ThreadLocal 访问某个变量时,实际上访问的是属于该线程的独立副本。ThreadLocal 通过以下几个关键点实现了这一点:

  1. 每个线程持有自己的 ThreadLocalMap
    每个线程内部都有一个 ThreadLocalMap 对象,这个对象存储了 ThreadLocal 变量及其对应的值。ThreadLocalMap 是 Thread 类中的一个成员变量,因此它与线程的生命周期绑定。
  2. ThreadLocalMap** 的结构**:
    ThreadLocalMap 是一个定制的哈希表,它的键是 ThreadLocal 对象,而值是对应的线程局部变量的值。每个线程持有的 ThreadLocalMap 可以存储多个 ThreadLocal 变量。
  3. 变量的存取过程:
    • 当线程调用 ThreadLocal 的 get() 方法时,ThreadLocal 会获取当前线程持有的 ThreadLocalMap,然后通过自身(作为键)从 ThreadLocalMap 中获取变量的值。
    • 当线程调用 ThreadLocal 的 set() 方法时,ThreadLocal 会将变量的值存储到当前线程持有的 ThreadLocalMap 中。

ThreadLocal 的实现细节

  1. ThreadLocal
    ThreadLocal 本身只是提供了一套访问接口,它内部依赖于 ThreadLocalMap 来存储和获取线程局部变量。
  2. ThreadLocalMap的实现
    • ThreadLocalMap 是一个内部类,它的结构类似于一个简化的哈希表。ThreadLocalMap 使用了一个简单的开放地址法来处理哈希冲突。
    • 每个键值对的键是一个 ThreadLocal 的弱引用(WeakReference),这有助于避免内存泄漏:当 ThreadLocal 对象被回收后,键会变成 null,相应的值也会被清理。
  3. ThreadLocalMap的垃圾回收
    由于 ThreadLocalMap 使用了弱引用,ThreadLocal 对象不会阻止其被垃圾回收机制回收。当 ThreadLocal 对象被回收后,ThreadLocalMap 中对应的键会变成 null,但是值仍然会存在。这种情况下,如果不及时清理,可能会导致内存泄漏。
  4. remove()方法
    ThreadLocal 提供了一个 remove() 方法,可以显式地将当前线程持有的 ThreadLocal 变量移除。这有助于防止内存泄漏,特别是在使用线程池的场景下,线程会被重复利用,如果不清理,可能会导致数据污染或内存泄漏。

内存泄漏

实际上 ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用,弱引用的特点是,如果这个对象只存在弱引用,那么在下一次垃圾回收的时候必然会被清理掉。

所以如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候会被清理掉的,这样一来 ThreadLocalMap中使用这个 ThreadLocal 的 key 也会被清理掉。但是,value 是强引用,不会被清理,这样一来就会出现 key 为 null 的 value。

ThreadLocal其实是与线程绑定的一个变量,如此就会出现一个问题:如果没有将ThreadLocal内的变量删除(remove)或替换,它的生命周期将会与线程共存。通常线程池中对线程管理都是采用线程复用的方法,在线程池中线程很难结束甚至于永远不会结束,这将意味着线程持续的时间将不可预测,甚至与JVM的生命周期一致。

如何避免内存泄漏:

  • 每次使用完threadlocal调用remove方法清除
  • 尽可能把threadlocal变量定义为static final,这样可以避免频繁创建实例。
  • 内部优化
    • 调用set()方法,会采用采样清理,全量清理,扩容时还能继续检查
    • 调用get()方法,如果没有命中,向后环形查找时进行清理。
    • 调用remove()方法,清理当前entry,还会向后清理

最好是使用完之后手动调用remove方法,这个方法底层会调用map的remove将 Entry 移除。
因此,ThreadLocal内存泄漏的根源是:由于ThreadLocalMap的生命周期跟Thread一样长,如果没有手动删除对应key就会导致内存泄漏,而不是因为弱引用。弱引用只是保证了 ThreadLocal 会被 GC 自动回收。
最佳做法:每次使用完ThreadLocal,都调用它的remove()方法,清除数据。

源码

复制代码
static class ThreadLocalMap {

	static class Entry extends WeakReference<ThreadLocal<?>> {
	   /** The value associated with this ThreadLocal. */
	    Object value;
	
	    Entry(ThreadLocal<?> k, Object v) {
	        super(k);
	        value = v;
	    }
	}
	
	ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
        table = new Entry[INITIAL_CAPACITY];
        int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
        table[i] = new Entry(firstKey, firstValue);
        size = 1;
        setThreshold(INITIAL_CAPACITY);
    }
    
	private Entry getEntry(ThreadLocal<?> key) {
        int i = key.threadLocalHashCode & (table.length - 1);
        Entry e = table[i];
        if (e != null && e.get() == key)
            return e;
        else
            return getEntryAfterMiss(key, i, e);
    }
}

该Java静态内部类Entry继承自WeakReference<ThreadLocal<?>>,主要用于存储ThreadLocal与其关联对象的弱引用及其对应的值。具体说明如下:

value:存储与ThreadLocal关联的具体值。

构造方法接收一个ThreadLocal对象和一个值,创建一个弱引用来持有ThreadLocal对象,并保存关联值。

使用场景

ThreadLocal 常用于以下场景:

  • 数据库连接管理:每个线程持有一个独立的数据库连接,避免多个线程同时使用同一个连接。
  • 用户上下文信息:在 Web 应用中,每个线程处理一个用户请求,可以通过 ThreadLocal 存储和访问该用户的上下文信息。
  • 线程安全的对象实例:通过 ThreadLocal 为每个线程创建独立的对象实例,避免线程间的竞争条件。
    注意事项
  • 内存泄漏:如果 ThreadLocal 变量不及时清理,可能会导致内存泄漏,尤其是在使用线程池时要特别注意调用 remove() 方法。
  • 适用场景:ThreadLocal 适合用于线程独立的数据存储,不适合跨线程的数据共享。
    总结来说,ThreadLocal 是一种通过在每个线程中创建独立变量副本的方式,来实现线程隔离的工具,它的底层依赖于每个线程持有的 ThreadLocalMap 来存储这些变量副本,从而确保线程间的数据独立性和安全性。

例子:

ThreadLocalMap 中的 Key 和 Value

  • Key:
    • 在 ThreadLocalMap 中,Key 是 ThreadLocal 实例本身。ThreadLocal 对象作为键,指向当前线程所持有的变量。
  • Value:
    • Value 是由 ThreadLocal 对象所关联的值。在本例中,Value 是 Integer,存储的是每个线程独立的计数器值。

每个线程都有一个 ThreadLocalMap,这个 ThreadLocalMap 使用 ThreadLocal 实例作为键来存储和获取对应的线程局部变量值,从而实现数据隔离。

复制代码
public class MultipleThreadLocalExample {

    // 定义两个不同的 ThreadLocal 变量
    private static ThreadLocal<Integer> threadLocalCounter1 = ThreadLocal.withInitial(() -> 0);
    private static ThreadLocal<String> threadLocalCounter2 = ThreadLocal.withInitial(() -> "Initial Value");

    public static void main(String[] args) {
        // 启动一个线程,独立操作两个 ThreadLocal 变量
        Thread thread = new Thread(() -> {
            // 操作第一个 ThreadLocal 变量
            threadLocalCounter1.set(threadLocalCounter1.get() + 10);
            System.out.println(Thread.currentThread().getName() + " - Counter1: " + threadLocalCounter1.get());

            // 操作第二个 ThreadLocal 变量
            threadLocalCounter2.set(threadLocalCounter2.get() + " Updated");
            System.out.println(Thread.currentThread().getName() + " - Counter2: " + threadLocalCounter2.get());
        });
        thread.start();
    }
}

多个 ThreadLocal 变量在同一线程中的工作机制

  • ThreadLocalMap 的键区分:
    • 在同一个线程中,不同的 ThreadLocal 变量对应着不同的键。这些键就是 ThreadLocal 对象本身。因此,即使在同一个线程中,每个 ThreadLocal 实例都能独立存储和访问自己的值,不会与其他 ThreadLocal 变量发生冲突。
  • ThreadLocalMap 的存储结构:
    • ThreadLocalMap 通过一个哈希表来存储键值对。键是 ThreadLocal 对象,值是线程局部变量的实际数据。因此,同一线程中的多个 ThreadLocal 变量不会相互覆盖或混淆。

ConcurrentHashMap 怎么实现线程安全的

采用了CAS算法(compareAndSwapObject)和部分代码使用synchronized锁保证线程安全。

对应的非并发容器:HashMap

目标:代替Hashtable、synchronizedMap,支持复合操作。

原理:JDK6中采用一种更加细粒度的加锁机制 Segment "分段锁",JDK8中采用 volatile + CAS 或者 synchronized 。

**添加元素时首先会判断容器是否为空:

  • 如果为空则使用 volatile 加 CAS 来初始化
  • 如果容器不为空,则根据存储的元素计算该位置是否为空。
    • 如果根据存储的元素计算结果为空,则利用 CAS 设置该节点;
    • 如果根据存储的元素计算结果不为空,则使用 synchronized ,然后,遍历桶中的数据,并替换或新增节点到桶中,最后再判断是否需要转为红黑树,这样就能保证并发访问时的线程安全了**。

如果把上面的执行用一句话归纳的话,就相当于是ConcurrentHashMap通过对头结点加锁来保证线程安全的,锁的粒度相比 Segment 来说更小了 ,发生冲突和加锁的频率降低了,并发操作的性能就提高了。

而且 JDK 1.8 使用的是红黑树优化了之前的固定链表,那么当数据量比较大的时候,查询性能也得到了很大的提升,从之前的 O(n) 优化到了 O(logn) 的时间复杂度。

CAS初始化源码

这段 Java 代码定义了一个名为 sizeCtl 的私有变量,其类型为 int,并且被 transient 和 volatile 修饰符所修饰。这个变量在类中的作用如下:

Table 初始化和调整大小控制:

  • 当 sizeCtl 的值为负数时,表示当前正在进行表的初始化或调整大小操作。

    • -1 表示正在初始化表。
    • 其他负数值(如 -2, -3 等)表示正在进行调整大小的操作,并且 -1 减去该值即为当前活跃的调整大小线程的数量。
      初始表大小或默认值:
  • 如果表(table)为空,并且 sizeCtl 的值为非负数,则该值表示创建表时应使用的初始大小。

  • 如果 sizeCtl 的值为 0,则表示使用默认大小创建表。
    调整大小的阈值:

  • 在初始化之后,sizeCtl 的值表示下次应该调整表大小时元素的数量阈值。
    变量修饰符说明:

  • transient:表示这个变量不会被序列化。当对象被序列化成字节流时,sizeCtl 的值不会被保存。

  • volatile:确保多线程环境下的可见性和有序性,即任何线程对 sizeCtl 的修改都会立即反映到其他线程中。
    通过这种方式,sizeCtl 变量帮助实现了并发哈希表(如 ConcurrentHashMap)在初始化和动态调整大小过程中的控制逻辑。

    private transient volatile int sizeCtl;
    private final Node<K,V>[] initTable() {
    Node<K,V>[] tab; int sc;
    while ((tab = table) == null || tab.length == 0) {
    if ((sc = sizeCtl) < 0)
    Thread.yield(); // lost initialization race; just spin提示调度器当前线程愿意放弃处理器使用权
    else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
    try {
    if ((tab = table) == null || tab.length == 0) {
    int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
    @SuppressWarnings("unchecked")
    Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
    table = tab = nt;
    sc = n - (n >>> 2);
    }
    } finally {
    sizeCtl = sc;
    }
    break;
    }
    }
    return tab;
    }

该函数初始化哈希表,主要功能如下:

  • 检查当前表格是否为空或长度为零。
  • 使用CAS操作安全地初始化表格数组。
  • 如果成功,则根据sizeCtl记录的大小创建新数组,并更新sizeCtl值。

添加元素putVal方法

复制代码
    /**
     * 核心方法,用于处理put和putIfAbsent操作
     * 该方法实现了哈希表的插入逻辑,包括处理哈希冲突和数据结构转换(链表转红黑树)
     * 
     * @param key 键,不能为null
     * @param value 值,不能为null
     * @param onlyIfAbsent 如果为true,则仅在键不存在时进行插入
     * @return 插入前该键对应的旧值,如果没找到则返回null
     */
    final V putVal(K key, V value, boolean onlyIfAbsent) {
        // 检查键值对是否为null,为null则抛出异常
        if (key == null || value == null) throw new NullPointerException();
        // 扩散哈希码,以减少哈希冲突
        int hash = spread(key.hashCode());
        int binCount = 0; // 用于记录链表或红黑树中的元素数量
        // 循环尝试在哈希表中插入值
        for (Node<K,V>[] tab = table;;) {
            Node<K,V> first; int n, i, firstHash;
            // 表为空时初始化哈希表(volatile和CAS)
            if (tab == null || (n = tab.length) == 0)
                tab = initTable();
            // 位置i处的节点为空,直接插入新节点
            else if ((first = tabAt(tab, i = (n - 1) & hash)) == null) {
                // 存储的元素在该node数组的下标位置结果为空,则利用 CAS 设置该节点
                if (casTabAt(tab, i, null,new Node<K,V>(hash, key, value, null)))
                    break; // 成功插入,跳出循环
            }
            // 位置i处的节点处于迁移中,帮助完成迁移
            else if ((firstHash = first.hash) == MOVED)
                tab = helpTransfer(tab, first);
            // 位置i处的节点正常,进行插入或更新操作
            else {
                V oldVal = null;
                // 同步锁,确保线程安全。存储的元素计算结果不为空,则使用 synchronized
                synchronized (first) {
                    // 再次检查节点,防止并发修改
                    if (tabAt(tab, i) == first) {
                        // 链表形式,遍历链表找到键或插入新节点
                        if (firstHash >= 0) {
                            binCount = 1;
                            for (Node<K,V> e = first;; ++binCount) {
                                K ek;
                                // 找到匹配的键,更新值并跳出循环
                                if (e.hash == hash &&
                                    ((ek = e.key) == key ||
                                     (ek != null && key.equals(ek)))) {
                                    oldVal = e.val;
                                    if (!onlyIfAbsent)
                                        e.val = value;
                                    break;
                                }
                                Node<K,V> pred = e;
                                // 链表末尾插入新节点
                                if ((e = e.next) == null) {
                                    pred.next = new Node<K,V>(hash, key,
                                                              value, null);
                                    break;
                                }
                            }
                        }
                        // 红黑树形式,调用红黑树的插入或更新方法
                        else if (first instanceof TreeBin) {
                            Node<K,V> p;
                            binCount = 2;
                            if ((p = ((TreeBin<K,V>)first).putTreeVal(hash, key,
                                                           value)) != null) {
                                oldVal = p.val;
                                if (!onlyIfAbsent)
                                    p.val = value;
                            }
                        }
                    }
                }
                // 链表节点数超过阈值,转换为红黑树
                if (binCount != 0) {
                    if (binCount >= TREEIFY_THRESHOLD)
                        treeifyBin(tab, i);
                    // 找到旧值,返回旧值
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }
        }
        // 更新哈希表大小和修改次数
        addCount(1L, binCount);
        return null; // 未找到旧值,返回null
    }
相关推荐
码熔burning3 分钟前
JVM 面试精选 20 题(续)
jvm·面试·职场和发展
刘一说4 分钟前
CentOS 系统 Java 开发测试环境搭建手册
java·linux·运维·服务器·centos
Victor3569 分钟前
Redis(14)Redis的列表(List)类型有哪些常用命令?
后端
Victor3569 分钟前
Redis(15)Redis的集合(Set)类型有哪些常用命令?
后端
卷福同学11 分钟前
来上海三个月,我在马路边上遇到了阿里前同事...
java·后端
bingbingyihao2 小时前
多数据源 Demo
java·springboot
饕餮争锋3 小时前
设计模式笔记_行为型_访问者模式
笔记·设计模式·访问者模式
2301_801673015 小时前
8.19笔记
网络·安全
不羁。。5 小时前
【撸靶笔记】第七关:GET - Dump into outfile - String
数据库·笔记·oracle
在努力的前端小白7 小时前
Spring Boot 敏感词过滤组件实现:基于DFA算法的高效敏感词检测与替换
java·数据库·spring boot·文本处理·敏感词过滤·dfa算法·组件开发