本文基于 slot

本文基于 JDK1.8

方法与线程

JVM 规范中虚拟机栈为线程私有,每个方法在执行时都会在虚拟机中创建一个栈帧。每一个栈帧表示没有执行完的方法,执行完的的方法,其栈帧会被弹出栈。

如何存放方法调用链路

如果想记录应用的方法调用链路,那么首要的问题就是这些调用数据应该存在什么地方才能够被全局访问到,并且是线程隔离的。

Java 提供 ThreadLocal 来隔离各个线程的私有变量,我们只需要在 ThreadLocal 中村放各个线程的调用 Stack 即可。

链路追踪

关于链路追踪的原理可以参阅 Google 发布的一篇论文 《Dapper, a Large-Scale Distributed Systems Tracing Infrastructure》

slot 的实现较为简单还不是很成熟,只遵循了 OpenTelemetry 的部分语义,后续会实现 OpenTracing 的全部语义。

全局 traceId 控制

具体实现为 TraceContext

public class TraceContext {
// 每个线程的 TraceId 私有
private static final ThreadLocal<String> TRACE_LOCAL = new ThreadLocal<>();

// 3. 当方法执行结束时需要清理 TraceId
public synchronized static void clear() {
TRACE_LOCAL.remove();
}

// 2. 获取 TraceId
public synchronized static String getTraceId() {
return TRACE_LOCAL.get();
}

// 1. 进入方法时会生成全局唯一 TraceId,并存储到线程内存中
public synchronized static void setTraceId(String traceId) {
TRACE_LOCAL.set(traceId);
}
}

调用链控制

具体实现为 TraceManager

public class TraceManager {
private static final ThreadLocal<Stack<String>> TRACE = new ThreadLocal<>();
// 我们认为根方法的 id 为0
public static final String ROOT_METHOD_SPAN_ID = "0";

/**
* 创建一个新的方法调用 span,调用栈中只存储 span id
*
* @return 新 span id
*/
private synchronized static String createSpan() {
// 获取当前调用栈
Stack<String> stack = TRACE.get();
if (null == stack) {
stack = new Stack<>();
TRACE.set(stack);
}
// 如果当前调用栈为空,那么则认为该方法为 root method
// 并创建此次调用链 id
if (stack.isEmpty()) {
TraceContext.setTraceId(ASMUtils.genId());
}
return ASMUtils.genId();
}

/**
* 新增一个方法调用栈,并压栈
*
* @return 当前 span
*/
public synchronized static String entrySpan() {
// 生成新的调用栈
final String span = createSpan();
final Stack<String> spans = TRACE.get();
// 压栈
spans.push(span);
return span;
}

/**
* 方法退出时同时把 span 出栈标识该方法退出调用栈
*/
public synchronized static void exitSpan() {
final Stack<String> spans = TRACE.get();
if (null == spans || spans.isEmpty()) {
TraceContext.clear();
return;
}
// 如果栈为空则认为本次调用链结束
spans.pop();
if (spans.isEmpty()) {
TraceContext.clear();
}
}

/**
* 获取父 span id,如果当前栈为空,则认为当前方法为 root method
* <p>
* 该方法应该先于 entrySpan 调用
*
* @return 当前方法的父 span id
*/
public synchronized static String getParentSpan() {
final Stack<String> spans = TRACE.get();
if (null == spans || spans.isEmpty()) {
return ROOT_METHOD_SPAN_ID;
}
return spans.peek();
}
}
  1. 当第一个方法开始执行时,ThreadLocal 中的 Stack 是空的

    +-------------------------------------------------------+
    | ThreadLocal |
    | Stack Stack Stack |
    | +-------------+ +-------------+ +-------------+ |
    | | | | | | | |
    | | | | | | | |
    | | | | | | | |
    | | | | | | | |
    | | | | | | | |
    | | | | | | | |
    | | | | | | | |
    | | | | | | | |
    | | | | | | | |
    | | | | | | | |
    | | | | | | | |
    | | | | | | | |
    | +-------------+ +-------------+ +-------------+ |
    +-------------------------------------------------------+
  2. 接下来我们只看一个线程,当一个方法被线程执行时,我们会调用 entrySpan() 方法在栈中压入一个 SpanId( 特殊的根方法的 SpanId 为0),如果是新的调用链的话还会生成 TraceId。

    +-------------------+        +-------------------+
    | ThreadLocal | | ThreadLocal |
    | | | |
    | | | +---------------+ |
    | | | | | |
    | Stack | | | TraceId | |
    | +-------------+ | | | | |
    | | | | | +---------------+ |
    | | | | +-------------------+
    | | | |
    | | | |
    | | | |
    | | | |
    | | | |
    | | | |
    | | | |
    | | | |
    | | | |
    | | | |
    | | | |
    | | | |
    | | | |
    | | | |
    | | | |
    | | | |
    | | | |
    | | | |
    | | +---------+ | |
    | | | SpanId | | |
    | | +---------+ | |
    | +-------------+ |
    +-------------------+
  3. 当第二个方法或更多方法被线程执行时,我们会为每一个方法(entrySpan())生成一个 SpanId 并压入栈中(不存入对象是为了节约内存空间),并将当前栈顶的 SpanId 设置为当前出栈 Span 的父 id(getParentSpan())。

    +----------------------------+
    | ThreadLocal |
    | |
    | +-------------------+
    | | | |
    | | | |
    | Stack | | |
    | +---------------+ | |
    | | | | +-------+------+
    | | | | || SpanId |
    | | | | || |
    | | | | +--------------+
    | | | | |
    | | | | |
    | | | | |
    | | | | |
    | | | | |
    | | | | |
    | | | | |
    | | | | |
    | | | | |
    | | | | |
    | | | | |
    | | | | |
    | | +----v------+ | |
    | | | | | |
    | | | SpanId +--------------------+
    | | +-----------+ | | |
    | | | | ParentId |
    | | +-----------+ | | |
    | | | | | | |
    | | | SpanId <--------------------+
    | | +-----------+ | | |
    | | | | |
    | | +-----------+ | | ParendId |
    | | | | | | |
    | 0 <----+ SpanId <--------------------+
    | | +-----------+ | |
    | +---------------+ |
    | |
    +----------------------------+

    即使是递归方法,每次方法的调用都会生成新的 SpanId,栈的深度应该由业务系统来考虑,栈过深的话会导致 JVM 出现 StackOverflow 的错误。

  4. exitSpan() 方法会检测每一个方法出栈时当前栈的状态,如果栈中没有数据,我们认为此次调用链结束,我们将把 TraceId 也进行弹出,做最后的收尾工作。

    +-------------------+                            +---------------+
    | ThreadLocal | | ThreadLocal |
    | | | | pop
    | | | +-------------->
    | +-------------+ | | | |
    | | Stack | | | +-----+----+ |
    | | | | | | | |
    | | | | | | TraceId | |
    | | | | | | | |
    | | | | | | | |
    | | | | | +----------+ |
    | | | | | |
    | | | | | |
    | | | | | |
    | | | | | |
    | | | | | |
    | | | | | |
    | | | | +---------------+
    | | | |
    | | | | pop
    | | +----------------------->
    | | | | |
    | | | | |
    | | | | |
    | | | | |
    | | | | |
    | | | | |
    | | +---+-----+ | |
    | | | SpanId | | |
    | | | | | |
    | | +---------+ | |
    | +-------------+ |
    +-------------------+

总结

因为所有的方法都在线程中执行,我们使用线程私有的并且可以全局访问的 ThreadLocal 对象来追踪方法的链路调用,并使用 Stack 来组织数据的调用关系。