ThreadLocalMap

ThreadLocalMap

文章目录

  • ThreadLocalMap
    • [1. `ThreadLocal` 的基础结构](#1. ThreadLocal 的基础结构)
      • [1.1 类继承关系](#1.1 类继承关系)
      • [1.2 `ThreadLocalMap` 内部类概述](#1.2 ThreadLocalMap 内部类概述)
      • [1.3 弱引用键的设计理念](#1.3 弱引用键的设计理念)
    • [2. `ThreadLocalMap`的实现机制](#2. ThreadLocalMap的实现机制)
      • [2.1 Entry结构与弱引用](#2.1 Entry结构与弱引用)
      • [2.2 哈希表设计](#2.2 哈希表设计)
      • [2.3 解决哈希冲突的策略](#2.3 解决哈希冲突的策略)
    • [3. `ThreadLocalMap`的关键操作源码分析](#3. ThreadLocalMap的关键操作源码分析)
      • [3.1 set方法实现](#3.1 set方法实现)
      • [3.2 get方法实现](#3.2 get方法实现)
      • [3.3 remove方法实现](#3.3 remove方法实现)
      • [3.4 过期Entry的清理机制](#3.4 过期Entry的清理机制)
    • [4. 核心操作源码分析](#4. 核心操作源码分析)
      • [4.1 `ThreadLocal`.set方法实现](#4.1 ThreadLocal.set方法实现)
      • [4.2 `ThreadLocal.get`方法实现](#4.2 ThreadLocal.get方法实现)
      • [4.3 `ThreadLocal.remove`方法实现](#4.3 ThreadLocal.remove方法实现)
      • [4.4 `ThreadLocal.withInitial`方法](#4.4 ThreadLocal.withInitial方法)
    • [5. 内存泄漏问题深度剖析](#5. 内存泄漏问题深度剖析)
      • [5.1 为什么会发生内存泄漏](#5.1 为什么会发生内存泄漏)
      • [5.3 源码中的防泄漏设计](#5.3 源码中的防泄漏设计)
      • [5.4 清理机制的触发时机](#5.4 清理机制的触发时机)

你是否曾遇到这样的场景:一个对象需要在同一个线程内的多个方法中传递,却不想把它作为参数在每个方法间传来传去?或者你需要保存一个"线程上下文"信息,比如用户身份、事务ID或请求追踪信息?

在并发的世界里,全局变量是危险的。正如一个热闹的宿舍里,把你的私人物品放在公共区域,总会有人"不小心"把它们挪走。ThreadLocal就像是给每个线程发了一个专属保险柜,只有线程自己能存取,线程间互不干扰。

然而,这个看似简单的工具类背后隐藏着巧妙的设计和潜在的风险。Java面试中,ThreadLocal的原理和内存泄漏问题"几乎成了高频标配题。许多开发者对ThreadLocal的印象仅限于"每个线程拥有独立的变量副本",但对其内存模型、实现机制和正确使用姿势知之甚少。

1. ThreadLocal 的基础结构

1.1 类继承关系

我们来看一下ThreadLocal的类定义:

java 复制代码
public class ThreadLocal<T> {
    // 实现代码...
}

ThreadLocal是一个泛型类,类型参数T表示它可以存储任何类型的对象。相比其他并发工具类,ThreadLocal的类结构非常简单,它没有继承任何类,也没有实现任何接口。

这种设计反映了它的专注性------只做一件事:为每个线程提供独立的变量副本。

1.2 ThreadLocalMap 内部类概述

ThreadLocal最核心的部分是其内部私有静态类ThreadLocalMap

java 复制代码
static class ThreadLocalMap {
    /**
     * Entry继承自WeakReference,key是ThreadLocal对象的弱引用
     */
    static class Entry extends WeakReference<ThreadLocal<?>> {
        /** 与这个ThreadLocal关联的值 */
        Object value;

        Entry(ThreadLocal<?> k, Object v) {
            super(k);
            value = v;
        }
    }

    /** 初始容量 - 必须是2的幂 */
    private static final int INITIAL_CAPACITY = 16;

    /** 存放数据的table,大小必须是2的幂 */
    private Entry[] table;

    /** table中的entry数量 */
    private int size = 0;

    /** 扩容阈值,默认为0 */
    private int threshold = 0;
    
    // 其他字段和方法...
}

乍一看,这个ThreadLocalMap结构很像HashMap,都是基于哈希表实现的。但有几个关键区别:

  1. ThreadLocalMapThreadLocal的静态内部类,但它的实例是保存在Thread类中的

  2. Entry的key是ThreadLocal对象的弱引用,这对内存管理非常重要

  3. ThreadLocalMap没有实现Map接口,是一个定制的哈希表

1.3 弱引用键的设计理念

ThreadLocalMap中使用弱引用来保存ThreadLocal对象,这是一个精妙的设计:

java 复制代码
static class Entry extends WeakReference<ThreadLocal<?>> {
    Object value;

    Entry(ThreadLocal<?> k, Object v) {
        super(k);  // 将ThreadLocal对象作为弱引用
        value = v;
    }
}

为什么要用弱引用?想象这样一个场景:

java 复制代码
void someMethod() {
    ThreadLocal<User> userThreadLocal = new ThreadLocal<>();
    userThreadLocal.set(new User("John"));
    
    // 使用userThreadLocal
    
    // 方法结束,userThreadLocal变量不再可访问
}

当方法执行完毕,局部变量userThreadLocal就会被销毁,但是Thread对象中的ThreadLocalMap仍然持有对这个ThreadLocal的引用。如果是强引用,那么即使userThreadLocal变量已经不可访问,ThreadLocal对象也不会被垃圾回收,这可能导致内存泄漏。

通过使用弱引用,一旦外部不再持有ThreadLocal的强引用,垃圾回收器就可以回收这个ThreadLocal对象,即使它仍被ThreadLocalMap引用。

但是,此时Entry中的value还是强引用,不会被自动回收,这就是常说的ThreadLocal内存泄漏的根源。后面我们会详细讨论这个问题。

2. ThreadLocalMap的实现机制

2.1 Entry结构与弱引用

ThreadLocalMap内部使用Entry数组来存储数据:

java 复制代码
private Entry[] table;

static class Entry extends WeakReference<ThreadLocal<?>> {
    Object value;
    
    Entry(ThreadLocal<?> k, Object v) {
        super(k);
        value = v;
    }
}

每个Entry包含:

  • 一个ThreadLocal的弱引用作为key
  • 一个普通的强引用Object作为value

值得注意的是,WeakReference是JDK提供的一种特殊引用类型,当一个对象只剩下弱引用指向它时,垃圾回收器会在下一次GC时回收该对象。

java 复制代码
// 弱引用的基本使用
ThreadLocal<?> threadLocal = new ThreadLocal<>();
WeakReference<ThreadLocal<?>> weakRef = new WeakReference<>(threadLocal);

// 如果此时threadLocal = null,那么weakRef.get()在下一次GC后可能返回null
threadLocal = null;
System.gc();  // 触发GC
ThreadLocal<?> referent = weakRef.get();  // 可能已经是null

这种设计让ThreadLocal实例在不再需要时可以被回收,同时Entry中的value依然存在,直到Thread结束或者手动调用remove()方法。

2.2 哈希表设计

ThreadLocalMap使用开放地址法解决哈希冲突:

java 复制代码
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);
}

private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
    Entry[] tab = table;
    int len = tab.length;

    while (e != null) {
        ThreadLocal<?> k = e.get();
        if (k == key)
            return e;
        if (k == null)
            expungeStaleEntry(i);
        else
            i = nextIndex(i, len);
        e = tab[i];
    }
    return null;
}

private static int nextIndex(int i, int len) {
    return ((i + 1 < len) ? i + 1 : 0);
}

HashMap使用链表或红黑树解决冲突不同,ThreadLocalMap使用线性探测法:如果计算出的槽位已被占用,就继续往后查找,直到找到空槽位或找到目标Entry。

ThreadLocalMap中每个ThreadLocal的哈希值通过一个特殊的原子递增生成器计算:

java 复制代码
private final int threadLocalHashCode = nextHashCode();
private static AtomicInteger nextHashCode = new AtomicInteger();
private static final int HASH_INCREMENT = 0x61c88647;

private static int nextHashCode() {
    return nextHashCode.getAndAdd(HASH_INCREMENT);
}

这里使用的增量0x61c88647是一个神奇的数字,它是斐波那契散列乘数,能够使哈希值均匀分布,减少冲突。

2.3 解决哈希冲突的策略

当发生哈希冲突时,ThreadLocalMap采用线性探测法来解决:

java 复制代码
private void set(ThreadLocal<?> key, Object value) {
    Entry[] tab = table;
    int len = tab.length;
    int i = key.threadLocalHashCode & (len-1);

    for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
        ThreadLocal<?> k = e.get();

        if (k == key) {
            e.value = value;
            return;
        }

        if (k == null) {
            replaceStaleEntry(key, value, i);
            return;
        }
    }

    tab[i] = new Entry(key, value);
    int sz = ++size;
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();
}

这段代码展示了ThreadLocalMap如何处理冲突:

  1. 计算初始槽位i

  2. 如果该位置已被占用,检查key是否相同

    1. 如果key相同,更新value并返回
    2. 如果key为null(已被GC回收),替换这个"陈旧"的Entry
    3. 否则,继续查找下一个位置
  3. 如果找到空槽位,插入新Entry

  4. 增加size并检查是否需要清理或扩容

值得注意的是,在查找过程中如果发现key为null的Entry(表示ThreadLocal已被回收),会主动清理这些Entry,这是一种"顺手清理"的机制,有助于减少内存泄漏。

3. ThreadLocalMap的关键操作源码分析

3.1 set方法实现

set方法是ThreadLocalMap最核心的方法之一,用于设置键值对:

java 复制代码
private void set(ThreadLocal<?> key, Object value) {
    Entry[] tab = table;
    int len = tab.length;
    int i = key.threadLocalHashCode & (len-1);

    for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
        ThreadLocal<?> k = e.get();

        if (k == key) {
            e.value = value;
            return;
        }

        if (k == null) {
            replaceStaleEntry(key, value, i);
            return;
        }
    }

    tab[i] = new Entry(key, value);
    int sz = ++size;
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();
}

这段代码处理了三种情况:

  1. 找到相同的key,更新value

  2. 找到key为null的"陈旧"Entry,替换它

  3. 找到空槽位,插入新Entry

其中第2点很重要,这是ThreadLocalMap自动清理机制的一部分。当检测到key为null的Entry时,调用replaceStaleEntry方法替换并清理:

java 复制代码
private void replaceStaleEntry(ThreadLocal<?> key, Object value, int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;
    Entry e;

    // 向前扫描,查找更多的陈旧Entry
    int slotToExpunge = staleSlot;
    for (int i = prevIndex(staleSlot, len); (e = tab[i]) != null; i = prevIndex(i, len)) {
        if (e.get() == null)
            slotToExpunge = i;
    }

    // 向后遍历
    for (int i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) {
        ThreadLocal<?> k = e.get();

        // 如果找到key,与staleSlot位置的Entry交换
        if (k == key) {
            e.value = value;

            tab[i] = tab[staleSlot];
            tab[staleSlot] = e;

            // 如果在前面的扫描中没有找到陈旧条目,则起点为这里
            if (slotToExpunge == staleSlot)
                slotToExpunge = i;
            
            // 清理陈旧的Entry
            cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
            return;
        }

        // 如果我们还没有找到staleSlot之前的陈旧条目
        if (k == null && slotToExpunge == staleSlot)
            slotToExpunge = i;
    }

    // 如果我们没有找到key,把新的Entry放入staleSlot
    tab[staleSlot].value = null;
    tab[staleSlot] = new Entry(key, value);

    // 如果有其他的陈旧Entry,清理它们
    if (slotToExpunge != staleSlot)
        cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}

这个方法相当复杂,它不仅替换了陈旧的Entry,还会主动清理其他陈旧Entry,以减少内存泄漏的可能性。

3.2 get方法实现

get方法用于获取与ThreadLocal关联的值:

java 复制代码
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);
}

private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
    Entry[] tab = table;
    int len = tab.length;

    while (e != null) {
        ThreadLocal<?> k = e.get();
        if (k == key)
            return e;
        if (k == null)
            expungeStaleEntry(i);
        else
            i = nextIndex(i, len);
        e = tab[i];
    }
    return null;
}

get方法首先计算哈希索引,然后检查该位置的Entry。如果key不匹配或为null,则调用getEntryAfterMiss继续查找。

同样,在查找过程中如果发现key为null的Entry,会调用expungeStaleEntry进行清理,这是另一个"顺手清理"的时机。

3.3 remove方法实现

remove方法用于移除ThreadLocal关联的值:

java 复制代码
private void remove(ThreadLocal<?> key) {
    Entry[] tab = table;
    int len = tab.length;
    int i = key.threadLocalHashCode & (len-1);
    
    for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
        if (e.get() == key) {
            e.clear();  // 清除引用
            expungeStaleEntry(i);  // 清理这个槽位及后续陈旧的Entry
            return;
        }
    }
}

remove方法首先查找key对应的Entry,找到后调用Entry.clear()方法清除ThreadLocal的弱引用,然后调用expungeStaleEntry清理这个槽位及后续可能的陈旧Entry。

这个方法是彻底清除ThreadLocal数据的正确方式,它不仅移除了value的强引用,还处理了可能的连锁效应。

3.4 过期Entry的清理机制

ThreadLocalMap内部实现了多种清理机制来处理过期(陈旧)的Entry:

java 复制代码
private int expungeStaleEntry(int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;

    // 清除指定槽位的Entry
    tab[staleSlot].value = null;
    tab[staleSlot] = null;
    size--;

    // 重新哈希后续的Entry
    Entry e;
    int i;
    for (i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) {
        ThreadLocal<?> k = e.get();
        if (k == null) {
            e.value = null;
            tab[i] = null;
            size--;
        } else {
            int h = k.threadLocalHashCode & (len - 1);
            if (h != i) {
                tab[i] = null;
                
                // 如果需要,重新放置Entry到正确位置
                while (tab[h] != null)
                    h = nextIndex(h, len);
                tab[h] = e;
            }
        }
    }
    return i;
}

private boolean cleanSomeSlots(int i, int n) {
    boolean removed = false;
    Entry[] tab = table;
    int len = tab.length;
    do {
        i = nextIndex(i, len);
        Entry e = tab[i];
        if (e != null && e.get() == null) {
            n = len;
            removed = true;
            i = expungeStaleEntry(i);
        }
    } while ( (n >>>= 1) != 0);
    return removed;
}

private void rehash() {
    expungeStaleEntries();  // 完全清理所有陈旧Entry
    
    // 如果大小仍然超过阈值的3/4,则扩容
    if (size >= threshold - threshold / 4)
        resize();
}

private void expungeStaleEntries() {
    Entry[] tab = table;
    int len = tab.length;
    for (int j = 0; j < len; j++) {
        Entry e = tab[j];
        if (e != null && e.get() == null)
            expungeStaleEntry(j);
    }
}

ThreadLocalMap提供了多个级别的清理机制:

  1. 定点清理:expungeStaleEntry方法清理指定位置的陈旧Entry,并重新哈希后续Entry

  2. 探测式清理:cleanSomeSlots方法在有限步数内查找并清理陈旧Entry

  3. 全量清理:expungeStaleEntries方法扫描整个表,清理所有陈旧Entry

这些清理机制在不同操作中被触发:

  • set操作可能触发探测式清理和rehash
  • get操作在查找过程中可能触发定点清理
  • remove操作总是触发定点清理
  • 当size达到阈值时,会触发全量清理和可能的扩容

通过这些多层次的清理机制,ThreadLocalMap尽量减少内存泄漏的可能性,但并不能完全避免。

4. 核心操作源码分析

4.1 ThreadLocal.set方法实现

ThreadLocal的set方法是我们直接使用的API,看看它是如何实现的:

java 复制代码
public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
}

ThreadLocalMap getMap(Thread t) {
    return t.threadLocals;
}

void createMap(Thread t, T firstValue) {
    t.threadLocals = new ThreadLocalMap(this, firstValue);
}

这个方法非常简洁:

  1. 获取当前线程对象

  2. 尝试获取线程中的ThreadLocalMap

  3. 如果map存在,调用map.set设置值

  4. 如果map不存在,创建一个新的ThreadLocalMap

关键点在于:ThreadLocalMap实例是存储在Thread对象中的,而不是ThreadLocal对象中。每个Thread都有自己的ThreadLocalMap,用于存储所有与该线程关联的ThreadLocal变量。

这就是ThreadLocal能够实现线程隔离的核心机制------数据实际上是存储在线程内部的。

4.2 ThreadLocal.get方法实现

get方法用于获取当前线程下的变量值:

java 复制代码
public T get() {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    return setInitialValue();
}

private T setInitialValue() {
    T value = initialValue();
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
    return value;
}

protected T initialValue() {
    return null;
}

get方法的逻辑是:

  1. 获取当前线程的ThreadLocalMap

  2. 如果map存在,尝试获取this作为key的Entry

  3. 如果Entry存在,返回其value

  4. 否则,调用setInitialValue设置并返回初始值

initialValue方法默认返回null,但可以被子类重写以提供自定义的初始值。这是一个很有用的特性,比如可以用来定义线程本地变量的默认值。

4.3 ThreadLocal.remove方法实现

remove方法用于移除当前线程中的变量:

java 复制代码
public void remove() {
    ThreadLocalMap m = getMap(Thread.currentThread());
    if (m != null)
        m.remove(this);
}

这个方法同样简单明了:获取当前线程的ThreadLocalMap,如果存在则从中移除this对应的Entry。

正确调用remove方法对防止内存泄漏至关重要,尤其是在线程池环境中。线程被重用时,如果不清理ThreadLocal变量,新的任务可能会访问到之前任务设置的值,既造成了内存泄漏,又可能导致逻辑错误。

4.4 ThreadLocal.withInitial方法

从Java 8开始,ThreadLocal增加了一个工厂方法withInitial:

java 复制代码
    return new SuppliedThreadLocal<>(supplier);
}

static final class SuppliedThreadLocal<T> extends ThreadLocal<T> {
    private final Supplier<? extends T> supplier;

    SuppliedThreadLocal(Supplier<? extends T> supplier) {
        this.supplier = Objects.requireNonNull(supplier);
    }

    @Override
    protected T initialValue() {
        return supplier.get();
    }
}

这个方法创建一个特殊的ThreadLocal子类,它的initialValue方法使用提供的Supplier来获取初始值。这提供了一种更简洁的方式来创建带初始值的ThreadLocal

java 复制代码
// 旧方式
ThreadLocal<SimpleDateFormat> dateFormatThreadLocal = new ThreadLocal<SimpleDateFormat>() {
    @Override
    protected SimpleDateFormat initialValue() {
        return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
    }
};

// 新方式
ThreadLocal<SimpleDateFormat> dateFormatThreadLocal = 
    ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));

这种函数式风格更加简洁,也更符合Java 8的编程习惯。

5. 内存泄漏问题深度剖析

5.1 为什么会发生内存泄漏

ThreadLocal的内存泄漏问题是众所周知的,让我们深入分析这个问题:

java 复制代码
Thread实例 -> ThreadLocalMap -> Entry -> value
                             -> Entry -> value
                             -> Entry -> value (key为null)

ThreadLocal对象不再被引用时,Entry中的key变成null(因为是弱引用),但value仍然被强引用。如果线程长时间存活(如线程池中的线程),这些无法访问的value就会一直占用内存,形成泄漏。

具体来说,泄漏发生的条件是:

  1. ThreadLocal对象不再被外部引用

  2. 线程一直存活

  3. 没有手动调用remove方法清理

在这种情况下,虽然ThreadLocal对象会被回收,但其关联的value不会被回收,而且无法再通过正常手段访问到这些value。

5.2 弱引用与内存泄漏的关系

ThreadLocalMap中的Entry继承自WeakReference,key是ThreadLocal的弱引用:

java 复制代码
static class Entry extends WeakReference<ThreadLocal<?>> {
    Object value;
    
    Entry(ThreadLocal<?> k, Object v) {
        super(k);
        value = v;
    }
}

弱引用的特性是:当对象只被弱引用引用时,垃圾回收器会在下一次回收中回收该对象。这意味着当ThreadLocal对象不再被外部强引用时,Entry中的key会变成null。

但这里有一个问题:虽然key会被回收,但value是强引用,不会自动回收。如果没有其他机制处理这些key为null的Entry,就会导致value无法被回收,形成内存泄漏。

ThreadLocalMap的设计者意识到了这个问题,所以在get、set、remove等操作中添加了清理机制,但这些清理是"顺手"进行的,不保证及时清理所有过期Entry。

5.3 源码中的防泄漏设计

ThreadLocalMap中包含多种防止内存泄漏的设计,我们前面已经分析过:

  1. 在set过程中清理:发现key为null的Entry时,会调用replaceStaleEntry或expungeStaleEntry进行清理

  2. 在get过程中清理:getEntryAfterMiss方法中,发现key为null时会调用expungeStaleEntry清理

  3. 在remove时清理:remove方法直接调用expungeStaleEntry清理当前和后续的过期Entry

  4. 在扩容前全量清理:rehash方法首先调用expungeStaleEntries清理所有过期Entry

​ 这些清理机制都是为了减少内存泄漏的可能性,但它们都有一个共同的问题:必须有人调用ThreadLocalMap的方法才会触发清理。如果一个线程不再使用任何ThreadLocal,但线程本身还在运行,那么这些过期的Entry就不会被自动清理。

5.4 清理机制的触发时机

让我们总结一下ThreadLocalMap中清理机制的触发时机:

  1. set时触发 :向ThreadLocalMap中设置值时,可能触发清理

  2. get时触发:获取值时,如果发生哈希冲突,可能触发清理

  3. remove时触发:移除值时,必定触发清理

  4. resize时触发:扩容前,会进行全量清理

这些清理机制虽然可以减少内存泄漏的可能性,但都不能完全避免泄漏。最安全的做法仍然是在不再需要ThreadLocal变量时,显式调用remove方法进行清理。

特别是在线程池环境中,线程结束任务后会被重用,如果不清理ThreadLocal,可能导致数据污染和内存泄漏。因此,ThreadLocal变量的生命周期应该与任务的生命周期一致,而不是与线程的生命周期一致。

相关推荐
zhojiew42 分钟前
service mesh的定制化与性能考量
java·云原生·service_mesh
cdut_suye1 小时前
【Linux系统】从零开始构建简易 Shell:从输入处理到命令执行的深度剖析
java·linux·服务器·数据结构·c++·人工智能·python
-qOVOp-1 小时前
zst-2001 历年真题 设计模式
java·算法·设计模式
张狂年少1 小时前
【十五】Mybatis动态SQL实现原理
java·sql·mybatis
元亓亓亓2 小时前
Java后端开发day46--多线程(二)
java·开发语言
七七小报2 小时前
uniapp-商城-51-后台 商家信息(logo处理)
java·服务器·windows·uni-app
神奇小永哥2 小时前
浅谈装饰模式
java
jiunian_cn2 小时前
【c++】多态详解
java·开发语言·数据结构·c++·visual studio
yousuotu2 小时前
python如何提取Chrome中的保存的网站登录用户名密码?
java·chrome·python
Code哈哈笑2 小时前
【图书管理系统】深度讲解:图书列表展示的后端实现、高内聚低耦合的应用、前端代码讲解
java·前端·数据库·spring boot·后端