Detailed explanation of the pit of passing HttpServletRequest parameters to asynchronous threads under Spring framework

  • 2021-07-09 07:58:21
  • OfStack

Under spring's annotation @ RequestMapping, you can get HttpServletRequest directly to get important request information such as request header:


@Slf4j
@RestController
@RequestMapping("/test")
public class TestController {
 
  private static final String HEADER = "app-version";
 
  @RequestMapping(value = "/async", method = RequestMethod.GET)
  public void test(HttpServletRequest request) {
        request.getHeader(HEADER);
  }
}

Often, this important information will also be used in asynchronous threads. Therefore, a natural idea is that it is better to directly transfer the request obtained here as a parameter to other spawn sub-threads, such as:


@Slf4j
@RestController
@RequestMapping("/test")
public class TestController {
 
  private static final String HEADER = "app-version";
 
  @RequestMapping(value = "/async", method = RequestMethod.GET)
  public void test(HttpServletRequest request) {
    log.info("Main thread: " + request.getHeader(HEADER));   
    new Thread(() -> {
      log.info("Child thread: " + request.getHeader(HEADER));
    }).start();
  }
}

Send after setting "app-version" to 1.0. 1 in header < base_url > /test/async request, you can see the result:

Main thread: 1.0.1
Child thread: 1.0.1

However, the pit appeared.

Since HttpServletRequest is not thread-safe (in hindsight), when the main thread completes its work and returns to response, corresponding objects such as HttpServletRequest are destroyed. To see this phenomenon, we can wait an extra period of time in the child thread to ensure that the main thread ends before the child thread.


@Slf4j
@RestController
@RequestMapping("/test")
public class TestController {

  private static final String HEADER = "app-version";
  private static final long CHILD_THREAD_WAIT_TIME = 5000;

  @RequestMapping(value = "/async", method = RequestMethod.GET)
  public void test(HttpServletRequest request) {
    log.info("Main thread: " + request.getHeader(HEADER));

    new Thread(() -> {
      try {
        Thread.sleep(CHILD_THREAD_WAIT_TIME);
      } catch (Throwable e) {

      }
      log.info("Child thread: " + request.getHeader(HEADER));
    }).start();
  }
}

Send after setting "app-version" to 1.0. 1 in header <base_url>/test/async Request, you can see the result:

Main thread: 1.0.1
Child thread: null

Obviously, no one can guarantee that the child thread from spawn will end before the main thread, so pass it directly HttpServletRequest Parameters to child threads are not feasible.

There is a method on the Internet that comes with the spring framework RequestContextHolder To get request, which is not feasible for asynchronous threads. Because only the thread responsible for request processing can call the RequestContextHolder Object, which is directly empty in other threads.

Then, a stupid way to think of is to take out the value of request, inject it into a custom object, and then pass this object as a parameter to the child thread:


@Slf4j
@RestController
@RequestMapping("/test")
public class TestController {

  private static final String HEADER = "app-version";
  private static final long MAIN_THREAD_WAIT_TIME = 0;
  private static final long CHILD_THREAD_WAIT_TIME = 5000;

  @RequestMapping(value = "/async", method = RequestMethod.GET)
  public void test(HttpServletRequest request) {
    log.info("Main thread: " + request.getHeader(HEADER));
    TestVo testVo = new TestVo(request.getHeader(HEADER));

    new Thread(() -> {
      try {
        Thread.sleep(CHILD_THREAD_WAIT_TIME);
      } catch (Throwable e) {

      }
      log.info("Child thread: " + request.getHeader(HEADER) + ", testVo = " + testVo.getAppVersion());
    }).start();

    try {
      Thread.sleep(MAIN_THREAD_WAIT_TIME);
    } catch (Throwable e) {

    }
  }

  @Data
  @AllArgsConstructor
  public static class TestVo {
    private String appVersion;
  }
}

After sending the request for 1.0. 1 according to "app-version", you can get:

Main thread: 1.0.1
Child thread: null, testVo = 1.0.1

Well, it finally worked.

This seems to be the end of the story, but if you look at the details carefully, there are several questions worth thinking about:

If request in child thread has been destroyed, why didn't you report null exception and just get an empty "app-version" value? If request is destroyed, why isn't TestVo, which was also created in the main thread, destroyed? Can the main thread really destroy objects? Isn't GC responsible for destroying objects, and why can null results always be obtained in child thread?

A reasonable reasoning is that at the end of the main thread, an destroy () method is called, which actively releases the resources in HttpServletRequest, for example, the clear () method corresponding to map storing header is called. In this way, the value corresponding to the previous "app-version" cannot be obtained in the child thread. Since TestVo is created by users themselves, it is impossible to write the code for releasing resources in destroy () method. Its value is preserved.

In addition, no matter whether the main thread calls the destroy () method or not, the real collection is still the work of GC, which explains that the child thread does not report null exception, but only cannot get the corresponding value of a specific key.

Going one step further, we can also think about the question, why not assign the request object to null directly in the destoy () method of the main thread?

This question seems strange, but in fact it doesn't hold water at all. Because even if you assign the request variable of the main thread to null, another variable in the sub-thread has already pointed to the memory corresponding to this request, and you can still get the corresponding value. For example:


@Slf4j
@RestController
@RequestMapping("/test")
public class TestController {

  private static final String HEADER = "app-version";
  private static final long MAIN_THREAD_WAIT_TIME = 5000;
  private static final long CHILD_THREAD_WAIT_TIME = 3000;

  @RequestMapping(value = "/async", method = RequestMethod.GET)
  public void test(HttpServletRequest request) {
    log.info("Main thread: " + request.getHeader(HEADER));
    TestVo testVo = new TestVo(request);

    new Thread(() -> {
      try {
        Thread.sleep(CHILD_THREAD_WAIT_TIME);
      } catch (Throwable e) {

      }
      log.info("Child thread: " + testVo.getRequest().getHeader(HEADER));
    }).start();

    request = null;

    try {
      Thread.sleep(MAIN_THREAD_WAIT_TIME);
    } catch (Throwable e) {

    }
  }

  @Data
  @AllArgsConstructor
  public static class TestVo {
    private HttpServletRequest request;
  }
}

Send the request for 1.0. 1 according to "app-version" and get:

Main thread: 1.0.1
Child thread: 1.0.1

Here, let the child thread wait 3 seconds so that the main thread has enough time to assign request to null. However, the child thread can still get the corresponding value.

Therefore, assigning the request variable to null does not release resources at all. So for map, which holds header in request, assigning the variable to null does not guarantee that references elsewhere will be 1 and disappear. The most direct and effective method is to invoke clear () to invalidate every element in map.

So to sum up, it is:

request and testVo of the main thread have variables pointing to sub-threads, that is, reference and count on the two objects are not 0, so GC will not really recycle the corresponding memory of these two parts. However, the value of header cannot be obtained because request is most likely called the clear () method of the internal map in the main thread at the destroy () method. testVo is a user-created object that cannot be released in advance in the destroy () method, so it can continue to maintain its original value.

Related articles: