Java8线程私有化入门-ThreadLocal示例

225 阅读9分钟

前情

本文主要内容为Java8的线程私有化入门了解及可直接照搬的模仿示例,如果这是你需要的,可以继续往下读。如果你是一个经验丰富的Java8背景下开发工程师,已把ThreadLocal作为日常开发工具的一部分,那么本文不会对你有很大的提升。

简单了解概念

  • 线程

    简单理解为你的java程序的一次连续的,逐步的动作,比如一次定时任务的运行、一次单元测试的执行、一次接口的调用、一次监听器的响应等。当然其中可能基于你的定义和编程,会使这次动作进行等待、继续执行、结束或分支出另一个线程去做另一件事儿,但是最重要的是这一次动作的起点和终点,他们就是线程的生命周期。 以下为Chatgpt给出的回答: image.png 一个简单例子: 你的传统三层结构一个SpringWeb项目中,你从postman对你的应用服务的开放的Controller接口发起了一次调用,你的应用接收到了请求: 接受请求->路由Mvc->Controller->Service->Dao->数据库->Dao->Service->Controller->Response响应体返回 这一整套流程常常发生在同一个线程内,也可以被我们视为一套接口请求动作。也正如我们所看到的,这一次线程是一步一步向下走再一步一步从最底层返回到最上层,这是很明显的栈的概念,如果你debug过你的程序,你会发现这也符合编程的理念,你从最上层开始编程,一层一层向下编程调用的内容,最终将结果一层一层返回给最上层。

  • 线程私有化

    如果说线程就像我们沿着梯子或绳子从地面下到井底去取一样东西再爬上来,那么线程私有化就相当于我们在下井的过程中需要一些随身携带的工具。如果没有线程私有化,那每个下井的人都需要从同一套工具库里拿自己需要的工具,就会出现一些争夺工具的情况。如果是线程私有化,就相当于每个下井的人随身携带了一份自己的工具,每个人使用自己的工具的时候不会影响到另外一个下井的人。这样虽然我们多了工具的开销,但是完全避免了工具的竞争或者错用,也省掉了传递工具的耗时。 以下是Chatgpt的回答: image.png 我们常常在资源竞争的时候,需要不断考虑加锁的情况,这就是在并发(多人同时下井)场景下,最大的问题。但是线程私有化是从另外一个角度去看待并发问题。编程中没有绝对的好的方案和坏的方案,而线程私有化就是典型的空间换时间的一个解决方案。

  • ThreadLocal

    Java8中,ThreadLocal是线程私有化的一个核心概念。他就是我们下井的时候的每个人私有的工具箱,它可以去让我们随身携带一些工具、数据和记录仪。先给一个例子(来自Chatgpt):

    public class ThreadLocalExample {
        private static ThreadLocal<Integer> threadLocalValue = ThreadLocal.withInitial(() -> 0);
    
        public static void main(String[] args) {
            Runnable task = () -> {
                for (int i = 0; i < 5; i++) {
                    int value = threadLocalValue.get();
                    value++;
                    threadLocalValue.set(value);
                    System.out.println(Thread.currentThread().getName() + ": " + threadLocalValue.get());
                }
            };
    
            Thread thread1 = new Thread(task, "Thread-1");
            Thread thread2 = new Thread(task, "Thread-2");
    
            thread1.start();
            thread2.start();
        }
    }
    
    

    可以看到,ThreadLocal在整个类中以作为全局的成员来定义,并且可以为其定义一个泛型,也就是我们这个工具包能带什么类型的工具或数据。在这里我们首先只关注其定义即可。
    常见的使用场景有:线程内日志记录、线程内数据统计、线程内私有工具类等。

具体概念和使用逻辑

我们再次回到SpringWeb的一次线程的下井过程:

收到请求(准备下井)-> 创建线程(开始下井)【携带ThreadLocal工具包】-> 路由到Controller层【携带ThreadLocal工具包并放入下井要用的工具】-> 进入Service层【携带ThreadLocal工具包并并放入下井记录的内容】-> Dao及数据库【使用ThreadLocal工具包在下井时记录到的数据】-> 返回到Service【或许会扔掉一些ThreadLocal中没用的数据】 -> 返回到Controller【在扔掉一些ThreadLocal中国用完的工具】-> 返回内容结束线程(出井)

在这一次完整的线程生命周期中,ThreadLocal帮我们保存了在各个阶段能共用的内容。即便是我本次下井既去修理了水管、又去关闭了阀门、又去井底采了一些水质样本来做研究,但是只要是仍在本次行动的范围内,我都可以借助ThreadLocal来帮我们存放和取用工具和数据。

使用逻辑如下:

  1. 定义ThreadLocal:
    private static ThreadLocal<Integer> threadLocalValue = ThreadLocal.withInitial(() -> 0);
    withInitial这个方法可以使ThreadLocal初始化一个线程私有变量。
  2. 将线程私有的对象放入ThreadLocal中: threadLocalValue.set(1);
    将Integer对象1放入ThreadLocal中
  3. 将ThreadLocal中的线程私有变量取出: Integer intValue = threadLocalValue.get(); 将当前线程私有持有的变量取出并赋值给我们需要的变量。

有以上三点即可使用线程私有化技术。 需要注意的是,本次线程可能会经过很多个类的很多个方法,要保证我们在本次线程的生命周期都能使用到这个线程私有化变量,我们可能需要正确合理的去设计类与类之间的关系,或许是静态共用,或许是抽象继承实现,在有正确的使用关系和正确的对线程私有化的使用方法的封装下,你才能非常方便的使用和debug检查一次线程内线程私有化变量的变化情况。特别是涉及到一个线程派生出新的线程去异步完成某个动作的时候,一定要注意不要让线程私有化成为编程的绊脚石,使之剪不断理还乱。

一个实例

  1. 在我的这个应用中,有三个Scheduler定时任务,他们均每隔一段时间执行一次,在应用中起着至关重要的作用。
  2. 我们想设计一个统一的日志记录的功能,用于记录每个定时任务执行过程中产生的一些参数和信息。
  3. 要求为【清晰记录三个不同的定时任务内的信息】、【禁止频繁地将日志记录进数据库】、【代码尽量减少重复和冗杂编码】

针对这样的一个场景,我们需要有一个化繁为简的思路。
为了满足要求,我们可能会考虑做一个日志工具类,游离于三个定时任务之外,提供一些记录不同级别日志的方法,并额外提供一套将日志入库的逻辑,当我们需要的时候我们就去调用日志工具类。但是这就涉及到我们三个定时任务在同时执行的过程中,考虑是否会争夺日志记录的资源(比如日期工具类、格式化工具类、日志记录数据和资源)的情况。所以我们需要更清晰的方法来完成。这便是我们线程私有化的用武之地。

由于每个定时任务在调期执行的时候,通过定时任务框架,都会使用一个单独的线程去进入定时任务、进入逻辑代码、进入数据处理且可能会访问数据库,我们考虑为每个定时任务都定义一个ThreadLocal用于存放每个线程产生的日志,这样我们即可实现线程间的日志隔离,并在线程结束的时候,把记录的日志做好标记扔进数据库。但是仍需考虑减少重复代码,所以我们不会在每个定时任务中写同一份ThreadLocal初始化代码,而是统一提取到抽象的父级中,并且在父级中实现日志的记录、查询功能。因为每一次线程的创建,都会单独创建一个ThreadLocal,所以虽然代码只有一份,但是ThreadLocal是完全跟随线程的生命周期的。

threadLocal.png

简单代码样例:

@Slf4j
public Abstract class AbsWorker{

    //使每个继承的子类都有拥有一个ThreadLocal
    private static final ThreadLocal<StringBuffer> threadLocalLogBuf = ThreadLocal.withInitial(StringBuffer::new);
    
    //用于将日志数据落库的Dao层
    private LogDao logDao;
    
    protected void appendLog(String msg, LogType logType) {
        try {
            StringBuffer logBuf = threadLocalLogBuf.get();
            String dateStr = DateJDK8Util.acquireCurrentDateTime("yyyy/MM/dd HH:mm:ss");
            logBuf.append(dateStr).append("  [").append(CurrentServerBean.serverId).append("]").
                    append(" ").append(logType.name()).append("  ").append(msg).append("\n");
            threadLocalLogBuf.set(logBuf);
        } catch (DmException e) {
            log.error("任务记录日志报错", e);
        }
    }
    

    private void doLogToDB(){
        StringBuffer logBuf = threadLocalLogBuf.get();
        logDao.writeLog(logBuf);
    }
}
public class Worker1 extends AbsWorker{
    
    @Scheduled(fixedDelayString = 3000)
    public void doWork1(){
        //业务逻辑代码块
        //...
        appendLog("[Worker1]Worker1 Log...", LogType.Info);
        //业务逻辑代码块
        //...
        //业务逻辑结束
        doLogToDB();
    }
}

// 其余定时任务也类似

在这样的代码结构下,每一个线程可以单独的记录和使用日志内容,这样就避免了线程间的日志冲突,并且每次仅在当前线程结束的时候才会进行数据库写操作,避免了频繁的数据库读写。当然这里对于ThreadLocal的日志记录和追加,是需要自己动手额外扩展的父级方法。在此不做赘述。 额外有一点,有人会注意到,当我们日志落库的时候,仍然会有对于数据库表资源的或者行资源的竞争,这一部分就要涉及到数据库结构的设计和类对象结构的设计,如何才能避免多线程下的数据库表竞争和频繁加锁使效率降低,在我的项目中我设计了状态锁来避免数据库的竞争,也就是每个不同的定时任务对应的数据库的对象会因为状态的不同而区分阶段,使得应用程序避免了对同一个对象的并发访问。

结论

如果你有以上类似的业务场景,在多线程并发的情况下,可以对线程私有化技术多加思考,并且不断的去深入debug,会让你对它有更清晰的认知。