[Java JVM] Hotspot GC研究- GC安全点 (Safepoint&Stop The World)
阅读原文时间:2021年04月26日阅读:1

什么是safepoint

引用openjdk官网的一段话:

A point during program execution at which all GC roots are known and all heap object contents are consistent. From a global point of view, all threads must block at a safepoint before the GC can run.

意思就是一个点, 在这个点, 所有GC Root的状态都是已知并且heap里的对象是一致的; 在这个点进行GC时, 所有的线程都需要block住, 这就是(STW)Stop The World.


为什么需要safepoint

很明显safepoint是个让人不开心的东西, 线程都干不了活了, 怎么搞生产? 存在是因为需要, 我们知道java在语言级别提供了线程支持, 每个线程都是独立的执行单元. 堆里对象的引用关系抽象出来就是一副有向图(Directed graph), 图中的节点就是对象, 该对象被其他对象引用可以用该对象的入度(indegree)表示, 而对象的出度(outdegree)可以表示该对象对其他对象的引用, 当然, 对象也可以引用自身, 只要让其内部字段赋值为this就可以了.

有向图太复杂, 可以在脑海里构建出这么一副引用关系图, 最顶是root(先不管是不是GC Root), 通过引用的线指向其他对象, 如果不考虑对象之间交叉引用的话, 这就像是一串葡萄, 拿住了根, 就提起了所有活着的对象. 那么什么会引起这串葡萄的变化? 这就是我们平时见得再多不过的字段赋值操作了(且将参数传递也认为是赋值), 例如:

public class JavaTest {
    public static class DemoObject {
        String val1;
    }

    /**
     * @param args
     */
    public static void main(String[] args) throws Exception {
        [1]DemoObject demoObject = new DemoObject();
        [2]//往demoObject上挂一个字符串对象
        [3]demoObject.val1 = "this is a string object";
        [4]Thread.sleep(1000000);
    }
}

我们知道代码是在线程里执行的, GC的代码也是在线程里执行, 如果执行GC的时候其他线程也同时执行的话, heap的状态将是难以追踪的. 以上面的代码为例, 假设GC线程通过扫描线程的stack(线程stack是一种GC Root), 扫描到demoObject, 然后根据这时候, main函数执行到[3], 但还未执行, 扫描的结果只发现demoObject是存活的, 接下来, main函数的线程执行[3], demoObject.val1引用了一个字符串对象, 这个对象的扫描就漏掉了, 除非以某种方式记录下这个变化, 然后重新扫描demoObject. 即便有办法记录这个赋值导致的变化然后再次扫描, 如果其他线程这时候又来捣乱, 那么重新扫描的时候有可能又发生了变化, 陷入循环…

再往下一点, 我们知道CPU执行运算时的数据, 需要从内存里载入寄存器中, 运算完再从寄存器存入内存, 对象的地址也要经过这么个过程. 假如一个java线程分配了一个对象A, 该对象的地址存在某个寄存器中, 然后线程的cpu时间片到期被切换出去, 同时GC的线程开始扫描存活对象, 由于没有路径到这个地址还在寄存器中的对象, 这个对象被认为是garbage, 回收了. 然后睡眠的java线程醒来了, 把寄存器中的对象地址赋值给了存活对象的某个字段, over…

GC的目的在于帮助我们收集不再使用的内存, 但是把正在是使用的内存当成垃圾回收显然是不能接受的. 同时通过分析也看到, 由于多线程运行环境的存在, GC的工作会变的异常复杂, 要安全的回收垃圾, 需要具备两个条件:

  • heap的变化是受限的, 当然了, 所有线程都停下来最好, 这样heap 在GC过程中是稳定的,这是最简单的情况.

  • heap的状态是已知的, 不会有活着的对象找不到或者很难找的情况. 想想对象地址在寄存器中的情况, 虽然可以有办法可以扫描线程的寄存器, 即使这样, 也必须知道哪个寄存器在某个时刻存的是地址, 要做到扫描不漏是很复杂的事情.

综上, safepoint下, heap是静止的, 并且所有活着的对象都可以被找到.


如何达到safepoint

首先, 怎么让其他线程停下来? 综合来说有主动和被动两种方式. 主动的, GC线程设置一个全局变量, java线程按某个策略去检测这个变量, 发现是safepoint, 主动挂起; 被动的, 发信号, 像在shell中CTRL+C就属于这种情况, 但是信号的处理点状态是不确定的, 可能有个对象的地址正在寄存器里待着呢.

hotspot采用的是第一种, 也就是主动检测的方式. 而在主动检测的方式中, 又两种方式:

  1. 指定点执行检测代码
  2. polling page访问异常触发

之所以会有两种方式的区别, 可能还要回到hotspot为什么叫hotspot这个问题上. Hotspot, 顾名思义, 就是热点的意思, 这里所谓的热点指的是热点代码, 也就是执行频率很高的代码, hotspot会根据运行时的信息来统计, 并将高频率执行的java字节码直接翻译成本地代码, 由此提高执行效率. 因此, hotspot有两种执行方式, 一个是解释执行, 一个是编译执行. 指定点检测主要是解释执行用的, 对于需要高效实现的地方, 则采用polling page.

我们来看看hotspot里面的纯C++解释器的部分代码, 窥一窥safepoint的貌相.

/*
  Interpreter safepoint: it is expected that the interpreter will have no live
  handles of its own creation live at an interpreter safepoint. Therefore we
  run a HandleMarkCleaner and trash all handles allocated in the call chain
  since the JavaCalls::call_helper invocation that initiated the chain.
  There really shouldn't be any handles remaining to trash but this is cheap
  in relation to a safepoint.
*/
#define SAFEPOINT                                                                 \
    if ( SafepointSynchronize::is_synchronizing()) {                              \
        {                                                                         \
          /* zap freed handles rather than GC'ing them */                         \
          HandleMarkCleaner __hmc(THREAD);                                        \
        }                                                                         \
        CALL_VM(SafepointSynchronize::block(THREAD), handle_exception);           \
    }

可以看出, 检查是否需要safepoint同步, 如果是, 则调用block函数.

那么, 哪些地方会调用呢?

...
      CASE(_areturn):
      CASE(_ireturn):
      CASE(_freturn):
      {
          // Allow a safepoint before returning to frame manager.
          SAFEPOINT;

          goto handle_return;
      }

      CASE(_lreturn):
      CASE(_dreturn):
      {
          // Allow a safepoint before returning to frame manager.
          SAFEPOINT;
          goto handle_return;
      }
 ...

代码比较多, 就不贴上来了, 大致来看, 主要是java方法返回和跳转指令(if或者循环里面)的地方会进行检测. 除此以外, 在解释执行的时候会采用两套字节码解释表, 在正常执行下, 执行不检测safepoint的解释表, 达到高效执行的目的. 当需要safepoint时, 会由GC线程修改为检测字节码的解释表. 这个过程是同时执行的, 因为解释表的item是对应字节码的解释函数入口指针, 也就是64位寄存器的宽度, 修改是原子的, 不需要同步.

至于polling page, 代码如下:

// Mark the polling page as unreadable
void os::make_polling_page_unreadable(void) {
  if (!guard_memory((char*)_polling_page, Linux::page_size())) {
    fatal("Could not disable polling page");
  }
}

bool os::guard_memory(char* addr, size_t size) {
  return linux_mprotect(addr, size, PROT_NONE);
}

// Mark the polling page as readable
void os::make_polling_page_readable(void) {
  if (!linux_mprotect((char *)_polling_page, Linux::page_size(), PROT_READ)) {
    fatal("Could not enable polling page");
  }
}

在编译执行的代码里, 会在指定点访问一个polling page, 类似与定点检测, 而polling page说白了跟普通物理页面没啥区别, 只是在需要safepoint时, 会修改该页面的权限为不可访问, 这样编译的代码在访问这个页面时, 会触发段违规异常(SEGEV). 而hotspot在启动时捕获了这个异常, 当意识到是访问polling page导致时, 则主动挂起.

为什么不像解释执行那样在普通状态下把safepoint检测完全规避掉呢, 猜想是因为编译执行后的代码都成为本地机器指令了, 而不像解释执行那样采用的是解释函数表. 函数表可以挨个替换成慢速检测版, 但是要将编译好的代码修改为检测safepoint的版本, 还是并发修改的情况下, 可以想象, 这将会十分困难的.

手机扫一扫

移动阅读更方便

阿里云服务器
腾讯云服务器
七牛云服务器

你可能感兴趣的文章