ThreadLocal, InheritableThreadLocal和TransmittableThreadLocal

ThreadLocal, InheritableThreadLocal和TransmittableThreadLocal

ThreadLocal(TL)

后续部分地方会使用ThraedLocal简称为TL

什么是TL?

ThreadLocal是Java中的一个类, 也称为线程本地变量, 它提供了线程局部变量的功能。每个ThreadLocal对象都可以存储一个线程本地的变量副本,这意味着每个线程都可以独立地访问自己的变量副本,而不会影响其他线程的副本

为什么要使用TL?

为了更加方便大家的理解, 先从"如果不用TL, 那么会出现什么问题"开始, 明白问题所在, 然后引申出TL, 明白这门技术的作用, 然后才什么时候用, 什么时候不用

大家都知道, 并发场景下, 有多个线程同时修改同一个共享变量可能会导致线程安全问题, 有下述方案可以解决

  • 加锁
    • 通过加锁, 让带代码线性排队执行, 例如synchronizedLock

在这里插入图片描述

  • ThreadLocal
    • ThreadLocal采用的是空间换时间
    • 将共享变量拷贝一份到线程的本地, 本地保存了共享变量的拷贝副本
    • 多线程对共享变量修改时, 实际上修改的是变量副本, 从而保证线程安全
      在这里插入图片描述

TL原理

TL结构

内存结构, 先看图
在这里插入图片描述

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  1. Thread类中, 有一个类型Thread.ThreadLocalMap的实例变量, 即每个线程都有自己的ThreadLocalMap
  2. ThreadLocalMap内部维护了Entry数组, 每个Entry代表一个完整对象
    • keyThreadLocal(并不是ThreadLocal本身, 而是它的弱引用)
    • valueThreadLocal的泛型对象值
  3. 线程隔离: 每个线程在往ThreadLocal里放值的时候,都会往自己ThreadLocalMap里存,读也是以ThreadLocal作为引用,在自己的map里找对应的key,从而实现了线程隔离
  4. ThreadLocalMap有点类似HashMap的结构,只是HashMap是由数组+链表实现的,而ThreadLocalMap中并没有链表结构
// java.lang.ThreadLocal.ThreadLocalMap
static class ThreadLocalMap { // 是ThreadLocal的内部类
    
    static class Entry extends WeakReference<ThreadLocal<?>> {
        // 与此ThreadLocal关联的值
        Object value;

        Entry(ThreadLocal<?> k, Object v) {
            super(k);
            value = v;
        }
    }
    // Entry数组
    private Entry[] table;
    
    // ThreadLocalMap的构造器,ThreadLocal作为key
    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);
    }
}
ThreadLocal的实现原理

哪些点要回答到?

  1. 线程和ThreadLocalMap关系?
  2. Entry的K-V存储的是什么?
  3. 并发下怎么就线程隔离了?
  • 每个线程都有一个属于自己的ThreadLocalMap

    • Thread线程类有一个类型为ThreadLocal.ThreadLocalMap的实例变量threadLocals
  • Entry的Key存ThreadLocal本身, Value存ThreadLocal泛型值

    • ThreadLocalMap内部维护着Entry数组,每个Entry代表一个完整的对象,keyThreadLocal本身,valueThreadLocal的泛型值
  • 每个线程读写操作的时, 都根据ThreadLocal去找ThreadLocalMap, 由于Entry中的Key存的是自己, 所以找的时候会找到自己Value进行操作

    • 并发多线程场景下,每个线程Thread,在往ThreadLocal里设置值的时候,都是往自己的ThreadLocalMap里存,读也是以某个ThreadLocal作为引用,在自己的map里找对应的key,从而可以实现了线程隔离

TL的一些设计上问答

为什么不直接使用线程ID作为ThreadLocalMap的Key

如果在你的应用中,一个线程中只使用了一个ThreadLocal对象,那么使用Thread做key也未尝不可

但实际中,一个线程可能使用不止一个ThreadLocal对象,此时存在以下问题

  • 假如一个类中, 有两个ThreadLocal, 如果使用线程ID作为ThreadLocalMap的Key, 通过线程ID无法区分出要获取的是哪个ThreadLocal
  • 因此, 不能使用Thread做key,而应该改成用ThreadLocal对象做key,这样才能通过具体ThreadLocal对象的get方法,轻松获取到你想要的ThreadLocal对象

代码

public class TianLuoThreadLocalTest {
    private static final ThreadLocal<String> threadLocal1 = new ThreadLocal<>();
    private static final ThreadLocal<String> threadLocal2 = new ThreadLocal<>();
}

如下图
在这里插入图片描述

那么ThreadLocal又是怎么做到唯一区分的?

ThreadLocal是通过threadLocalHashCode属性唯一区分的, 每个ThreadLocal对象都有一个hash值threadLocalHashCode,每初始化一个ThreadLocal对象,hash值就增加一个固定的大小0x61c88647

public class ThreadLocal<T> {

    // 初始化容量, 这个值必须是2次幂
    private static final int INITIAL_CAPACITY = 16;

    // hash表(桶), 容量大小必须为2次幂
    private Entry[] table;

    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);
    }

    static class ThreadLocalMap {
        ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
            // 初始化桶, 初始容量为16
            table = new Entry[INITIAL_CAPACITY];
            // hash+ 取模计算桶索引
            int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
			// 在该桶位置放置一个新Entry
            table[i] = new Entry(firstKey, firstValue);
            // size设置为1, 表示桶中有一个K-V对了
            size = 1;
            // 设置触发扩容的阈值为16, 后续用于判断是否需要扩容
            setThreshold(INITIAL_CAPACITY);
        }
    }
}

HASH_INCREMENT的值比较特殊, 被称为斐波那契数 也叫 黄金分割数hash增量为 这个数字,带来的好处就是 hash 分布非常均匀

手动模拟斐波那契数所谓的分布均匀

// 斐波那契数
int hashIncrement = 0x61c88647;
// 容量
int capacity = 16;
int hashCode = 0;
for (int i = 0; i < capacity - 1; i++) {
    hashCode = i * hashIncrement;
    int bucket = hashCode & (capacity - 1);
    System.out.println(i + "在桶中的位置: " + bucket);
}

运行结果如下, 发现分布还是很均匀的,

在插入过程中,根据ThreadLocal对象的hash值,定位到table中的位置i,过程如下:

  1. i位置为null: 就初始化一个Entry对象放在位置i上
  2. 位置i已经有Entry对象: 如果这个Entry对象的key正好是即将设置的key,那么重新设置Entry中的value
  3. 位置i的Entry对象,和即将设置的key没关系,那么只能找下一个空位置;

在get的时候,也会根据ThreadLocal对象的hash值,定位到table中的位置,然后判断该位置Entry对象中的key是否和get的key一致,如果不一致,就判断下一个位置

为什么说TL可能会导致内存泄漏? 因为弱引用吗?怎么解决?

前置知识:

强引用:我们常常new出来的对象就是强引用类型,只要强引用存在,垃圾回收器将永远不会回收被引用的对象,哪怕内存不足的时候

软引用:使用SoftReference修饰的对象被称为软引用,软引用指向的对象在内存要溢出的时候被回收

弱引用:使用WeakReference修饰的对象被称为弱引用,只要发生垃圾回收,若这个对象只被弱引用指向,那么就会被回收

虚引用:虚引用是最弱的引用,在 Java 中使用 PhantomReference 进行定义。虚引用中唯一的作用就是用队列接收对象即将死亡的通知

详细介绍请戳—>Java四大引用类型

问题描述

ThreadLocal内存泄露指的是:ThreadLocal被回收了,ThreadLocalMap Entry的key没有了指向, 但Entry仍然有ThreadRef->Thread->ThreadLoalMap-> Entry value-> Object 这条引用一直存在导致内存泄露

如下图
在这里插入图片描述

ThreadLocalMap使用ThreadLocal弱引用作为key,当ThreadLocal变量被手动设置为null,即一个ThreadLocal没有外部强引用来引用它,当系统GC时,ThreadLocal一定会被回收。这样的话,ThreadLocalMap中就会出现keynullEntry,就没有办法访问这些keynullEntryvalue,如果当前线程再迟迟不结束的话(比如线程池的核心线程),这些keynullEntryvalue就会一直存在一条强引用链:Thread变量 -> Thread对象 -> ThreaLocalMap -> Entry -> value -> Object 永远无法回收, 造成内存泄漏。
在这里插入图片描述

内存泄漏的具体条件

在这里插入图片描述

总结上述, 可以发现导致内存泄露的概率非常低

  1. 只要ThreadLocal没被回收(使用时强引用不置null),那ThreadLocalMap Entry key的指向就不会在GC时断开被回收,也没有内存泄露一说法
  2. ThreadLocalMap是依附在Thread上的,只要Thread销毁,那ThreadLocalMap也会销毁, 非线程池环境下,也不会有长期性的内存泄露问题
  3. ThreadLocal实现下还做了些"保护"措施,get/set/remove方法如果在操作ThreadLocal时,发现key为null,会将其清除掉, 线程池(线程复用)环境下如果调用了上述方法, 那么不会有长期内存泄漏的问题

也就说只要我们用完后即使手动调用remove掉就不会出现这么多问题

模拟ThreadLocal内存泄漏
public class ThreadLocalTestDemo {

    private static final ThreadLocal<MemoryClass> THREAD_LOCAL = new ThreadLocal<>();

    public static void main(String[] args) throws InterruptedException {

        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(5, 5, 1, TimeUnit.MINUTES, new LinkedBlockingQueue<>());

        for (int i = 0; i < 5; ++i) {
            threadPoolExecutor.execute(new Runnable() {
                @Override
                public void run() {
                    MemoryClass memoryClass = new MemoryClass();
                    System.out.println("创建对象: " + memoryClass);
                    THREAD_LOCAL.set(memoryClass);
                    memoryClass = null; // 将对象设置为 null,表示此对象不在使用了
                   // THREAD_LOCAL.remove(); // 如果不手动remove, 就会出现内存泄漏, 如果使用完remove, 那么就不会内存泄漏
                }
            });
            Thread.sleep(1000);
        }
    }

    static class MemoryClass {
        // 100M
        private final byte[] bytes = new byte[100 * 1024 * 1024];
    }
}

设置堆的最大值, 方便测试
在这里插入图片描述

发现OutOfMemoryError异常并提示Java heap space, 即内存泄漏了
在这里插入图片描述

如果使用完了就remove, 那么不会出现内存泄漏的问题, 将下述代码的注释解开, 运行

// 其它代码...
System.out.println("创建对象: " + memoryClass);
THREAD_LOCAL.set(memoryClass);
THREAD_LOCAL.remove(); // 这行代码
// 其它代码...

在这里插入图片描述

综上结论, 因为我们使用了线程池,线程池有很长的生命周期,因此线程池会一直持有memoryClass对象的value值, 即使设置了memoryClass = null, 但是对memoryClas引用还是存在的

内存图如下

i=1, 创建MemoryClass设置到ThreadLocal时
在这里插入图片描述

i=1, memoryClass = null时
在这里插入图片描述

i=2, 创建MemoryClass设置到ThreadLocal时
在这里插入图片描述

i=2, memoryClass = null时
在这里插入图片描述

i=3, 创建MemoryClass设置到ThreadLocal时
在这里插入图片描述

上述的Value的内存无法回收, 所以就出现了内存泄漏

源码跟踪

ThreadLocalMap考虑到上述线程存活周期较长的情况, 导致内存泄露的问题, 在在ThreadLocalget,set,remove方法,都会清除线程ThreadLocalMap里所有keynullvalue

看看源码是怎么做的

set()

ThreadLocalMapset数据(新增或者更新数据)分为好几种情况

**第一种情况:**槽位数据不为空,key值与当前ThreadLocal通过hash计算获取的key值一致

  • 直接更新该槽位的数据
    在这里插入图片描述

第二种情况: 通过hash计算后的槽位对应的Entry数据为空

  • 直接将数据放到该槽位即可
    在这里插入图片描述

第三种情况: 槽位数据不为空,往后遍历过程中,在找到Entrynull的槽位之前,没有遇到key过期的Entry

  • 遍历散列数组,线性往后查找

    • 如果找到Entrynull的槽位,则将数据放入该槽位中

在这里插入图片描述

  • 如果找到的key值相等数据,直接更新即可
    在这里插入图片描述

第四种情况: 槽位数据不为空,往后遍历过程中,在找到Entrynull的槽位之前,遇到key过期的Entry

大致步骤

  1. 初始化探测式清理扫描起始位置

    • 起始位置即staleSlot初始为index=6

      外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  2. staleSlot位置向迭代

    • 以当前staleSlot开始 向前迭代查找,检测是否有过期的Entry数据,如果有则更新过期数据起始扫描下标slotToExpunge。如果找到了过期的数据,继续向前迭代, 直到碰到Entrynull结束
  • slotToExpunge的作用用来判断当前过期槽位staleSlot之前是否还有过期元素, 后续会使用到的

    在这里插入图片描述

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

    在这里插入图片描述

  1. staleSlot位置向迭代

    • 没有没有Key相同的Entry

      • 迭代后没有找到Key相同的Entry

      在这里插入图片描述

      • stableSlotEntryValue置空,将传入的key-value构造一个新的Entry替换table[stableSlot]位置的Entry

        在这里插入图片描述

        在这里插入图片描述

    • 找到Key相同的Entry

      • 替换旧Value

      在这里插入图片描述

      • 和staleSlot交换位置

        在这里插入图片描述

/*
将指定的值与给定的ThreadLocal键相关联
*/
private void set(ThreadLocal<?> key, Object value) {

    // 1. 计算索引: 将传入的key(即ThreadLocal对象)的哈希码并进行按位与操作(key.threadLocalHashCode & (len-1)),计算出在内部Entry数组tab中的目标索引位置。这个计算方式确保了哈希分布均匀且索引值始终在数组范围内
    Entry[] tab = table;
    int len = tab.length;
    int i = key.threadLocalHashCode & (len-1);

    // 2. 遍历查找或替换: 从计算出的索引开始,遍历Entry数组。对于每个Entry,检查其包含的ThreadLocal对象是否与传入的key相等。如果找到匹配项,则直接将对应的Entry的值更新为传入的value,然后结束函数执行
    for (Entry e = tab[i];
         e != null;
         e = tab[i = nextIndex(i, len)]) {
        ThreadLocal<?> k = e.get();

        // 2.1 如果key相等: 直接替换
        if (k == key) {
            e.value = value;
            return;
        }

        // 2.2 如果key为null: 若在遍历过程中遇到Key为null的Entry, 则说明该索引位之前存放的key(threadLocal对象)被回收了,这通常是因为外部将threadLocal变量置为null,
        // 又因为entry对threadLocal持有的是弱引用,一轮GC过后,对象被回收。
        // 这种情况下,既然用户代码都已经将threadLocal置为null,那么也就没打算再通过该对象作为key去取到之前放入threadLocalMap的value, 因此ThreadLocalMap中会使用replaceStaleEntry替换调这个空闲Entry,
        // 将新的键值对存入,并返回
        if (k == null) {
            // replaceStaleEntry()方法解析在后面
            replaceStaleEntry(key, value, i);
            return;
        }
    }

    // 3. 创建新Key: 若遍历完对应索引位置的所有Entry仍没有找到匹配的key,说明向后迭代的过程中遇到了entry为null的情况,则在当前索引位置创建一个新的Entry(new Entry(key, value)),并将新值插入到数组中, 同时会增加size计数器
    tab[i] = new Entry(key, value);
    int sz = ++size;

    // 4. 容量控制和扩容: 调用cleanSomeSlots()做一次启发式清理工作,清理散列数组中Entry的key过期的数据
    // 如果清理工作完成后,未清理到任何数据,且size超过了阈值(数组长度的2/3),进行rehash()操作
    if (!cleanSomeSlots(i, sz) && sz >= threshold) {
        // rehash()中会先进行一轮探测式清理,清理过期key,清理完成后如果size >= threshold - threshold / 4,就会执行真正的扩容逻辑, 扩容逻辑见rehash()分析
        rehash();
    }
}

总结上述

什么情况下桶可以使用?

  1. k = key : 说明是替换操作, 可以使用
  2. k = null: 说明是过期桶, 执行替换逻辑, 占用过期桶
  3. Entry=null: 表示该桶没有存放Entry,直接使用

这里提一嘴replaceStaleEntry和cleanSomeSlots的区别

replaceStaleEntry

  • 触发时机: 调用新值时(即调用set方法), 发现 Entry 中的 Key 已经被垃圾回收器回收(即 Key 为 null),但是 Value 还存在时,会调用此方法进行替换
  • 目的: 将当前线程本地变量表中的这个无效(stale)Entry 替换为新的键值对,同时清理掉旧的、不再需要的 Value 对象引用,以防止内存泄漏
  • replaceStaleEntry 更侧重于在插入新值时立即处理遇到的无效Entry

cleanSomeSlots

  • 触发时机: 通用的清理方法, 通常在进行扩容、初始化或在某些操作后触发,用于清理整个ThreadLocalMap中的一系列连续槽位上的无效Entry(指已被GC回收但Value尚未释放的Entry), 从指定的起始索引开始,按照一定的步长遍历数组的一部分,查找并移除所有 Key 为 null 的 Entry
  • cleanSomeSlots 则是在一定场景下对整个 Map 进行批量的、被动式的无效条目清理
get()

流程图解

在这里插入图片描述

/*
从当前执行线程的上下文中获取与之关联的线程局部变量(ThreadLocal)的值
*/
public T get() {
    // 获取当前线程
    Thread t = Thread.currentThread();
    // 通过当前线程对象,调用getMap(t)方法获取一个ThreadLocalMap对象
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        // 查找与当前ThreadLocal实例相关Entry, 方法里面有key==null的清除逻辑
        ThreadLocalMap.Entry e = map.getEntry(this);
        // 如果找到了对应Entry(即当前线程已经设置了ThreadLocal的值)
        if (e != null) {
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    // 若在当前线程的ThreadLocalMap中没有找到对应的Entry(即当前线程尚未设置过此ThreadLocal的值),则调用setInitialValue()方法。这个方法会先调用initialValue()方法为当前线程生成一个初始值,然后将其存入当前线程的ThreadLocalMap中,并最终返回这个初始值
    return setInitialValue();
}

/*
ThreadLocal的数组中查找与给定Key关联的Entry
*/
private Entry getEntry(ThreadLocal<?> key) {
    // 1. 计算索引: 传入参数key的threadLocalHashCode属性与当前table长度减一的结果进行按位与操作,得到在table数组中的索引位置 i。这种哈希策略是为了将键均匀分布到数组的不同位置,减少哈希冲突
    int i = key.threadLocalHashCode & (table.length - 1);
    // 2. 获取指定索引Entry
    Entry e = table[i];
    // 3. Entry匹配: 直接返回该Entry
    if (e != null && e.get() == key)
        return e;
    else // 如果Entry没有命中, 调用getEntryAfterMiss处理未命中情况, 里面有key==null的清除逻辑
        return getEntryAfterMiss(key, i, e);
}

/*
当Entry未命中时调用
- ThreadLocal<?> key: 要查找的ThreadLocal
- int i: 根据key的hashcode计算得到的初始索引值,用于从哈希表(数组)开始搜索
- Entry e: 哈希表(数组)中初始索引值i对应的条目
*/
private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
    // 1. 初始化变量
    // 获取当前ThreadLocal类维护的哈希表引用
    Entry[] tab = table;
    // 获取哈希表的长度
    int len = tab.length;

    // 2. 循环遍历哈希表
    // 使用while循环遍历以索引i开始的链表结构(这里通过数组模拟的链表,即开放地址法解决冲突),直到遇到空的Entry或者找到与key相等的Entry为止。
    while (e != null) {
        // 获取当前Entry的ThreadLocal
        ThreadLocal<?> k = e.get();
        // 如果找到的键k等于传入的key,说明找到了匹配的Entry,返回这个Entry
        if (k == key)
            return e;
        // Entry的key为null,则表明没有外部引用,且被GC回收,是一个过期Entry
        if (k == null)
            expungeStaleEntry(i); //删除过期的Entry
        else // 否则,使用nextIndex(i, len);方法计算下一个索引位置
            i = nextIndex(i, len);
        // 并更新当前条目e为新索引处的Entry
        e = tab[i];
    }

    // 3. 找不到匹配的Entry
    // 当循环结束仍没有找到与key相等的Entry时,表明哈希表中不存在与给定key关联的Entry,函数返回null
    return null;
}
remove()
/*
从当前线程关联的ThreadLocalMap中移除与当前ThreadLocal实例相关的值
*/
public void remove() {
    // 获取当前执行线程(通过Thread.currentThread()获取)内部持有的ThreadLocalMap对象
    ThreadLocalMap m = getMap(Thread.currentThread());
    // 如果ThreadLocalMap不为null就调用移除
    if (m != null) {
        m.remove(this);
    }
}

/*
从ThreadLocal对象的内部表中移除指定的键, 主要用于清理不再需要的ThreadLocal变量,防止内存泄漏
*/
private void remove(ThreadLocal<?> key) {
    // 获取ThreadLocal类的内部哈希表引用
    Entry[] tab = table;
    int len = tab.length;
    // 计算出键(key)在哈希表中的索引位置
    int i = key.threadLocalHashCode & (len-1);
    // 获取索引i处链表的头节点,然后不断遍历下一个节点直到链表末尾或找到目标Entry对象
    for (Entry e = tab[i];
         e != null;
         e = tab[i = nextIndex(i, len)]) {
        // 检查当前Entry对象的键是否与传入的key相等,如果相等
        if (e.get() == key) {
            // 调用e.clear()方法来清除该Entry对象内部存储的ThreadLocal引用和value,从而释放相关资源
            e.clear();
            // 调用expungeStaleEntry(i)方法进一步清理无效条目并重新调整哈希表状态,比如从链表中移除已经清除的Entry
            // expungeStaleEntry键后续方法分析, 不再赘述
            expungeStaleEntry(i);
            return;
        }
    }
}
replaceStaleEntry()
/*
在set()的过程中, 如果发现Key=null的Entry时(已被GC回收但Value尚未释放的Entry), 则用新提供的K-V替换Entry, 并遍历清理其它Key=null的Entry
- key:要更新或插入的新Key
- value:与新Key关联的Value
- staleSlot:在搜索新key的过程中遇到的第一个Key=null的Entry的索引
*/
private void replaceStaleEntry(ThreadLocal<?> key, Object value,
                               int staleSlot) {
    // 获取当前ThreadLocalMap的table数组和其长度
    Entry[] tab = table;
    int len = tab.length;
    Entry e;

    // slotToExpunge表示开始探测式清理过期数据的开始下标, 默认从当前的staleSlot开始
    int slotToExpunge = staleSlot;
    // 以当前的staleSlot开始,向前迭代查找, for循环一直碰到Entry为null才会结束
    for (int i = prevIndex(staleSlot, len); // prevIndex具体的作用见这段代码后续
         (e = tab[i]) != null;
         i = prevIndex(i, len)) {

        // 如果找到了过期数据(即Key=null的Entry), 那么就将slotToExpunge(探测清理过期数据的开始下标)更新为这个过期数据(Key=null的Entry)所在的索引
        if (e.get() == null)
            slotToExpunge = i;
    }

    // 从staleSlot向后查, 碰到Entry为null的桶结束
    for (int i = nextIndex(staleSlot, len); //  nextIndex具体的作用见这段代码后续
         (e = tab[i]) != null;
         i = nextIndex(i, len)) {
        // 获取当前遍历到的Entry的Key
        ThreadLocal<?> k = e.get();

        // 如果当前遍历到Entry的Key和方法传入的Key, 那么就说明这里是替换逻辑
        if (k == key) {
            // 将当前的Entry的Value更新为方法传入的Value
            e.value = value;
            // 交换当前staleSlot位置中Entry
            tab[i] = tab[staleSlot];
            tab[staleSlot] = e;

            // 如果slotToExpunge == staleSlot, 说明replaceStaleEntry()一开始向前查找过期数据时并未找到过期的Entry数据, 接着向后查找过程中也未发现过期数据
            if (slotToExpunge == staleSlot) {
                // 修改slotToExpunge(开始探测式清理过期数据的下标)为当前循环的index
                slotToExpunge = i;
            }

            // 最后调用cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);进行启发式过期数据清理
            // cleanSomeSlots(): 过期key相关Entry的启发式清理(Heuristically scan)
            // expungeStaleEntry(): 过期key相关Entry的探测式清理
            // 详细的见后续分析
            cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
            return;
        }

        // 执行到这里说明k!=key, 即当前Entry[]没有一个k和方法传入key相同
        
        // 前驱节点扫描时未发现过期数据
        // k == null说明当前遍历的Entry是一个过期数据
        // slotToExpunge == staleSlot说明,一开始的向前查找数据并未找到过期的Entry
        if (k == null && slotToExpunge == staleSlot) {
            // 更新slotToExpunge为当前位置
            slotToExpunge = i;
        }
    }

    // 执行到这里说明往后迭代的整个过程中如果没有找到k == key的数据,且碰到Entry为null的数据, 则结束当前的迭代操作

    // 将staleSlot的value置空(因为这个Entry本来就是过期的, 所以key也是过期的), 此时key和value都空,腾出了空间
    tab[staleSlot].value = null;
    // 将新的数据添加到table[staleSlot] 对应的slot中
    tab[staleSlot] = new Entry(key, value);
	
    // slotToExpunge != staleSlot说明完往前迭代的时候, 发现了其它的过期的Entry
    if (slotToExpunge != staleSlot) {
        // 开启清理数据逻辑
        cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
    }
        
}

/*
将给定的索引值i减1,并对结果取模len,返回新的索引值。如果减1后的值小于0,则返回len-1
*/
private static int prevIndex(int i, int len) {
    return ((i - 1 >= 0) ? i - 1 : len - 1);
}

/*
将给定的索引值i加1,并对结果取模len,返回新的索引值。如果i加1后小于len,则返回i+1;否则返回0
*/
private static int nextIndex(int i, int len) {
    return ((i + 1 < len) ? i + 1 : 0);
}
cleanSomeSlots() 启发式清理
/*
了解决哈希表中可能存在过期条目的问题,它采用启发式方法对部分单元格进行扫描并移除发现的过期Entry,
参数:
i:当前不包含过期Entry的索引位置,从这个位置之后开始进行扫描。
n:控制扫描范围的参数,初始时,将扫描大约log2(n)个单元格。若在扫描过程中发现过期Entry,则将扫描范围扩大至log2(table.length)-1个额外的单元格
返回值: 是否找到并移除了过期Entry,初始值为false
*/
private boolean cleanSomeSlots(int i, int n) {
    boolean removed = false;
    // 获取table, 和len的引用
    Entry[] tab = table;
    int len = tab.length;
    // 循环
    do {
        // 
        i = nextIndex(i, len);
        Entry e = tab[i];
        // 如果当前Entry为null或者已经过期了
        if (e != null && e.get() == null) {
            // 更新n为table的长度,因为后续的槽位可能都与这个槽位冲突
            n = len;
            // 将删除标记更新为true
            removed = true;
            // 调用探测式扫描
            i = expungeStaleEntry(i);
        }
    } while ( (n >>>= 1) != 0); // n >>> 1相当于 n / 2,每次迭代都会检查哈希表的一半
    return removed;
}
expungeStaleEntry() 探测式清理

遍历散列数组,从开始位置向后探测清理过期数据,将过期数据的Entry设置为null,沿途中碰到未过期的数据则将此数据rehash后重新在table数组中定位,如果定位的位置已经有了数据,则会将未过期的数据放到最靠近此位置的Entry=null的桶中,使rehash后的Entry数据距离正确的桶的位置更近一些

探测式清理触发逻辑

  • 正常情况下set

在这里插入图片描述

  • 过段时间出现过期数据

在这里插入图片描述

  • 如果有其他数据set到map中,就会触发探测式清理操作, 同时对没过期的数据进行rehash, 如果rehash计算得到index和现在的index不同就移动, 向后移动到Entry=nullrehash计算index最近的节点

在这里插入图片描述

探测式清理干了什么?

  1. 清理过期的Entry
  2. 碰到正常数据,rehash计算该数据的位置否偏移(即计算的index和当前index是否一致),如果偏移(不一致),则重新计算slot位置
    • 桶位置理论上更接近i= key.hashCode & (tab.len - 1)的位置, 这样会提高整个hash表查询性能

原理图解

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

清空当前staleSlot位置的数据, 往后继续探测

在这里插入图片描述

往后探测,发现index=7的Entry的Key不为null, 对这个keyrehash计算后的index和现在的index相同,不做任何处理, 继续往后探测

在这里插入图片描述

往后探测,发现index=8的Entry的Key不为null,将这个过期Entry进行回收
在这里插入图片描述

如果index的keyrehash计算后的位置为null,那么直接将这个元素移动到这个位置
在这里插入图片描述

如果index的keyrehash后计算的位置不为null, 那么向后寻找不为null且离正确位置最近的槽位
在这里插入图片描述

index的key不为null,执行rehash计算得到的index和当前位置的index一样,不做处理
在这里插入图片描述

当探测式清理遍历到Entry=null时候就退出循环

在这里插入图片描述

具体源码

/*
用于清理线程局部变量(ThreadLocal)哈希表中已过期的Entry
- staleSlot:表示哈希表中已知键为null的槽位索引,即一个已过期Entry的位置
*/
private int expungeStaleEntry(int staleSlot) {
    // 1. 初始化变量
    // 获取当前哈希表(Entry类型数组)
    Entry[] tab = table;
    // 获取哈希表的长度
    int len = tab.length;

    // 2. 删除指定staleSlot位置的Entry, 即将Key和Value都置为null, 同时将size-1
    tab[staleSlot].value = null;
    tab[staleSlot] = null;
    size--;

    // 3. 循环重新哈希,直到遇到null
    Entry e;
    int i;
    // 从staleSlot的下一个槽位开始遍历,直到遇到空槽位(即Entry=null)为止
    for (i = nextIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = nextIndex(i, len)) {
        // 对于每个非空槽位i,获取其中存储的ThreadLocal实例k,检查其是否为null
        ThreadLocal<?> k = e.get();
        // 如果k为null,则清除该槽位中Entry的Value和槽位中Entry,减少size计数器
        if (k == null) {
            e.value = null;
            tab[i] = null;
            size--;
        } else { // 如果key不为null(即Entry没有没有过期)
            // 重新计算当前key的下标位置h,并与当前槽位下标i比较
            int h = k.threadLocalHashCode & (len - 1);
            // 如果h与i不相等,说明这个Entry发生过了hash冲突,将这个Entry进行移动
            if (h != i) {
                // 将当前所在槽位Entry置空
                tab[i] = null;
				
                // 在正确索引h处寻找第一个空槽位(通过nextIndex方法循环查找)
                // 两种情况
                // 1. rehash计算的index上的Entry为nul: 直接将当前所在Entry移动过去
                // 2. rehash计算的index上的Entry不为null: 往后迭代查找第一个Entry=null的槽位,将当前Entry移动到找到的空槽位
                while (tab[h] != null)
                    h = nextIndex(h, len);
                // 将条目e放置在找到的空槽位h处
                tab[h] = e;
            }
        }
    }
    // 4. 返回值
    // 当循环结束时,返回最后一个被检查过的空槽位的索引i,这个索引之后的所有槽位都已被检查并进行了必要的清理或调整
    return i;
}
ThreadLocalMap是怎么扩容的?

桶容量和扩容阈值初始化流程图如下
在这里插入图片描述

ThreadLocalMap的构造方法是延迟加载的,也就是说,只有当线程第一次调用set()时才set()ThreadLocalMap

createMap()
/*
触发阈值, 初始为0
*/
private int threshold;

/*
初始容量
*/
private static final int INITIAL_CAPACITY = 16;

/*
设置调整大小阈值以在最坏的情况下保持2/3的负载系数
*/
private void setThreshold(int len) {
    threshold = len * 2 / 3;
}

/*
set()方法
*/
public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        map.set(this, value);
    } else { // 首次调用set()方法的时候,会初始化并将Value设置进去
        // 初始化ThreadLocalMap()
        createMap(t, value);
    }
}

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

/*
初始化ThreadLocalMap
*/
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
    // 初始桶容量,INITIAL_CAPACITY=16
    table = new Entry[INITIAL_CAPACITY];
    // 计算key的index
    int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
    // 将value设置到桶
    table[i] = new Entry(firstKey, firstValue);
    // size设置为1
    size = 1;
    // 设置桶的扩容阈值,即计算所得初始扩容阈值为10
    setThreshold(INITIAL_CAPACITY);
}

/*
扩容因子取2/3计算扩容阈值
*/
private void setThreshold(int len) {
    threshold = len * 2 / 3;
}
map.set()和resize()

ThreadLocalMap.set()方法的最后,如果执行完启发式清理工作后,未清理到任何数据,且当前散列数组中Entry的数量已经达到了列表的扩容阈值(len * 2 / 3),就开始执行rehash()逻辑

在这里插入图片描述

/*
将变量set到ThreadLocalMap中
*/
public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        // map初始化好后就走这里进行set()
        map.set(this, value);
    } else {
        createMap(t, value);
    }
}

/*
set()方法调用rehash()的时机
1. cleanSomeSlots过程中没有发现过期的Entry
2. 当前size大小大于等于扩容的阈值,初始阈值len * 2/3 = 10(初始容量设置见ThreadLocalMap初始化java.lang.ThreadLocal.ThreadLocalMap#ThreadLocalMap(java.lang.ThreadLocal<?>, java.lang.Object)中的setThreshold(INITIAL_CAPACITY))
*/
private void set(ThreadLocal<?> key, Object value) {
    // 其它代码...

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

/*
尝试清理过期Entry,如果清理后还是超过指定的阈值,就进行扩容
*/
private void rehash() {
    // 先进行一遍探测式清理
    expungeStaleEntries();

    // 如果清理完后, 桶中数据大小仍然还是大于等于阈值的3/4, 那么调用resize扩容
    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];
        // 如果当前Entry不为null且Entry未过期,那么从该位置调用一次探测式清理
        if (e != null && e.get() == null) {
            // expungeStaleEntry之前set()的探测式清理已经分析过,不在赘述
            expungeStaleEntry(j);
        }
    }
}

/*
真正扩容操作
*/
private void resize() {
    // 获取当前的table引用oldTab和其长度oldLen
    Entry[] oldTab = table;
    int oldLen = oldTab.length;
    // 计算扩容的容量, 两倍扩容
    int newLen = oldLen * 2;
    // 创建一个Entry数组, 容量为旧容量的两倍
    Entry[] newTab = new Entry[newLen];
    // count用来计算新table中数据大小
    int count = 0;
	
    // 遍历旧table, 碰到过期的Entry就移除,正常Entry就移动到新table中
    for (Entry e : oldTab) {
        if (e != null) {
            // 获取当前指向的Entry的Key
            ThreadLocal<?> k = e.get();
            // 如果key=null, 表明这个Entry是过期的,将Value置空(GC回收)
            if (k == null) {
                e.value = null;
            } else { // 如果key不为null,表示这是一个正常的Entry
                // 计算这个Entry在新table中位置h
                int h = k.threadLocalHashCode & (newLen - 1);
                // 将Entry放置到新table中
                // 1. 如果h位置没有被其它Entry占用就直接防止在这个Entry
                // 2. 如果h位置有Entry了, 那么往后寻找一个空的槽位, 将这个Entry放置到新寻找到的槽位
                while (newTab[h] != null) {
                    // 更新h位置
                    h = nextIndex(h, newLen);
                }
                newTab[h] = e;
                // count+1
                count++;
            }
        }
    }
	// 更新扩容阈值, setThreshold中计算扩容阈值公式: 新阈值 = newLen * 2 / 3
    setThreshold(newLen);
    // 更新size的大小
    size = count;
    // 新的table newTab 赋值给全局变量table,完成扩容过程
    table = newTab;
}

resize()流程图解

准备添加
在这里插入图片描述

set将12添加到ThreadLocalMap后触发rehash(), 调用expungeStaleEntries()进行探测扫描

在这里插入图片描述

探测式清理后, 发现size还是满足这个条件size >= threshold - threshold / 4 = 10 - 10 / 4 = 8, 触发了resize()操作, 将旧table拷贝到新table中, 完成扩容

在这里插入图片描述

set()的一个总流程
在这里插入图片描述

ThreadLocalMap Hash冲突是如何解决的?

绿色块Entry代表正常数据,黄色块代表Entrykey值为null,已被垃圾回收红色块表示Entrynull, 准备存放的位置

ThreadLocalMap中使用了黄金分隔数来作为hash计算因子,大大减少了Hash冲突的概率,但是仍然会存在冲突

HashMap解决冲突的方法是在数组上构造一个链表结构,冲突的数据挂载到链表上,如果链表长度超过一定数量则会转化成红黑树

ThreadLocalMap并没有链表结构, 也就说不能像HashMap一样哈希冲突时在同一个槽位下挂载

在这里插入图片描述

结论

如果发生了Hash冲突, 那么就会线性查找, 一直找到Entrynull的槽位才会停止查找,将当前元素放入此槽位中。

在迭代的过程中, 比如遇到了Entry不为nullkey值相等的情况,还有Entry中的key值为null的情况等等都会有不同的处理, 在内存泄漏篇中的源码中已经详细阐述了

key是弱引用,GC回收会影响ThreadLocal的正常工作嘛?

ThreadLocalkey既然是弱引用会不会GC贸然把key回收掉,进而影响ThreadLocal的正常使用?

弱引用: 具有弱引用的对象拥有更短暂的生命周期。如果一个对象只有弱引用存在了,则下次GC将会回收掉该对象(不管当前内存空间足够与否)

答案是不会的, 因为是ThreadLocal变量引用了Key, 所以不会被回收掉, 除非你将ThreadLocal置为null

// 创建obj对象
Object obj = new Object();
// 将obj包装成弱引用
WeakReference<Object> weakReference = new WeakReference<>(obj);
System.out.println("GC回收之前,弱引用: " + weakReference.get());

// 通知GC
System.gc();
Thread.sleep(2000);
System.out.println("GC回收之后,弱引用: " + weakReference.get());

// 手动设置为object对象为null
obj = null;
// 通知GC
System.gc();
Thread.sleep(2000);
System.out.println("对象object设置为null,GC回收之后,弱引用: " + weakReference.get());
Entry的Key为什么设置成弱引用?强引用不行?

官方文档是这样子描述的

To help deal with very large and long-lived usages, the hash table entries use WeakReferences for keys

为了应对非常大且持续时间很长的使用,哈希表使用弱引用作为key

在这里插入图片描述

如果Key使用强引用

  • ThreadLocal的对象被回收了,但是ThreadLocalMap还持有ThreadLocal的强引用的话,如果没有手动删除,ThreadLocal就不会被回收,会出现Entry的内存泄漏问题

如果Key使用弱引用

  • ThreadLocal的对象被回收了,因为ThreadLocalMap持有ThreadLocal的弱引用,即使没有手动删除,ThreadLocal也会被回收。value则在下一次ThreadLocalMap调用set,get,remove的时候会被清除

也就说, 使用弱引用作为EntryKey,可以多一层保障:弱引用ThreadLocal不会轻易内存泄漏,对应的value在下一次ThreadLocalMap调用set,get,remove的时候会被清除

实际上,我们的内存泄漏的根本原因是,不再被使用的Entry,没有从线程的ThreadLocalMap中删除。一般删除不再使用的Entry有这两种方式:

  • 使用完ThreadLocal,手动调用remove(),把Entry从ThreadLocalMap中删除
  • ThreadLocalMap的自动清除机制去清除过期EntryThreadLocalMapget(),set()时都会触发对过期Entry的清除)
    • 这种方式不可靠, 如果依赖ThreadLocalMap的清除机制, 就很有可能发生内存泄漏
ThreadLocal和synchronized的区别?
  • 相同之处: ThreadLocalsynchronized关键字都用于处理多线程并发访问变量的问题

  • 不同之处

    • ThreadLocal通过局部变量副本的方式解决不同线程之间的冲突问题,采用的是空间换时间思想
    • synchronized依赖JVM同步机制,通过对象的锁机制保证同一时间只有一个线程访问变量,采用的是时间换空间思想
Entry的Value为什么又不设计成弱引用的?

假定Value被设计成弱引用,此时Entry的Value如果被Entry引用,同时被其它业务系统的引用,如果此时某个业务将其设置成null,导致被GC回收了,可能会导致后续业务系统出现异常

使用场景和注意事项

ThreadLocal很重要一个注意点,就是使用完,要手动调用remove(), 特别是在使用线程池的时候

ThreadLocal的应用场景主要有以下这几种:

  • 使用日期工具类,当用到SimpleDateFormat,使用ThreadLocal保证线性安全
  • 全局存储用户信息(用户信息存入ThreadLocal,那么当前线程在任何地方需要时,都可以使用)
  • 保证同一个线程,获取的数据库连接Connection是同一个,使用ThreadLocal来解决线程安全的问题
  • 使用MDC(Mapped Diagnostic Context)保存日志信息
    • 映射诊断上下文(Mapped Diagnostic Context,简称MDC)是一种工具, 理解成一个日志的扩展,扩展的目的就是给每个线程输出的日志打上一个标记(一个线程只有一个标记且不能重复一般使用uuid即可),这样我们在查看日志时候,就可以根据这个标记来区分调用链路

InheritableThreadLocal(ITL)

ThreadLocal是线程隔离的,在异步场景下是无法给子线程共享父线程中创建的线程副本数据的,如果我们希望父子线程共享数据, 那么就需要用到InheritableThreadLocal(下文称为ITL)

ThreadLocal<String> threadLocal = new ThreadLocal<>();
InheritableThreadLocal<String> inheritableThreadLocal = new InheritableThreadLocal<>();

threadLocal.set("逸一时, 误一世");
inheritableThreadLocal.set("你是一个一个一个");

Thread thread = new Thread(() -> {
    System.out.println("ThreadLocal的Value: " + threadLocal.get());
    System.out.println("InheritableThreadLocal的Value: " + inheritableThreadLocal.get());
});
thread.start();

运行结果如下
在这里插入图片描述

在子线程中,是可以获取到父线程的 InheritableThreadLocal 类型变量的值,但是不能获取到 ThreadLocal 类型变量的值

因为ThreadLocal线程隔离的, 所以无法获取

InheritableThreadLocal又为什么可以访问的到呢?

Thread类中,除了成员变量threadLocals之外,还有另一个成员变量:inheritableThreadLocals。它们两类型是一样的

public class Thread implements Runnable {
    ThreadLocalMap threadLocals = null;
    ThreadLocalMap inheritableThreadLocals = null;
}

Thread类的构造方法中(源码是JDK11的)

private Thread(ThreadGroup g, Runnable target, String name,
               long stackSize, AccessControlContext acc,
               boolean inheritThreadLocals) {
    // 其它代码...
    
	// 获取当前线程的父线程(创建线程的当前线程就是新线程的父线程)
    Thread parent = currentThread();
    
    // 其它代码...
    
    // 如果允许子线程继承ThreadLocal 并且父线程的inheritableThreadLocals不为空
    if (inheritThreadLocals && parent.inheritableThreadLocals != null)
        // 将父线程的inheritableThreadLocals赋值给子线程的inheritableThreadLocals
        this.inheritableThreadLocals =
        ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
    
    // 其它代码...
}

InheritableThreadLocal仍然有缺陷,一般我们做异步化处理都是使用的线程池,而InheritableThreadLocal是在new Thread中赋值的,而线程池是线程复用的逻辑,所以这里会存在问题,可以引入阿里的TransmittableThreadLocal解决

TransmittableThreadLocal(TTL)

InheritableThreadLocal 支持子线程访问父线程,本质上就是在创建线程的时候将父线程中的本地变量值全部复制到子线程中,而在线程池中,线程是复用的,并不用每次新建,那么此时InheritableThreadLocal复制的父线程就变成了第一个执行任务的线程了,即后面所有新建的线程,他们所访问的本地变量都源于第一个执行任务的线程(期间也可能会遭遇到其他线程的修改),从而造成本地变量混乱

阿里的开源的TransmittableThreadLocal(后续称为TTL)为我们提供相关的解决方案

先演示以下使用

InheritableThreadLocal<String> itl = new InheritableThreadLocal<>();

// 单例线程池
ExecutorService executorService = Executors.newSingleThreadExecutor();

itl.set("逸一时,误一世");
executorService.submit(() -> {
    System.out.println("第一次从线程池中获取数据: " + itl.get());
});

itl.set("你是一个一个一个");
executorService.submit(() -> {
    System.out.println("第二次从线程池中获取数据: " + itl.get());
});

运行结果如下图
在这里插入图片描述

分析原因

  1. 这里使用的是单例线程池,固定线程数是1
  2. 首次submit任务的时,线程池会初始化一个线程,创建的时候会调用构造方法初始化,此时会将父线程中ITL复制到子线程中, 所以第一次显示为逸一时,误一世
  3. 第二次submit任务的时,线程池中已经有一个线程, 直接复用,此时没有调用构造方法,所以子线程中的值没有更新,使用的还是旧数据,所以显示的还是逸一时,误一世

如果我们想要第二次输出的是你是一个一个一个,那么就需用阿里开源的TransmittableThreadLocal

引入对应pom文件

<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>transmittable-thread-local</artifactId>
    <version>最新版本</version>
</dependency>

使用方式如下

  • 方式一: JavaAgent自动修改字节码, 启动jar的时候,附加上参数-javaagent:/xx/transmittable-thread-local.jar(参数必须放首位)

  • 方式二: TTL代码封装调用, 如下文所示

// 创建TTL
TransmittableThreadLocal<String> ttl = new TransmittableThreadLocal<>();

// 使用 TtlExecutors.getTtlExecutorService() 包装一下我们自己的线程池,这样才可以 使用 TransmittableThreadLocal 解决在使用线程池等会缓存线程的组件情况下传递ThreadLocal的问题
ExecutorService executorService = TtlExecutors.getTtlExecutorService(
    Executors.newSingleThreadExecutor());

ttl.set("逸一时,误一世");
executorService.submit(() -> {
    System.out.println("第一次从线程池中获取数据: " + ttl.get());
});

ttl.set("你是一个一个一个");
executorService.submit(() -> {
    System.out.println("第二次从线程池中获取数据: " + ttl.get());
});

运行结果如下图
在这里插入图片描述

原理

// com.alibaba.ttl.threadpool.TtlExecutors#getTtlExecutorService
public static ExecutorService getTtlExecutorService(@Nullable ExecutorService executorService) {
    if (TtlAgent.isTtlAgentLoaded() || executorService == null || executorService instanceof TtlEnhanced) {
        return executorService;
    }
    return new ExecutorServiceTtlWrapper(executorService, true);
}


// com.alibaba.ttl.threadpool.ExecutorServiceTtlWrapper
class ExecutorServiceTtlWrapper extends ExecutorTtlWrapper implements ExecutorService, TtlEnhanced {
    private final ExecutorService executorService;

    // 最后我们使用的线程池也就是这个增强后的ExecutorServiceTtlWrapper了。它在这里也实现了ExecutorService接口,那么肯定是实现了里面的所有方法
    ExecutorServiceTtlWrapper(@NonNull ExecutorService executorService, boolean idempotent) {
        super(executorService, idempotent);
        this.executorService = executorService;
    }
    //.....



    // TTL对我们用到Runnable和Callable都进行了包装增强
    @NonNull
    @Override
    public <T> Future<T> submit(@NonNull Callable<T> task) {
        // TtlCallable.get()见com.alibaba.ttl.TtlRunnable#get(java.lang.Runnable, boolean, boolean)
        // 内部其实就是多了层包装
        return executorService.submit(TtlCallable.get(task, false, idempotent));
    }

    @NonNull
    @Override
    public <T> Future<T> submit(@NonNull Runnable task, T result) {
        // TtlRunnable.get()见com.alibaba.ttl.TtlRunnable#get(java.lang.Runnable, boolean, boolean)
        // 内部其实就是多了层包装
        return executorService.submit(TtlRunnable.get(task, false, idempotent), result);
    }

    @NonNull
    @Override
    public Future<?> submit(@NonNull Runnable task) {
        // TtlRunnable.get()见com.alibaba.ttl.TtlRunnable#get(java.lang.Runnable, boolean, boolean)
        // 内部其实就是多了层包装
        return executorService.submit(TtlRunnable.get(task, false, idempotent));
    }
}
TtlRunnable
public final class TtlRunnable implements Runnable, TtlWrapper<Runnable>, TtlEnhanced, TtlAttachments {
    private final AtomicReference<Object> capturedRef;
    private final Runnable runnable;
    // 运行后是否 释放 Ttl 值的引用
    private final boolean releaseTtlValueReferenceAfterRun;

    private TtlRunnable(@NonNull Runnable runnable, boolean releaseTtlValueReferenceAfterRun) {
        // capture() 这里具体调用的是 TransmittableThreadLocal下内部类Transmitter的capture()方法
        // 捕获当前线程中的所有TransmittableThreadLocal和注册的ThreadLocal值。
        this.capturedRef = new AtomicReference<>(capture());
        this.runnable = runnable;
        this.releaseTtlValueReferenceAfterRun = releaseTtlValueReferenceAfterRun;
    }

    @Override
    public void run() {
        // capturedRef是主线程传递下来的ThreadLocal的值
        final Object captured = capturedRef.get();
        if (captured == null || releaseTtlValueReferenceAfterRun && !capturedRef.compareAndSet(captured, null)) {
            throw new IllegalStateException("TTL value reference is released after run!");
        }
        /**
         * 1. backup(备份)是子线程已经存在的ThreadLocal变量
         * 2. 将captured的ThreadLocal值在子线程中set进去
         */
        final Object backup = replay(captured); 
        try {
            // 执行线程的任务
            runnable.run();
        } finally {
            // restore()方法的作用是在run()方法执行完毕后,恢复子线程的原始ThreadLocal状态。
            /*
            为什么需要在run执行完之后调用restore()?
            1. restore里面会主动调用remove()回收,避免内存泄露(会删除子线程新增的TTL)
            2. 子线程中,可能因为执行了某些操作(比如设置了新的ThreadLocal值),使得子线程的ThreadLocal状态与主线程不同, 不调用restore()的话,就会覆盖之前backup备份部分子线程的数据,这样可能在业务上有隐患
            */
            restore(backup);
        }
    }

    //.... 
}
replay()
/**
 * 将快照重放到执行线程
 * @param captured 快照
 */
public static Object replay(Object captured) {
     // 重放capture()方法中捕获的TransmittableThreadLocal和手动注册的ThreadLocal中的值,本质是重新拷贝holder中的所有变量,生成新的快照
    // 笔者注:重放操作一般会在子线程或者线程池中的线程的任务执行的时候调用,因此此时的holder#get()拿到的是子线程的原来就存在的本地线程变量,重放操作就是把这些子线程原有的本地线程变量备份
    final Snapshot capturedSnapshot = (Snapshot) captured;
    return new Snapshot(replayTtlValues(capturedSnapshot.ttl2Value), replayThreadLocalValues(capturedSnapshot.threadLocal2Value));
}

/**
 * 重放TransmittableThreadLocal,并保存执行线程的原值
 */
private static WeakHashMap<TransmittableThreadLocalCode<Object>, Object> replayTtlValues(WeakHashMap<TransmittableThreadLocalCode<Object>, Object> captured) {
    // 新建一个新的备份WeakHashMap,其实也是一个快照
    WeakHashMap<TransmittableThreadLocalCode<Object>, Object> backup = new WeakHashMap<TransmittableThreadLocalCode<Object>, Object>();

    // 这里的循环针对的是子线程,用于获取的是子线程的所有线程本地变量
    for (final Iterator<TransmittableThreadLocalCode<Object>> iterator = holder.get().keySet().iterator(); iterator.hasNext(); ) {
        TransmittableThreadLocalCode<Object> threadLocal = iterator.next();

        // 拷贝holder当前线程(子线程)绑定的所有TransmittableThreadLocal的K-V结构到备份中
        backup.put(threadLocal, threadLocal.get());

         // 清理所有的非捕获快照中的TTL变量,以防有中间过程引入的额外的TTL变量(除了父线程的本地变量)影响了任务执行后的重放操作
            // 简单来说就是:移除所有子线程的不包含在父线程捕获的线程本地变量集合的中所有子线程本地变量和对应的值
            /**
             * 这个问题可以举个简单的例子:
             * static TransmittableThreadLocal<Integer> TTL = new TransmittableThreadLocal<>();
             * 
             * 线程池中的子线程C中原来初始化的时候,在线程C中绑定了TTL的值为10087,C线程是核心线程不会主动销毁。
             * 
             * 父线程P在没有设置TTL值的前提下,调用了线程C去执行任务,那么在C线程的Runnable包装类中通过TTL#get()就会获取到10087,显然是不符合预期的
             *
             * 所以,在C线程的Runnable包装类之前之前,要从C线程的线程本地变量,移除掉不包含在父线程P中的所有线程本地变量,确保Runnable包装类执行期间只能拿到父线程中捕获到的线程本地变量
             *
             * 下面这个判断和移除做的就是这个工作
             */
        if (!captured.containsKey(threadLocal)) {
            iterator.remove();
            threadLocal.superRemove();
        }
    }

	// 重新设置TTL的值到捕获的快照中
    // 其实真实的意图是:把从父线程中捕获的所有线程本地变量重写设置到TTL中,本质上,子线程holder里面的TTL绑定的值会被刷新
    setTtlValuesTo(captured);

    // 回调模板方法beforeExecute
    doExecuteCallback(true);

    return backup;
}

private static WeakHashMap<ThreadLocal<Object>, Object> replayThreadLocalValues( WeakHashMap<ThreadLocal<Object>, Object> captured) {
    final WeakHashMap<ThreadLocal<Object>, Object> backup = new WeakHashMap<ThreadLocal<Object>, Object>();

    for (Map.Entry<ThreadLocal<Object>, Object> entry : captured.entrySet()) {
        final ThreadLocal<Object> threadLocal = entry.getKey();
        backup.put(threadLocal, threadLocal.get());

        final Object value = entry.getValue();
        // 如果值是标记已删除,则清除
        if (value == threadLocalClearMark) threadLocal.remove();
        else threadLocal.set(value);
    }

    return backup;
}
resotre()
/**
 * 恢复备份的原快照
 */
public static void restore( Object backup) {
    // 将之前保存的TTL和threadLocal原来的数据覆盖回去
    final Snapshot backupSnapshot = (Snapshot) backup;
    restoreTtlValues(backupSnapshot.ttl2Value);
    restoreThreadLocalValues(backupSnapshot.threadLocal2Value);
}

private static void restoreTtlValues( WeakHashMap<TransmittableThreadLocalCode<Object>, Object> backup) {
    // 调用执行完后回调接口
    doExecuteCallback(false);

    // 移除子线程新增的TTL
    for (final Iterator<TransmittableThreadLocalCode<Object>> iterator = holder.get().keySet().iterator(); iterator.hasNext(); ) {
        TransmittableThreadLocalCode<Object> threadLocal = iterator.next();
        // 恢复快照时,清除本次传递注册进来,但是原先不存在的 TransmittableThreadLocal
        // 移除掉所有不在备份里面的TTL数据,应该是为了避免内存泄漏吧
        if (!backup.containsKey(threadLocal)) {
            iterator.remove();
            threadLocal.superRemove();
        }
    }

    // 重置为原来的数据(就是恢复回备份前的值)
    setTtlValuesTo(backup);
}

private static void setTtlValuesTo( WeakHashMap<TransmittableThreadLocalCode<Object>, Object> ttlValues) {
    for (Map.Entry<TransmittableThreadLocalCode<Object>, Object> entry : ttlValues.entrySet()) {
        TransmittableThreadLocalCode<Object> threadLocal = entry.getKey();
        // set 的同时,也就将 TransmittableThreadLocal 注册到当前线程的注册表了
        threadLocal.set(entry.getValue());
    }
}
caputrue()和holder()

线程级别的的缓存,每次调用run()前后进行set和还原数据

/**
 * holder - 线程级别缓存,用于保存父线程(或者明确了父线程的子线程)的TTL对象
 * 1. 用WeakHashMap弱引用,为了避免内存泄漏,内存不足时弱引用自动被回收
 * 2. 使用InheritableThreadLocal,作用跟ThreadLocal差不多(因为replay设置值,run(),最后还是会restore还原)
 */
private static InheritableThreadLocal<WeakHashMap<TransmittableThreadLocal<Object>, ?>> holder =
    new InheritableThreadLocal<WeakHashMap<TransmittableThreadLocal<Object>, ?>>() {
    @Override
    protected WeakHashMap<TransmittableThreadLocalCode<Object>, ?> initialValue() {
        // holder默认使用InheritableThreadLocal。初始化的时候会调用initialValue返回一个WeekHashMap
        return new WeakHashMap<TransmittableThreadLocalCode<Object>, Object>();
    }

    @Override
    protected WeakHashMap<TransmittableThreadLocalCode<Object>, ?> childValue(WeakHashMap<TransmittableThreadLocalCode<Object>, ?> parentValue) {
        // 返回的是子线程在第一次get的时候的初始值,如果不重写,默认就是返回父线程的值
        return new WeakHashMap<TransmittableThreadLocalCode<Object>, Object>(parentValue);
    }
};
holder拷贝
  • TTL有一个静态内部类Transmitter ,专门用于操作TTL本地线程缓存的重放、恢复备份、清除等操作。下面以TtlRunnable作为一个入口进行分析
  1. 通过TtlRunnable.get(runnable)进行增强调用
// 调用执行线程的时候包裹(TtlRunnable.get
executor.execute(TtlRunnable.get(runnable));
  1. 会调用到TtlRunnable的构造方法,然后调用到capture()拷贝方法
public final class TtlRunnable implements Runnable, TtlWrapper<Runnable>, TtlEnhanced, TtlAttachments {
    private TtlRunnable(@NonNull Runnable runnable, boolean releaseTtlValueReferenceAfterRun) {
        // capturedRef:拷贝副本的引用
        this.capturedRef = new AtomicReference<Object>(capture());
        this.runnable = runnable;
        this.releaseTtlValueReferenceAfterRun = releaseTtlValueReferenceAfterRun;
    }
    // 其它代码
}

/**
 * capture():拷贝副本
 * - 分为TTL拷贝、ThreadLocal拷贝
 */
public static Object capture() {
    // 抓取快照
    return new Snapshot(captureTtlValues(), captureThreadLocalValues());
}
/** 抓取 TransmittableThreadLocal 的快照 **/
private static WeakHashMap<TransmittableThreadLocalCode<Object>, Object> captureTtlValues() {
    WeakHashMap<TransmittableThreadLocalCode<Object>, Object> ttl2Value = new WeakHashMap<TransmittableThreadLocalCode<Object>, Object>();
    // 主线程和子线程其实都是共用一个holder的,所以主线程new一个TTL并做一个set操作之后,会搞一份数据put到holder中。
    // 这时候就可以进行一个副本的拷贝,遍历holder子线程的值,然后拷贝一份出来
    // eg:主线程用这个ttl.set("我是主线程");,这时候holder就会对应多了要给ttl,并且值是"我是主线程"
    for (TransmittableThreadLocalCode<Object> threadLocal : holder.get().keySet()) {
        // threadLocal.copyValue()默认还是拷贝引用
        ttl2Value.put(threadLocal, threadLocal.copyValue());
    }
    return ttl2Value;
}
/** 抓取 ThreadLocal 的快照 **/
private static WeakHashMap<ThreadLocal<Object>, Object> captureThreadLocalValues() {
    final WeakHashMap<ThreadLocal<Object>, Object> threadLocal2Value = new WeakHashMap<ThreadLocal<Object>, Object>();
    // 从 threadLocalHolder 中,遍历注册的 ThreadLocal,将 ThreadLocal 和 TtlCopier 取出,将值复制到 Map 中
    for (Map.Entry<ThreadLocal<Object>, TtlCopier<Object>> entry : threadLocalHolder.entrySet()) {
        final ThreadLocal<Object> threadLocal = entry.getKey();
        final TtlCopier<Object> copier = entry.getValue();
        // 默认拷贝的是引用
        threadLocal2Value.put(threadLocal, copier.copy(threadLocal.get()));
    }
    return threadLocal2Value;
}
总结
  1. TTL通过增强Runnable,将原本位于构造方法的变量副本的传递,推迟到线程任务执行的时候,即在run()中,这样即使是使用线程池的线程,也能够在使用的时候将线程的变量副本继续传递下去

  2. 通过captured/replay/restor捕获、重放和回放机制,避免了在高并发情况下,线程池在CallerRunsPolicy拒绝策略下,启动的异步线程和主线程在同一线程内执行,因为子线程修改线程的变量副本从而导致业务数据混乱的问题

    • capture方法:抓取线程(线程A)的所有TTL
    • replay方法:在另一个线程(线程B)中,回放在capture方法中抓取的TTL值,并返回 回放前TTL值的备份
    • restore方法:恢复线程B执行replay方法之前的TTL值(即备份)

总结参考部分TTL的Issues

没有特别理解 capture replay restore 这样的方式的好处?

TTL值的抓取、回放和恢复方法(即CRR操作)

TtlCallable

其实和TtlRunable原理一样的

public final class TtlCallable<V> implements Callable<V>, TtlWrapper<Callable<V>>, TtlEnhanced, TtlAttachments {
    private final AtomicReference<Object> capturedRef;
    private final Callable<V> callable;
    // 运行后是否 释放 Ttl 值的引用
    private final boolean releaseTtlValueReferenceAfterCall;    

    private TtlCallable(@NonNull Callable<V> callable, boolean releaseTtlValueReferenceAfterCall) {
        // 抓取TTL值
        this.capturedRef = new AtomicReference<>(capture());
        this.callable = callable;
        this.releaseTtlValueReferenceAfterCall = releaseTtlValueReferenceAfterCall;
    }

    @Override
    @SuppressFBWarnings("THROWS_METHOD_THROWS_CLAUSE_BASIC_EXCEPTION")
    public V call() throws Exception {
        // 获取TTL值
        final Object captured = capturedRef.get();
        if (captured == null || releaseTtlValueReferenceAfterCall && !capturedRef.compareAndSet(captured, null)) {
            throw new IllegalStateException("TTL value reference is released after call!");
        }
		// 回访TTL值,并返回TTL备份
        final Object backup = replay(captured);
        try {
            return callable.call();
        } finally {
            // 恢复TTL备份
            restore(backup);
        }
    }
}

TL,ITL,TTL区别

  1. ThreadLocal:单个线程生命周期强绑定,只能在某个线程的生命周期内对ThreadLocal进行存取,不能跨线程存取

  2. InheritableThreadLocal:在子线程创建的时候,父线程会把threadLocal拷贝到子线中(但是线程池的子线程不会频繁创建,就不会传递信息)

  3. TransmittableThreadLocal:解决了ITL中线程池无法传递线程本地副本的问题,在构造类似Runnable接口对象时进行初始化

参考资料

Java中的ThreadLocal

Java面试必问:ThreadLocal终极篇

⛳面试题-简述并分析ThreadLocalMap的key为什么是弱引用

系列八、key是弱引用,gc垃圾回收时会影响ThreadLocal正常工作吗

ThreadLocal是如何导致内存泄漏的

深入分析 ThreadLocal 内存泄漏问题

面试必备:ThreadLocal详解

Java面试必问,ThreadLocal终极篇

面试官:小伙子,听说你看过ThreadLocal源码?(万字图文深度解析ThreadLocal)

ThreadLocal就是这么简单

对ThreadLocal实现原理的一点思考

JAVA并发-自问自答学ThreadLocal

ThreadLocal夺命11连问

ThreadLocal的介绍+经典应用场景

Java的ThreadLocal,弱引用的Key使用后GC?

ThreadLocal的进化——InheritableThreadLocal

讲透 ThreadLocal 和 InheritableThreadLocal

从ThreadLocal谈到TransmittableThreadLocal,从使用到原理

TransmittableThreadLocal原理解析

还在为线程间上下文传递而烦恼,用TransmittableThreadLocal试试

一文吃透ThreadLocal的前世与今生

ThreadLocal你懂了,你还懂TransmittableThreadLocal嘛?

阿里开源的TransmittableThreadLocal的正确使用姿势

ThreadLocal的进化——TransmittableThreadLocal

待画图

TransmittableThreadLocal解决线程池本地变量问题,原来我一直理解错了❌

待重新研读

通过transmittable-thread-local源码理解线程池线程本地变量传递的原理

全链路追踪必备组件之 TransmittableThreadLocal 详解

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mfbz.cn/a/438592.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

Service Mesh:如何为您的微服务架构带来可靠性和灵活性

在云原生架构中&#xff0c;Service Mesh 技术成为了微服务架构中不可或缺的一环。本文灸哥将和你一起探讨 Service Mesh 技术的原理、功能和实践&#xff0c;帮助架构师和开发人员更好地理解和应用这一关键技术。 1、Service Mesh 技术概述 Service Mesh 又称为服务网格&…

Vue3+ts(day01:Vue3简介、初始化Vue3工程)

学习源码可以看我的个人前端学习笔记 (github.com):qdxzw/frontlearningNotes 觉得有帮助的同学&#xff0c;可以点心心支持一下哈&#xff08;笔记是根据b站上学习的尚硅谷的前端视频【张天禹老师】&#xff0c;记录一下学习笔记&#xff0c;用于自己复盘&#xff0c;有需要学…

事务失效的八种情况!!!!

一、非publi修饰的方法。 /*** 私有方法上的注解&#xff0c;不生效&#xff08;因私有方法Spring扫描不到该方法&#xff0c;所以无法生成代理&#xff09;*/ Transactional private boolean test() {//test code }二、类内部访问。 类内部非直接访问带注解标记的方法 B&…

乐高wedo硬件编程

文章目录&#xff1a; 一&#xff1a;wedo零件认识 1.砖块 2.薄片 3.滑片 4.梁 5.轴 6.销 7.齿轮 8.连接器 9.装饰零件 10.电子零件 11.轮胎 12.柔性零件 二&#xff1a;软件下载安装 1.下载安装 2.使用 三&#xff1a;软件里面的指令模块介绍 绿色部分 …

Leetcode3071. 在矩阵上写出字母 Y 所需的最少操作次数

Every day a Leetcode 题目来源&#xff1a;3071. 在矩阵上写出字母 Y 所需的最少操作次数 解法1&#xff1a;模拟 统计 Y 中的元素出现次数&#xff0c;记到一个长为 3 的数组 cnt1 中。统计不在 Y 中的元素出现次数&#xff0c;记到一个长为 3 的数组 cnt2 中。 计算最多…

Linux-socket套接字

前言 在当今数字化时代&#xff0c;网络通信作为连接世界的桥梁&#xff0c;成为计算机科学领域中至关重要的一部分。理解网络编程是每一位程序员必备的技能之一&#xff0c;而掌握套接字编程则是深入了解网络通信的关键。本博客将深入讨论套接字编程中的基本概念、常见API以及…

(五)关系数据库标准语言SQL

注&#xff1a;课堂讲义使用的数据库 5.1利用SQL语言建立数据库 5.1.1 create Database 5.1.2 create schema...authorization... 创建数据库和创建模式的区别&#xff1a; 数据库是架构的集合&#xff0c;架构是表的集合。但在MySQL中&#xff0c;他们使用的方式是相同的。 …

电脑工作电压是多少你要看看光驱电源上面标的输入电压范围

要确定电脑的工作电压&#xff0c;必须查看电源上标注的输入电压范围。 国内法规规定民用220V电压范围为10%-15%&#xff0c;也就是说通信220V电压正常范围为187--242V&#xff0c;供电设备一般为180V。 --250V电压范围&#xff0c;即正常情况下电脑电源电压不低于187V即可工作…

4.1k star,官方出品的redis桌面管理工具——redislnsight

导航 令人抓狂的大key加载RedisInsight 简介RedisInsight的亮点GitHub 地址安装和使用RedisInsight 下载安装 使用RedisInsight redis数据库可视化直观的CLI&#xff08;Command-Line Interface&#xff09;日志分析和命令分析 结语参考 令人抓狂的大key加载 工欲善其事必先利…

查询IP地址保障电商平台安全

随着电子商务的快速发展&#xff0c;网购已经成为人们日常生活中不可或缺的一部分。然而&#xff0c;网络交易安全一直是人们关注的焦点之一&#xff0c;尤其是在面对日益频发的网络诈骗和欺诈行为时。为了提高网购平台交易的安全性&#xff0c;一种有效的方法是通过查询IP地址…

每周一算法:A*(A Star)算法

八数码难题 题目描述 在 3 3 3\times 3 33 的棋盘上&#xff0c;摆有八个棋子&#xff0c;每个棋子上标有 1 1 1 至 8 8 8 的某一数字。棋盘中留有一个空格&#xff0c;空格用 0 0 0 来表示。空格周围的棋子可以移到空格中。要求解的问题是&#xff1a;给出一种初始布局…

算法优化 —— 注意力机制篇

文章目录 前言一、EMA 高效多尺度注意力机制(ICASSP2023)二、BiFormer: 基于动态稀疏注意力构建高效金字塔网络架构(2023CVPR)前言 提示:这里可以添加本文要记录的大概内容: 一、EMA 高效多尺度注意力机制(ICASSP2023) 原论文链接 代码如下: class EMA(nn.Module

数据结构-线段树

&#x1f4d1;前言 本文主要是【线段树】——线段树简单使用的文章&#xff0c;如果有什么需要改进的地方还请大佬指出⛺️ &#x1f3ac;作者简介&#xff1a;大家好&#xff0c;我是听风与他&#x1f947; ☁️博客首页&#xff1a;CSDN主页听风与他 &#x1f304;每日一句&…

R实现热图与网络图组合并显示显著性

大家好&#xff0c;我是带我去滑雪&#xff01; 热图和网络图分别展示了数据的不同方面。热图可用于显示变量之间的相关性或模式&#xff0c;而网络图则可用于显示节点之间的连接关系。通过将它们组合在一起&#xff0c;可以更全面地展示数据之间的关系和结构。下面开始代码实战…

STM32CubeIDE基础学习-STM32CubeIDE软件快捷键介绍

STM32CubeIDE基础学习-STM32CubeIDE软件快捷键介绍 文章目录 STM32CubeIDE基础学习-STM32CubeIDE软件快捷键介绍前言第1章 查看快捷键方法第2章 设置快捷键方法第3章 常用快捷键示例总结 前言 这个STM32CubeIDE软件使用的是Eclipse框架的开发环境&#xff0c;所以所使用的快捷…

单链表OJ题:LeetCode--141.环形链表

朋友们、伙计们&#xff0c;我们又见面了&#xff0c;本期来给大家解读一下LeetCode中的第141道单链表OJ题&#xff0c;如果看完之后对你有一定的启发&#xff0c;那么请留下你的三连&#xff0c;祝大家心想事成&#xff01; 数据结构与算法专栏&#xff1a;数据结构与算法 个 …

DHCP部署与安全

在当今快速发展的网络世界中&#xff0c;动态主机配置协议&#xff08;DHCP&#xff09;扮演着至关重要的角色。这项技术不仅简化了网络管理&#xff0c;还提高了网络资源的利用率。本文旨在深入探讨DHCP的工作原理、优势以及如何有效部署和保护DHCP服务器。 一、DHCP作用 自…

抖音商家短视频直播流量变现运营SOP地图

【干货资料持续更新&#xff0c;以防走丢】 抖音商家短视频直播流量变现运营SOP地图 部分资料预览 资料部分是网络整理&#xff0c;仅供学习参考。 抖音运营资料合集&#xff08;完整资料包含以下内容&#xff09; 目录 【提升短视频运营效率的专业指南】 高效运营&#xf…

Python笔记(三)—— Python循环语句

循环普遍存在于日常生活中&#xff0c;同样&#xff0c;在程序中&#xff0c;循环功能也是至关重要的基础功能。 循环在程序中同判断一样&#xff0c;也是广泛存在的&#xff0c;是非常多功能实现的基础&#xff1a; bilibili循环轮播图 循环和判断一样&#xff0c;同样是程序…

npm市场发布包步骤

1.打开npm官网npm官网 2.创建自己的账号 3.查看当前npm的镜像源&#xff0c; 如果出现淘宝的镜像源则需要切换成官方的镜像源 npm config get registry //查看镜像源 https://registry.npm.taobao.org/ //淘宝的镜像源 https://registry.npmjs.org/ //官方的镜像源 …
最新文章