ThreadLocal 实践与源码解析

开发
ThreadLocal 是 Java 提供的一种简单而强大的机制,用于实现线程局部变量,即每个线程都有自己的独立副本,互不干扰。

在多线程编程中,共享资源的管理和同步一直是开发人员面临的挑战之一。ThreadLocal 是 Java 提供的一种简单而强大的机制,用于实现线程局部变量,即每个线程都有自己的独立副本,互不干扰。这种机制不仅简化了并发编程中的数据管理,还提高了代码的可读性和可维护性。

一、详解ThreadLocal

1.什么是ThreadLocal?它有什么用?

为了保证特定变量对当前线程可见,我们就可以使用ThreadLocal关键字,ThreadLocal可以为每个线程创建这个变量的副本并存到每个线程的存储空间中(关于这个存储空间后文会展开讲述),从而确保共享变量对每个线程隔离:

2.ThreadLocal基础使用示例

如上文所说ThreadLocal最典型的用法就是维护各个线程各自需要独享变量,基于ThreadLocal为每个将每个线程的id存到线程内部,彼此之间互不影响。

ThreadLocal<String> THREAD_LOCAL = new ThreadLocal<>();

        Thread t1 = new Thread(() -> {
            log.info("t1往THREAD_LOCAL存入变量:[{}]", Thread.currentThread().getName());
            THREAD_LOCAL.set(Thread.currentThread().getName());
            log.info("t1获取THREAD_LOCAL的值为:[{}]", THREAD_LOCAL.get());
        }, "t1");


        Thread t2 = new Thread(() -> {
            log.info("t2往THREAD_LOCAL存入变量:[{}]", Thread.currentThread().getName());
            THREAD_LOCAL.set(Thread.currentThread().getName());
            log.info("t2获取THREAD_LOCAL的值为:[{}]", THREAD_LOCAL.get());
            THREAD_LOCAL.remove();
            log.info("t2删除THREAD_LOCAL的后值为:[{}]", THREAD_LOCAL.get());
        }, "t2");

        t1.start();
        t2.start();

        ThreadUtil.sleep(1,TimeUnit.DAYS);

从输出结果可以看出,两个线程都用THREAD_LOCAL 在自己的内存空间中存储了变量的副本,彼此互相隔离的使用

21:59:51.351 [t2] INFO MultiApplication - t2往THREAD_LOCAL存入变量:[t2]
21:59:51.351 [t1] INFO MultiApplication - t1往THREAD_LOCAL存入变量:[t1]
21:59:51.358 [t1] INFO MultiApplication - t1获取THREAD_LOCAL的值为:[t1]
21:59:51.359 [t2] INFO MultiApplication - t2获取THREAD_LOCAL的值为:[t2]
21:59:51.359 [t2] INFO MultiApplication - t2删除THREAD_LOCAL的后值为:[null]

二、从两种应用场景来介绍一下ThreadLocal

1.日期格式化工具类

我们创建100个线程使用同一个dateFormat完成日期格式化:

 private static Logger logger = LoggerFactory.getLogger(MyThreadLocalDemo3.class);


    static SimpleDateFormat dateFormat=new SimpleDateFormat("yyyy-MM-dd HH:mm:ss:SS");


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

        ExecutorService threadPool = Executors.newFixedThreadPool(100);

        for (int i = 0; i < 100; i++) {
            int finalI = i;
            //线程池中的线程
            threadPool.submit(()->{
                new MyThreadLocalDemo3().caclData(finalI);

            });
        }

        threadPool.shutdown();

    }

    /**
     * 计算second后的日期
     * @param second
     * @return
     */
    public String caclData(int second){
        Date date=new Date(1000*second);
        String dateStr = dateFormat.format(date);
        logger.info("{}得到的时间字符串为:{}",Thread.currentThread().getId(),dateStr);
        return dateStr;
    }

从输出结果可以看出,间隔几毫秒的线程出现相同结果:

基于该问题我们使用ThreadLocal为线程分配SimpleDateFormat副本:

static ThreadLocal<SimpleDateFormat> threadLocal=ThreadLocal.withInitial(()->new SimpleDateFormat("yyyy-MM-dd HH:mm:ss:SS"));


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

        ExecutorService threadPool = Executors.newFixedThreadPool(100);

        for (int i = 0; i < 100; i++) {
            int finalI = i;
            //线程池中的线程
            threadPool.submit(()->{
                new MyThreadLocalDemo3().caclData(finalI);

            });
        }

        threadPool.shutdown();

    }

    /**
     * 计算second后的日期
     * @param second
     * @return
     */
    public String caclData(int second){
        Date date=new Date(1000*second);
        SimpleDateFormat simpleDateFormat = threadLocal.get();
        String dateStr = simpleDateFormat.format(date);
        logger.info("{}得到的时间字符串为:{}",Thread.currentThread().getId(),dateStr);
        return dateStr;
    }

2.服务间调用的线程变量共享

我们日常web开发都会涉及到各种service的调用,例如某个controller需要调用完service1之后再调用service2。因为我们的controller和service都是单例的,所以如果我们希望多线程调用这些controller和service保证共享变量的隔离,也可以用到ThreadLocal。

为了实现这个示例,我们编写了线程获取共享变量的工具类:

public class MyUserContextHolder {
    private static ThreadLocal<User> holder = new ThreadLocal<>();

    public static ThreadLocal<User> getHolder() {
        return holder;
    }
}

service调用链示例如下,笔者创建service1之后,所有线程复用这个service完成了调用,并且在服务间调用直接通过ThreadLocal完成了线程副本共享:

public class MyThreadLocalGetUserId {

    private static Logger logger = LoggerFactory.getLogger(MyThreadLocalGetUserId.class);

    private static ExecutorService threadPool = Executors.newFixedThreadPool(10);

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            int finalI = i;
            MyService1 service1 = new MyService1();
            threadPool.submit(() -> {

                service1.doWork1("username" + (finalI+1));
            });

        }


    }
}


class MyService1 {

    private static Logger logger = LoggerFactory.getLogger(MyThreadLocalGetUserId.class);

    public void doWork1(String name) {

        logger.info("service1 存储userName:" + name);
        ThreadLocal<String> holder = MyUserContextHolder.getHolder();
        holder.set(name);
        MyService2 service2 = new MyService2();
        service2.doWork2();
    }

}

class MyService2 {
    private static Logger logger = LoggerFactory.getLogger(MyThreadLocalGetUserId.class);

    public void doWork2() {
        ThreadLocal<String> holder = MyUserContextHolder.getHolder();

        logger.info("service2 获取userName:" + holder.get());
        MyService3 service3 = new MyService3();
        service3.doWork3();
    }
}


class MyService3 {
    private static Logger logger = LoggerFactory.getLogger(MyThreadLocalGetUserId.class);

    public void doWork3() {
        ThreadLocal<String> holder = MyUserContextHolder.getHolder();

        logger.info("service3获取 userName:" + holder.get());

// 避免oom问题
        holder.remove();
    }
}

从输出结果来看,在单例对象情况下,既保证了同一个线程间变量共享。

也保证了不同线程之间变量的隔离。

三、基于源码了解ThreadlLocal工作原理

1.ThreadlLocal如何做到线程隔离的?

我们下面这段代码为例进行分析,本质上ThreadLocal的withInitial指明了每个线程初始化时设置默认值:

ThreadLocal<SimpleDateFormat> threadLocal = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss:SS"));

当我们执行get操作时,threadLocal 就会为当前线程完成内部map的初始化,然后通过initialValue获取上一步声明的SimpleDateFormat实例,由此保证每个线程内部都有一个独有的SimpleDateFormat:

对应的我们给出ThreadlLocal的get的源码,整体逻辑与上述差不多,即初始化线程内部的map,然后通过setInitialValue调用initialValue创建初始值存到线程的map中:

public T get() {
  //获取当前线程
        Thread t = Thread.currentThread();
        //拿到当前线程中的map
        ThreadLocalMap map = getMap(t);
        //如果map不为空则取用当前这个ThreadLocal作为key取出值,否则通过setInitialValue完成ThreadLocal初始化
        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() {
  //执行initialValue为当前线程创建变量value,在这里也就是我们要用的SimpleDateFormat 
        T value = initialValue();
        //获取当前线程map,有则直接以ThreadLocal为key将SimpleDateFormat 设置进去,若没有先创建再设置
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
//返回SimpleDateFormat 
        return value;
    }

2.ThreadLocalMap有什么特点?和HashMap有什么区别

我们通过源码查看到这个map为ThreadLocalMap,它是由一个个Entry 构成的数组:

 private Entry[] table;

并且每个Entry 的key是弱引用,这就意味着当触发GC时,Entry 的key也就是ThreadLocal就会被回收。

 static class Entry extends WeakReference<ThreadLocal<?>> {
            /** The value associated with this ThreadLocal. */
            Object value;

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

除上面所说,thread中的map和hashmap还有一个不同点就是数据结构,因为threadLocal的适用场景特殊,所以大部分情况下其内部存储空间不会存储太多元素,所以出于简单的考虑,线程中的map本质上就是一个数组,一旦发生冲突则直接通过线性探测法找到数组中空闲的位置将值存入:

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

               //......
            }
   //找到合适的位置将元素存入
            tab[i] = new Entry(key, value);
            //更新一下容量信息
            int sz = ++size;
            //......
        }

四、ThreadLocal使用注意事项

1.内存泄漏问题

我们有下面这样一段web代码,每次请求test0就会像线程池中的线程存一个4M的byte数组:

RestController
public class TestController {
    final static ThreadPoolExecutor poolExecutor = new ThreadPoolExecutor(100, 100, 1, TimeUnit.MINUTES,
            new LinkedBlockingQueue<>());// 创建线程池,通过线程池,保证创建的线程存活

    final static ThreadLocal<Byte[]> localVariable = new ThreadLocal<Byte[]>();// 声明本地变量

    @RequestMapping(value = "/test0")
    public String test0(HttpServletRequest request) {
        poolExecutor.execute(() -> {
            Byte[] c = new Byte[4* 1024* 1024];
            localVariable.set(c);// 为线程添加变量

        });
        return "success";
    }

   
}

我们将这个代码打成jar包部署到服务器上并启动

java -jar -Xms100m -Xmx100m # 调整堆内存大小
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heapdump.hprof  # 表示发生OOM时输出日志文件,指定path为/tmp/heapdump.hprof
-XX:+PrintGCTimeStamps -XX:+PrintGCDetails -Xloggc:/tmp/heapTest.log # 打印日志、gc时间以及指定gc日志的路径
demo-0.0.1-SNAPSHOT.jar

只需频繁调用几次,就会输出OutOfMemoryError

Exception in thread "pool-1-thread-5" java.lang.OutOfMemoryError: Java heap space
        at com.example.jstackTest.TestController.lambda$test0$0(TestController.java:25)
        at com.example.jstackTest.TestController$$Lambda$582/394910033.run(Unknown Source)
        at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
        at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
        at java.lang.Thread.run(Thread.java:748)

问题的根本原因是我们没有及时回收Thread从ThreadLocal中得到的变量副本。因为我们的使用的线程是来自线程池中,所以线程使用结束后并不会被销毁,这就使得ThreadLocal中的变量副本会一直存储与线程池中的线程中,导致OOM。

可能你会问了,不是说Java有GC回收机制嘛?为什么还会出现Thread中的ThreadLocalMap的value不会被回收呢?

我们上文提到ThreadLocal得到值,都会以ThreadLocal为key,ThreadLocal的initialValue方法得到的value作为值生成一个entry对象,存到当前线程的ThreadLocalMap中。 而我们的Entry的key是一个弱引用,一旦我们使用的threadLocal临时变量用完被垃圾回收之后,这个key就会因为弱引用的原因被回收,而我们这个key所对应的value仍然被线程池中的线程的强引用引用着,所以就迟迟无法回收,随着时间推移每个线程都出现这种情况导致OOM。

所以我们每个线程使用完ThreadLocal之后,一定要使用remove方法清楚ThreadLocalMap中的value:

localVariable.remove()

从源码中可以看到remove方法会遍历当前线程map然后将强引用之间的联系切断,确保下次GC可以回收掉可以无用对象。

private void remove(ThreadLocal<?> key) {
            Entry[] tab = table;
            int len = tab.length;
            int i = key.threadLocalHashCode & (len-1);
            //定位,并将entry清除
            for (Entry e = tab[i];
                 e != null;
                 e = tab[i = nextIndex(i, len)]) {
                if (e.get() == key) {
                    e.clear();
                    expungeStaleEntry(i);
                    return;
                }
            }
        }

2.空指针问题

使用ThreadLocal存放包装类的时候也需要注意添加初始化方法,否则在拆箱时可能会出现空指针问题。

 private  static ThreadLocal<Long> threadLocal = new ThreadLocal<>();


    public static void main(String[] args) {
        Long num = threadLocal.get();
        long sum=1+num;

    }

输出错误:

Exception in thread "main" java.lang.NullPointerException
 at com.guide.base.MyThreadLocalNpe.main(MyThreadLocalNpe.java:11)

解决方式:

 private  static ThreadLocal<Long> threadLocal = ThreadLocal.withInitial(()->new Long(0));

3.线程重用问题

这个问题和OOM问题类似,在线程池中服用同一个线程未及时清理,导致下一次HTTP请求时得到上一次ThreadLocal存储的结果。


ThreadLocal<String> threadLocal = ThreadLocal.withInitial(() -> null);


 * 线程池中使用threadLocal示例
     *
     * @param accountCode
     * @return
     */
    @GetMapping("/account/getAccountByCode/{accountCode}")
    @SentinelResource(value = "getAccountByCode")
    ResultData<Map<String, Object>> getAccountByCode(@PathVariable(value = "accountCode") String accountCode) throws InterruptedException {
        Map<String, Object> result = new HashMap<>();
        
        CountDownLatch countDownLatch = new CountDownLatch(1);
        
        
        threadPool.submit(() -> {

            String before = Thread.currentThread().getName() + ":" + threadLocal.get();
            log.info("before:" + before);
            result.put("before", before);

            log.info("调用getByCode,请求参数:{}", accountCode);
            QueryWrapper<Account> queryWrapper = new QueryWrapper<>();
            queryWrapper.eq("account_code", accountCode);
            Account account = accountService.getOne(queryWrapper);

            String after = Thread.currentThread().getName() + ":" + account.getAccountName();
            result.put("after", account.getAccountName());
            log.info("after:" + after);

            threadLocal.set(account.getAccountName());
            
            //完成计算后,使用countDown按下倒计时门闩,通知主线程可以执行后续步骤
            countDownLatch.countDown();

        });

        //等待上述线程池完成
        countDownLatch.await();

        return ResultData.success(result);
    }

从输出结果可以看出,我们第二次进行HTTP请求时,threadLocal第一get获得了上一次请求的值,出现脏数据。

C:\Users\xxx>curl http://localhost:9000/account/getAccountByCode/demoData
{"status":100,"message":"操作成功","data":{"before":"pool-2-thread-1:null","after":"pool-2-thread-1:demoData"},"success":true,"timestamp":1678410699943}
C:\Users\xxx>curl http://localhost:9000/account/getAccountByCode/Zsy
{"status":100,"message":"操作成功","data":{"before":"pool-2-thread-1:demoData","after":"pool-2-thread-1:zsy"},"success":true,"timestamp":1678410707473}

解决方法也很简单,手动添加一个threadLocal的remove方法即可:

@GetMapping("/account/getAccountByCode/{accountCode}")
    @SentinelResource(value = "getAccountByCode")
    ResultData<Map<String, Object>> getAccountByCode(@PathVariable(value = "accountCode") String accountCode) throws InterruptedException {
        Map<String, Object> result = new HashMap<>();

        CountDownLatch countDownLatch = new CountDownLatch(1);

        try {
            threadPool.submit(() -> {

                String before = Thread.currentThread().getName() + ":" + threadLocal.get();
                log.info("before:" + before);
                result.put("before", before);

                log.info("调用getByCode,请求参数:{}", accountCode);
                QueryWrapper<Account> queryWrapper = new QueryWrapper<>();
                queryWrapper.eq("account_code", accountCode);
                Account account = accountService.getOne(queryWrapper);

                String after = Thread.currentThread().getName() + ":" + account.getAccountName();
                result.put("after", after);
                log.info("after:" + after);

                threadLocal.set(account.getAccountName());

                //完成计算后,使用countDown按下倒计时门闩,通知主线程可以执行后续步骤
                countDownLatch.countDown();

            });
        } finally {
            threadLocal.remove();
        }


        //等待上述线程池完成
        countDownLatch.await();

        return ResultData.success(result);
    }

五、ThreadLocal的不可继承性

1.通过代码证明ThreadLocal的不可继承性

如下代码所示,ThreadLocal子线程无法拿到主线程维护的内部变量

/**
 * ThreadLocal 不具备可继承性
 */
public class ThreadLocalInheritTest {
    private static ThreadLocal<String> THREAD_LOCAL = new ThreadLocal<>();

    private static Logger logger = LoggerFactory.getLogger(ThreadLocalInheritTest.class);

    public static void main(String[] args) {
        THREAD_LOCAL.set("mainVal");
        logger.info("主线程的值为: " + THREAD_LOCAL.get());

        new Thread(() -> {
            try {
                //睡眠3s确保上述逻辑运行
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            logger.info("子线程获取THREAD_LOCAL的值为:[{}]", THREAD_LOCAL.get());
        }).start();
    }

}

2.使用InheritableThreadLocal实现主线程内部变量继承

如下所示,我们将THREAD_LOCAL 改为InheritableThreadLocal类即可解决问题。

/**
 * ThreadLocal 不具备可继承性
 */
public class ThreadLocalInheritTest {

    private static ThreadLocal<String> THREAD_LOCAL = new InheritableThreadLocal<>();

    private static Logger logger = LoggerFactory.getLogger(ThreadLocalInheritTest.class);

    public static void main(String[] args) {
        THREAD_LOCAL.set("mainVal");
        logger.info("主线程的值为: " + THREAD_LOCAL.get());

        new Thread(() -> {
            try {
                //睡眠3s确保上述逻辑运行
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            logger.info("子线程获取THREAD_LOCAL的值为:[{}]", THREAD_LOCAL.get());
        }).start();
    }

}

3.基于源码剖析原因

因为 ThreadLocal会将变量存储在线程的 ThreadLocalMap中,所以我们先看看InheritableThreadLocal的getMap方法,从而定位到了inheritableThreadLocals:

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

然后我们到Thread类去定位这个变量的使用之处,所以我们在创建线程的地方打了个断点:

从而定位到这段初始化,它会获取主线程的ThreadLocalMap并将主线程ThreadLocalMap中的值存到子线程的ThreadLocalMap中。

private void init(ThreadGroup g, Runnable target, String name,
                      long stackSize, AccessControlContext acc,
                      boolean inheritThreadLocals) {
        if (name == null) {
            throw new NullPointerException("name cannot be null");
        }

        this.name = name;

  //获取当前线程的主线程
        Thread parent = currentThread();
       
        if (inheritThreadLocals && parent.inheritableThreadLocals != null)
            this.inheritableThreadLocals =
            //将主线程的map的值存到子线程中
                ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
        //......
    }

createInheritedMap内部就会调用ThreadLocalMap方法将主线程的ThreadLocalMap的值存到子线程的ThreadLocalMap中。

private ThreadLocalMap(ThreadLocalMap parentMap) {
            Entry[] parentTable = parentMap.table;
            int len = parentTable.length;
            setThreshold(len);
            table = new Entry[len];
   //遍历父线程数据复制到子线程map中
            for (int j = 0; j < len; j++) {
                Entry e = parentTable[j];
                if (e != null) {
                    @SuppressWarnings("unchecked")
                    ThreadLocal<Object> key = (ThreadLocal<Object>) e.get();
                    if (key != null) {
                     //......
                     //定位当前子线程bucket位置将value存入
                        while (table[h] != null)
                            h = nextIndex(h, len);
                        table[h] = c;
                        size++;
                    }
                }
            }
        }

六、ThreadLocal在Spring中的运用

其实针对日期格式化问题,Spring已经为我们内置好了相应的工具类即DateTimeContextHolder:

 private static final ThreadLocal<DateTimeContext> dateTimeContextHolder =
   new NamedThreadLocal<>("DateTimeContext");

该工具类和simpledateformate差不多,使用示例如下所示,是spring封装的,使用起来也很方便:

public class DateTimeContextHolderTest {


    protected static final Logger logger = LoggerFactory.getLogger(DateTimeContextHolderTest.class);

    private final static DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");

    private Set<String> set = new ConcurrentHashSet<String>();

    @Test
    public void test_withLocale_same() throws Exception {
        ExecutorService threadPool = Executors.newFixedThreadPool(30);

        for (int i = 0; i < 30; i++) {
            int finalI = i;
            threadPool.execute(() -> {
                LocalDate currentdate = LocalDate.now();
                int year = currentdate.getYear();
                int month = currentdate.getMonthValue();
                int day = 1 + finalI;
                LocalDate date = LocalDate.of(year, month, day);

                DateTimeFormatter fmt = DateTimeContextHolder.getFormatter(formatter, null);
                String text = date.format(fmt);
                set.add(text);
                logger.info("转换后的时间为" + text);
            });
        }

        threadPool.shutdown();
        while (!threadPool.isTerminated()) {

        }

        logger.info("查看去重后的数量"+set.size());


    }
}

七、为什么JDK建议将ThreadLocal设置为static

我们都知道使用static是属于类,存在于方法区中,即修饰的变量是全局共享的,这意味着当前ThreadLocal在通过static之后,即所有的实例对象都共享一个ThreadLocal。从而避免重复创建TSO(Thread Specific Object)即ThreadLocal所关联的对象的创建的开销。以及这种方案使得即使出现内存泄漏也是O(1)级别的内存泄露,场景如下:

  • 假设使用线程非线程池模式,即线程结束后threadLocalMap就会被回收,这种情况下也只有在threadLocal第一次调用get到线程销毁之间的时间段存在内存泄漏的情况。
  • 如果使用的是全局线程池,因为线程池的线程并不会被回收,所以threadLocalMap中的entry一直存在于堆内存中,但由于该ThreadLocal属于全局共享,所以大量线程进行操作时一定概率触发expungeStaleEntry清除过期对象,一定程度上避免了内存泄漏的情况。
  • 极端情况下,如果threadLocal创建之后只有线程池中的一个线程get或初始化后完全没有线程再去使用,这就会导致threadLocalMap存在强引用而导致无法被回收,O(1)级别的内存泄漏由此诞生。

对应的实例变量的ThreadLocal的O(n)内存泄漏,这就不必多说。

八、小结

  • ThreadLocal通过在将共享变量拷贝一份到每个线程内部的ThreadLocalMap保证线程安全。
  • ThreadLocal使用完成后记得使用remove方法手动清理线程中的ThreadLocalMap过期对象,避免OOM和一些业务上的错误。
  • ThreadLocal是不可被继承了,如果想使用主线的的ThreadLocal,就必须使用InheritableThreadLocal。
责任编辑:赵宁宁 来源: 写代码的SharkChili
相关推荐

2024-10-28 08:15:32

2021-12-08 15:07:51

鸿蒙HarmonyOS应用

2024-08-22 18:49:23

2021-05-06 08:55:24

ThreadLocal多线程多线程并发安全

2024-10-31 09:24:42

2023-09-21 22:02:22

Go语言高级特性

2023-05-18 08:54:22

OkHttp源码解析

2024-09-19 08:49:13

2019-07-17 14:03:44

运维DevOps实践

2024-08-30 09:53:17

Java 8编程集成

2021-03-19 06:31:06

vue-lazyloa图片懒加载项目

2023-12-04 16:18:30

2022-03-09 23:02:30

Java编程处理模型

2021-07-03 08:51:30

源码Netty选择器

2022-10-25 10:20:31

线程变量原理

2022-12-28 10:50:34

AI训练深度学习

2024-03-14 09:30:04

数据库中间件

2020-04-21 13:13:54

ZooKeeper源码实践

2018-04-09 08:17:36

线程ThreadLocal数据

2024-03-05 09:39:03

Zadig版本管理版本
点赞
收藏

51CTO技术栈公众号