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

发布时间 2023-04-26 17:12:22作者: 大阿张

1.问题引出

在使用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容器中,最后会由具体的业务类处理

image
image

每次web请求,web容器都会交由一个单独的线程来处理这次请求(由池化技术支持),如果这时候多个请求同时请求同一个接口,那就是多个线程同时会执行你的业务代码,如果你的业务代码中有临界区(受保护的资源,像类中的成员变量【通常Spring的Bean都是单例的,所以多个线程同时访问修改同一个Bean中的成员变量是不安全的】,数据库资源,一段方法执行等等,都可以看成是临界区资源),那你就必须要考虑线程安全问题

单机情况下,你可以考虑jvm锁,像synchronized,ReentrantLock,如果是分布式环境就需要考虑使用分布式锁

2. 线程安全问题

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

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

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

1.类加载器子系统
类加载器子系统负责在JVM中加载Java程序使用的类。当Java程序需要使用某个类时,类加载器会从文件系统或网络中加载类的字节码,然后创建出对应的Class对象。

2.运行时数据区
运行时数据区是JVM的内存管理子系统。它包括了方法区、堆、栈等部分。其中,堆是用来存储对象实例的,而栈则是用来存储方法调用和局部变量的。方法区包含了所有的类信息、常量池等。

3.执行引擎
执行引擎是JVM的核心组件,它负责将字节码翻译成机器指令并执行。JVM支持两种执行引擎:解释器和即时编译器。解释器将字节码逐行翻译为机器指令并执行,而即时编译器会将字节码直接编译为本地机器代码,从而大幅度提高执行效率。

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

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

● 分工问题:不同的任务交由合适的线程去处理
● 同步问题:一个线程的任务执行完成后,通知其他线程继续执行任务的方式叫同步
● 互斥问题:分工和同步强调的是任务的执行性能,而互斥强调的是执行任务的正确性,也就是线程安全问题,而在并发编程中解决原子性,可见性,有序性问题的核心方案就是线程间的互斥

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

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

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的两张图

image
image

在 JDK 8 中,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多线程程序中都具有重要的作用,但它们的职责和作用不同,应该根据具体的需求来合理使用。

线程是计算机进行任务调度的最小单位,在编程中,线程通常指执行特定代码的一组指令流,并且可以与其他线程并发地运行。通俗的讲,你可以看成cpu回味线程分配时间片,让线程这个逻辑上的概念,去执行任务,也就是咱们的代码。

当一个线程执行到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对象,value是set进来的对象,其实都是引用值。

这个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的结构)


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

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


参考:javaguide.cn