从web请求开始到线程安全问题,以自己的理解简单的谈谈ThreadLocal

共 7857字,需浏览 16分钟

 ·

2023-05-15 00:43

1. 问题引出

ThreadLocal这个类经常被面试问到,犹记当时找工作的时候,可没少背相关的八股文。但是当时只是会背,没有真正的去使用。ThreadLocal在真正开发中哪个业务场景下会用到呢,用它能解决什么问题呢?当时都没有考虑过。

学习一个东西,首先是去要弄清这个”东西“它要解决什么问题、它的应用场景,其次就是要简单的写一些demo去用一下,最后是做一下思考去看看它的实现源码。而不是一上来就看源码,除非是神仙,要不你会没有头绪效率很低,因为代码是分散的逻辑。你要抓住一个你自己的疑问点,然后再去看源码实现。

接下来就用我浅薄的知识,简单的谈谈对ThreadLocal的理解。

在使用spring 框架进行web开发时,我们经常会使用一个Interceptor(拦截器)并将它交由ioc容器管理,用于web请求的一些拦截工作,类似下面这种,这里面就会使用ThreadLocal对象对当前线程做些操作,也就是保存一些"东西"到当前线程中,就是一个绑定的效果。

    @Component
public class RequestInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        ThreadContextHolder.setHttpRequest(request);
        ThreadContextHolder.setHttpResponse(response);
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        ThreadContextHolder.setHttpRequest(request);
        ThreadContextHolder.setHttpResponse(response);
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        //ThreadLocal 线程变量隔离,会在每一个线程里面 创建一个副本对象,每个线程互不影响
        //但是 如果用完不一出,会有内存泄漏的风险
        //Thread中,ThreadLocalMap -> ThreadLocal key ,value 存入的值  ThreadLocal弱引用
        ThreadContextHolder.remove();
    }
}

我们都知道spring是运行在web容器(tomcat,jetty,undertow 等等)中的,看看下图,一次web请求就会经过web容器,然后由web容器内部的http服务器转发到Servlet容器中,最后会由具体的业务类处理。

bb1e450609161383fd476a7b4fa22427.webp

dabebb0157ac210ab1bd89d322093729.webpimage

每次web请求,web容器都会交由一个单独的线程来处理这次请求(由池化技术支持,ThreadLocal用完要remove,web容器可能会复用线程,造成其他线程拿到上一次线程的变量副本值),如果这时候多个请求同时请求同一个后端接口,那就是多个线程同时会执行你的业务代码,如果你的业务代码中有临界区(受保护的资源,像类中的成员变量【通常Spring的Bean都是单例的,所以多个线程同时访问修改同一个Bean中的成员变量是不安全的】,数据库资源,一段方法执行等等,都可以看成是临界区资源),那你就必须要考虑线程安全问题,单机情况下,你可以考虑jvm锁,像synchronized,ReentrantLock,如果是分布式环境就需要考虑使用分布式锁。这就引出了线程安全问题的思考。

2. 线程安全问题

上面讲了使用锁可以保护临界区资源,究其原因就是线程不安全问题,线程不安全是由可见性、原子性和有序性引起的。

  • • CPU缓存导致可见性问题

  • • 线程切换导致原子性问题,这里需要注意线程切换可以发生在任何一条 CPU 指令执行完,而不是以编译优化带来的有序性问题代码语句为粒度的,高级语言里一条语句往往需要多条 CPU 指令完成

  • • 编译优化带来的有序性问题

上面的线程安全问题,建议去看一看并发编程。我们都知道,一个java程序是运行在jvm虚拟机中的,你每启动一个java程序就会运行一个jvm虚拟机。java代码会经编译器编译成Class文件存储到磁盘中,当你启动java程序时,会将Class文件加载到jvm中,然后进行类加载等等。

要注意jvm是运行在操作系统上的,jvm的解释器会逐行的将字节码文件解释二进制机器码指令,或是通过即时编译器将一大部分的字节码一次编程二进制机器码,然后通过操作系统提供的系统调用将这些指令写入到操作系统的内核空间中的代码段,等待CPU执行

总之线程安全问题,是并发编程中首先要考虑的问题,总结一下并发编程三大核心问题

● 分工问题:不同的任务交由合适的线程去处理 

● 同步问题:一个线程的任务执行完成后,通知其他线程继续执行任务的方式叫同步 

● 互斥问题:分工和同步强调的是任务的执行性能,而互斥强调的是执行任务的正确性,也就是线程安全问题,而在并发编程中解决原子性,可见性,有序性问题的核心方案就是线程间的互斥

即同一时刻只允许一个线程访问共享资源,如果我们能够保证对共享变量的修改是互斥的,那么,无论是单核 CPU 还是多核 CPU,就都能保证原子性了

jvm 实例是运行在内存中的,是运行在操作系统之上的,无论哪种编程语言编写的多线程程序,最终都是调用操作系统的线程来执行任务

21a71c47f4413698beeb827c536eefa8.webpimage

3.ThreadLocal

上面讲了很多多线程问题,让大家对线程安全问题有了大概的了解,这一部分从原理上去探究,会有点复杂,要了解的东西很多。你得知道计算机组成原理,操作系统,jvm,spring,web容器。我上面写的也是一个大概的过程,也有不准确的地方。言归正传,看一看ThreadLocal,在第一部分中的拦截器中,我们就调用了ThreadLocal对象,看一下ThreadContextHolder这个类吧:

    public class ThreadContextHolder {

    private static final ThreadLocal<HttpServletRequest> REQUEST_THREAD_LOCAL_HOLDER = new ThreadLocal<>();
    private static final ThreadLocal<HttpServletResponse> RESPONSE_THREAD_LOCAL_HOLDER = new ThreadLocal<>();

    public static void remove() {
        REQUEST_THREAD_LOCAL_HOLDER.remove();
        RESPONSE_THREAD_LOCAL_HOLDER.remove();
    }

    public static HttpServletResponse getHttpResponse() {

        return RESPONSE_THREAD_LOCAL_HOLDER.get();
    }

    public static void setHttpResponse(HttpServletResponse response) {
        RESPONSE_THREAD_LOCAL_HOLDER.set(response);
    }

    public static HttpServletRequest getHttpRequest() {
        return REQUEST_THREAD_LOCAL_HOLDER.get();
    }

    public static void setHttpRequest(HttpServletRequest request) {

        REQUEST_THREAD_LOCAL_HOLDER.set(request);
    }

}

这个类中咱们只看这个REQUEST_THREAD_LOCAL_HOLDER 成员变量吧,有没有思考过为啥我们的项目中都是这样使用static final来修饰呢?private 关键字我们是知道的,是为了封装性。这里我要是去掉static final呢

static修饰成员变量,那么这个成员变量是属于类本身,而不是属于对象的,你可以直接通过类名.成员变量名调用,但是这里是private修饰的,在其他类中不能直接调用。static修饰的成员变量在jvm运行时数据区的方法区(<jdk1.7),在jdk1.8后方法区不在分配在运行时数据区而是迁移到本地内存的元空间,取javaguide.cn的两张图

 786ed3accd5375bab49061ccd7ef309d.webpe6e86c1944a74854d705965bbdcc99b7.webp

在 JDK 7 中,JVM 的运行时数据区包括堆、方法区、虚拟机栈、本地方法栈和程序计数器,这些都是 JVM 所管理的内存区域。其中堆、方法区都是线程共享的内存,本地方法栈是为 Native 方法服务的,而程序计数器则是当前线程所执行的字节码的行号指示器。而本地内存(Native Memory)指的是操作系统管理的内存区域,不受 JVM 管理,因此也不受 Java 堆大小、栈大小等 JVM 参数限制。在 Java 应用程序中,可以通过 JNI 调用 Native 方法,使用 C/C++ 代码来分配和管理本地内存。

继续开始讨论,刚才说了static修饰的成员变量是在方法区中的,那后面加一个final又是为啥呢,final修饰的变量是常量,也就是只能赋值一次,在这里REQUEST_THREAD_LOCAL_HOLDER成员变量存的是ThreadLocal对象的引用,也就是说你在其他的类中不能在对REQUEST_THREAD_LOCAL_HOLDER变量赋值了。那么只用final关键字修饰的成员变量是在jvm的内存区域中的哪块呢,只用final关键字修饰的成员变量 和普通的成员变量是一样的,都是属于对象实例的,是存在与堆空间中的,只不过和上面一样只能赋值一次。那么类中的方法是加载到哪个数据区域呢?方法是加载到方法区中的,那如果是用final关键字修饰方法呢?它还是加载到方法区,只不过该方法不能被子类重写。那如果用static修饰方法呢?它还是加载到方法区。

好了,终于要谈谈ThreadLocal了,在java中一个线程对应一个Thread对象,Thread是一个类,Thread类既有静态变量,也有实例化变量,其中每个线程对象都会持有一个线程栈。

通常情况下,线程栈是通过本地方法栈(Native Method Stack)和Java虚拟机栈(Java Virtual Machine Stack)两部分来实现的。其中,本地方法栈用于存储执行本地方法(Native Method)的相关信息,而Java虚拟机栈则用于存储执行Java方法的相关信息。线程栈是每个线程私有的,用于存储该线程正在执行的方法信息、局部变量、操作数栈等数据。线程栈的空间大小是固定的,并且通常是在虚拟机启动时就被分配好的。如果线程需要的栈空间超出了这个固定值,就会抛出StackOverflowError异常。因此,线程栈对于线程的执行非常重要,它直接影响了线程的执行能力和效率。

而线程对象是Java中用于表示线程的类,线程对象提供了一系列方法来控制和管理线程的行为。在创建线程时,我们会创建一个线程对象,并将其作为实际线程的代理(Proxy)来完成线程的管理工作。线程对象包含了线程的状态、优先级、名称以及线程执行的代码等信息,可以通过调用线程对象的方法来启动、停止、暂停或终止线程的执行。

因此,我们可以把线程栈看作是线程内部的执行环境,而线程对象则是对线程进行管理的依据。线程栈与线程对象在Java多线程程序中都具有重要的作用,但它们的职责和作用不同,应该根据具体的需求来合理使用。

当一个线程执行到ThreadContextHolder.setHttpRequest(request)时,它就调用ThreadLocal对象的set方法,来看一看吧:

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

set方法中首先拿到当前线程对象,然后调用getMap方法传入当前线程对象。再看getMap方法返回的其实是当前线程对象中的threadLocals变量,你打开Thread类你就会发现,threadLocals变量初始值为null,也就是说存的引用是null。

那么此时返回的map 就是null,然后会调用createMap方法,在该方法中你就会发现,它会new 一个ThreadLocalMap对象,key为当前的ThreadLocal对象引用(你可以看成是ThreadContextHolder类中静态成员变量存放的引用),value是set进来的对象引用(你可看成是HttpServletRequest 或HttpServletResponse),其实都是引用值,也就是对象的地址值。

这个new 出来的ThreadLocalMap对象的引用值就会赋值给当前Thread对象中的threadLocals变量,至此当前线程对象中的threadLocals变量就存了一个指向ThreadLocalMap对象的一个引用值

接下来看看当前线程执行到ThreadContextHolder.getHttpRequest()方法时,会发生什么

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

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

get方法中首先也是先拿到当前线程对象t,然后调用getMap方法传入当前对象,上面我们已经给当前线程对象的threadLocals变量赋值了,所以这里可以成功拿到不为null的map.

紧接着就通过当前的ThreadLocal对象作为key取出咱们一开始set进去的value(这里简单的说一下,如果感兴趣可以细究一下ThreadLocalMap的结构)

4. 总结

其实ThreadLocal就是解决了不同线程之间的线程安全问题,它只不过是以空间换时间的形式,在每个线程对象中保存变量,这样就可以在这个线程的寿命周期中去对这个保存的变量去做操作,而不会影响其他线程。

ThreadLocal的大概原理就是这样,但需要注意的是:ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用,而 value 是强引用。所以,如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。

这样一来,ThreadLocalMap 中就会出现 key 为 null 的 Entry。假如我们不做任何措施的话,value 永远无法被 GC 回收,这个时候就可能会产生内存泄露。ThreadLocalMap 实现中已经考虑了这种情况,在调用 set()、get()、remove() 方法的时候,会清理掉 key 为 null 的记录。使用完 ThreadLocal方法后 最好手动调用remove()方法

如果还想深入的去了解ThreadLocal的原理,可以去javaguide.cn网站上去深入的学习一下。

浏览 35
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报