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
    }
相关推荐
手握风云-几秒前
数据结构(Java版)第二期:包装类和泛型
java·开发语言·数据结构
许苑向上3 分钟前
Dubbo集成SpringBoot实现远程服务调用
spring boot·后端·dubbo
喵叔哟20 分钟前
重构代码中引入外部方法和引入本地扩展的区别
java·开发语言·重构
IPFoxy66623 分钟前
跨境出海安全:如何防止PayPal账户被风控?
安全
龙信科技24 分钟前
【电子物证培训】龙信助力三明市公安局电子物证取证竞赛
安全
尘浮生26 分钟前
Java项目实战II基于微信小程序的电影院买票选座系统(开发文档+数据库+源码)
java·开发语言·数据库·微信小程序·小程序·maven·intellij-idea
zhd15306915625ff38 分钟前
库卡机器人维护需要注意哪些事项
安全·机器人·自动化
郑祎亦1 小时前
Spring Boot 项目 myblog 整理
spring boot·后端·java-ee·maven·mybatis
不是二师兄的八戒1 小时前
本地 PHP 和 Java 开发环境 Docker 化与配置开机自启
java·docker·php
爱编程的小生1 小时前
Easyexcel(2-文件读取)
java·excel