ThreadLocal/InheritableThreadLocal 设计与源码分析

ThreadLocal 提供了线程本地的变量,每个线程只能通过 get/set 方法访问自己的变量。此类的实例通常声明为类的 private static 属性、用来把状态(比如事务ID)关联到线程上。

InheritableThreadLocal 扩展了 ThreadLocal,为子线程提供从父线程那里继承的值:在创建子线程时,子线程会接收所有可继承的线程局部变量的初始值,以获得父线程所具有的值。

1. 实现思路

如果自行实现一个 ThreadLocal,直接思路可能是:ThreadLocal 内维护一个 Map,以 线程对象为 key,value 为变量。

这个思路的问题有:
1. 当线程终止、JVM 进行垃圾回收时,这个 Map 还持有对线程的引用而没法回收线程的资源;如果 JVM 要能回收,那么必须知道有多少 ThreadLocal 实例持有对线程的引用,这会给 JVM 带来负担。
2. 为了实现 InheritableThreadLocal 时,在创建时还必须找出所有的 InheritableThreadLocal,判断父线程是否有设置变量,有的则进行拷贝变量。

从上述问题来看,实现线程本地变量至少应该考虑:
1. 线程本地变量不应该直接持有对 Thread 对象的引用,避免给 JVM 回收 Thread 带来额外的开销;
2. 为实现 InheritableThreadLocal,一个线程在哪些 InheritableThreadLocal 里设置了变量应该有个集中式的存储,这样才方便把父线程的可继承本地变量拷贝到子线程的。
3. 可能有多个线程同时对 ThreadLocal 进行设置变量,那么对 Map 的访问应当是线程安全的。

再来看下线程本地变量涉及哪些参与者:ThreadLocal 、Thread、变量值。

一个 ThreadLocal 可以持有多个 Thread 的变量,一个 Thread 也可以在多个 ThreadLocal 上设置变量。因此一个 (ThreadLocal, Thread) 的组合才能唯一确定一个线程本地变量值。

Map 只能放在 (ThreadLocal, Thread) 中的一个,前面也说了放在 ThreadLocal 上是不合适的。再来看看放在 Thread 上如何。

每个 Thread 的 Map 属性的 key 是 ThreadLocal 对象,value 是变量值,看来也能实现线程本地变量。

这样反转之后,ThreadLocal 不会持有线程的引用,线程回收不存在问题,线程的 Map 也可以在线程回收时进行回收,Map 里面保存的变量值也可以进行回收。

可继承的线程本地变量可以用另一个 Map 来维护,起到了集中存储的作用。

每个线程都只访问自己的 Map,自然没有并发的竞争。

完美!JDK 从 1.3 开始就是按这个思路去实现的。

继续阅读

Linux select/poll/epoll 原理(三)poll 实现

针对 select 系统调用的三个不足,poll 解决的是第一个、最多 1024 个 FD 限制的问题。

其实现思路是:
1. 不再使用位图来传递事件和结果,而是使用 pollfd 。 结构体数组来传递。
2. 在内部实现时,以 poll_list 链表的形式来分批次保存 pollfd 。不像 select 那样一次申请完整的一大块内存。
3. 通过从进程的信号量里获取能打开的最大文件数量,解决 1024 个限制的问题。

0. 基本数据结构

// 源码位置:include/uapi/asm-generic/poll.h
struct pollfd {
    int fd;         // FD
    short events;   // 输入的敢兴趣事件
    short revents;  // 输出的结果
};

// 源码位置:fs/select.c
struct poll_list {
    struct poll_list *next;

    // entries 指向的数组里 pollfd 的数量
    int len;

    // 指向 pollfd 数组的指针
    struct pollfd entries[0];
};

pollfd 结构体用来传递单个FD的输入事件、输出结果。

poll_list 是一个链表,其节点指向 pollfd 结构体的数组,这个数组要么是在栈上预分配、要么是按内存页分配(保持页对齐)。

继续阅读

Linux select/poll/epoll 原理(二)select 实现

阅读源码时看到一个结构体的定义不知道在哪时,可以通过这个网站来查找,非常方便。

__user 是一个宏定义,表示用户空间的,内核不能直接使用,需要使用函数 copy_from_user/copy_to_user 进行处理。

0. 进程打开的文件

进程的表示:

// 源码位置:include/linux/sched.h
struct task_struct {
    // ....省略其他属性

    /* 文件系统信息: */
    struct fs_struct        *fs;

    /* 打开的文件信息: */
    struct files_struct     *files;

    // ....省略其他属性
}

进程维护打开的文件的数据结构:

// 源码位置:include/linux/fdtable.h
struct files_struct {
  /*
   * read mostly part
   */
    atomic_t count;
    bool resize_in_progress;
    wait_queue_head_t resize_wait;

    struct fdtable __rcu *fdt;
    struct fdtable fdtab;
  /*
   * written part on a separate cache line in SMP
   */
    spinlock_t file_lock ____cacheline_aligned_in_smp;
    unsigned int next_fd;
    unsigned long close_on_exec_init[1];
    unsigned long open_fds_init[1];
    unsigned long full_fds_bits_init[1];
    struct file __rcu * fd_array[NR_OPEN_DEFAULT];
};

struct fdtable {
    // 进程能打开的最大文件数
    unsigned int max_fds;
    struct file __rcu **fd;         /* current fd array */
    unsigned long *close_on_exec;

    // 当前打开的一组文件
    unsigned long *open_fds; 
    unsigned long *full_fds_bits;
    struct rcu_head rcu;
};

static inline bool fd_is_open(unsigned int fd, const struct fdtable *fdt)
{
    return test_bit(fd, fdt->open_fds);
}

小结:进程打开的文件维护在位图 fdtable.open_fds 里,对应的比特位为 1 表示文件打开,为 0 是关闭。

select 里传递事件也借鉴了这种思想,通过位图来传递,FD 对应的比特位为 1 表示对事件感兴趣或有事件发生。

继续阅读

Linux select/poll/epoll 原理(一)实现基础

本序列涉及的 Linux 源码都是基于 linux-4.14.143 。

1. 文件抽象 与 poll 操作

1.1 文件抽象

在 Linux 内核里,文件是一个抽象,设备是个文件,网络套接字也是个文件。

文件抽象必须支持的能力定义在 file_operations 结构体里。

在 Linux 里,一个打开的文件对应一个文件描述符 file descriptor/FD,FD 其实是一个整数,内核把进程打开的文件维护在一个数组里,FD 对应的是数组的下标。

文件抽象的能力定义:

// 源码位置:include/linux/fs.h
struct file_operations {
    struct module *owner;
    loff_t (*llseek) (struct file *, loff_t, int);
    ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
    ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
    ssize_t (*read_iter) (struct kiocb *, struct iov_iter *);
    ssize_t (*write_iter) (struct kiocb *, struct iov_iter *);
    int (*iterate) (struct file *, struct dir_context *);
    int (*iterate_shared) (struct file *, struct dir_context *);

    // 对于 select/poll/epoll 最重要的实现基础
    // 非阻塞的轮询文件状态的函数
    unsigned int (*poll) (struct file *, struct poll_table_struct *);

    // 省略其他函数指针
} __randomize_layout;


// 源码位置:include/linux/poll.h
typedef struct poll_table_struct {
    // 文件的 file_operations.poll 实现一定会调用的队列处理函数
    poll_queue_proc _qproc;

    // poll 操作敢兴趣的事件
    unsigned long _key;
} poll_table;

// poll 队列处理函数
typedef void (*poll_queue_proc)(struct file *, wait_queue_head_t *, struct poll_table_struct *);

继续阅读

JVM 堆外内存泄漏分析(二)

关于 堆外内存的组成可以看上一篇文章 JVM 堆外内存泄漏分析(一)

1. NMT

NMT(Native Memory Tracking)是 HotSpot JVM 引入的跟踪 JVM 内部使用的本地内存的一个特性,可以通过 jcmd 工具访问 NMT 数据。NMT 目前不支持跟踪第三方本地代码的内存分配和 JDK 类库。

NMT 不跟踪非 JVM 代码的内存分配,本地代码里的内存泄露需要使用操作系统支持的工具来定位。

1.1 开启 NMT

启用 NMT 会带来 5-10% 的性能损失。NMT 的内存使用率情况需要添加两个机器字 word 到 malloc 内存的 malloc 头里。NMT 内存使用率也被 NMT 跟踪。

启动命令: -XX:NativeMemoryTracking=[off | summary | detail]

off:NMT 默认是关闭的;
summary:只收集子系统的内存使用的总计数据;
detail:收集每个调用点的内存使用数据。

1.2 jcmd 访问 NMT 数据

命令: jcmd <pid> VM.native_memory [summary | detail | baseline | summary.diff | detail.diff | shutdown] [scale= KB | MB | GB]

option desc
summary 按分类打印汇总数据
detail 按分类打印汇总数据
打印虚拟内存映射
按调用点打印内存使用汇总
baseling 创建内存使用快照用于后续对比
summary.diff 基于最新的基线打印一份汇总报告
detail.diff 基于最新的基线打印一份明细报告
shutdown 关闭 NMT

在 NMT 启用的情况下,可以通过下面的命令行选项在 JVM 退出时输出最后的内存使用数据:
-XX:+UnlockDiagnosticVMOptions -XX:+PrintNMTStatistics

继续阅读

JVM 堆外内存泄漏分析(一)

1. JVM 感知容器资源

Java 应用部署在 Kubernetes 集群里,每个容器只运行一个进程, JVM 的启动命令是打包在镜像文件里的。

常规的方式是采用 -Xmx4g -Xms2g 这样的参数来指定 JVM 堆的最大、最小尺寸,如果需要调整堆大小就需要重新打包镜像。

为了避免因为修改堆大小而重新打包,从 JDK 8u191 版本开始支持 JVM 感知容器资源限制,这样在调整 JVM 内存分配时就不需要重新打包镜像文件,采用下面的参数来使 JVM 在启动时感知到容器的资源限制,并设定堆的大小:

-XX:+UseCGroupMemoryLimitForHeap
-XX:InitialRAMPercentage=60.00
-XX:MaxRAMPercentage=80.00
-XX:MinRAMPercentage=60.00 

假如分配给容器的内存上限是 4G,那么上述配置,JVM 堆的初始大小和最小尺寸是 4G * 0.6 即 2.4G,最大尺寸是 4G * 0.8 即 3.2G。

2. JVM 被 oomkill

上面的配置运行一段时间后发现容器自动重启了,在 linux 下通过 dmesg 命令查看系统日志,可以看到类似下面的日志:

Aug  8 15:32:40 H-LDOCKER-01 kernel: [ pid ]   uid   tgid   total_vm      rss        nr_ptes   nr_pmds   swapents   oom_score_adj  name
Aug  8 15:32:40 H-LDOCKER-01 kernel: [33775]   1001  33775  7624373       2036828    4476      32        0          -998           java
Aug  8 15:32:40 H-LDOCKER-01 kernel: Memory cgroup out of memory: Kill process 33775 (java) score 0 or sacrifice child
Aug  8 15:32:40 H-LDOCKER-01 kernel: Killed process 33775 (java) total-vm:30497492kB, anon-rss:8134056kB, file-rss:13256kB

继续阅读

Spring 事务钩子

1. 应用场景说明

有多个事务发起的点 E0、E1,都调用到需要在事务里执行的方法 M0、M1,M0、M1 里都可能产生一些逻辑:一些作为事务的一部分执行、一些在事务提交失败时执行、一些在事务成功提交后执行。

2. 自行实现的一个简陋实现

应用命令模式把要根据事务是否提交成功来决定执行的逻辑封装成一个命令,在事务之外来执行。

定义了下面这样一个类,分别用于收集在事务提交成功和失败后执行的逻辑:

public class Actions {
    private List<Runnable> transactionCommitSuccessActions = new ArrayList<Runnable>();
    private List<Runnable> transactionCommitFailedActions = new ArrayList<Runnable>();
}

在开启事务前初始化一个 Actions 对象,然后事务方法调用到的方法都得传递这个对象,在事务方法结束后就可以根据事务是否成功来执行不同的队列里的逻辑。

继续阅读

容器下 -XX:+HeapDumpOnOutOfMemoryError 未生成 dump 文件的问题

JVM 的启动命令一般都会加上参数 -XX:+HeapDumpOnOutOfMemoryError=/path/to/save/dump.hprof,用于在 JVM 发生 OOM 时自动生成内存 dump 文件。

应用在生产环境是运行在 Docker 容器里、由 K8S 负责管理容器。

但是有的应用发生 OOM 时,在 /path/to/save/dump.hprof 路径下并没有生成对应的 dump 文件。

优秀的运维同事小伍在测试环境进行了各种测试,得出以下两种情况没法生产文件:
1. /path/to/save/ 如果中间层的目录没有提前建好,是没法生成 dump 文件的。
2. 堆外内存不足 800M 时,也没法生成 dump 文件。

第1点没啥问题,文件所在的目录没有提前建好是会报 java.io.FileNotFoundException 异常的。

第2点其实是因为 JVM 运行在容器里,容器允许使用的内存是有上限的,比如分配给容器的是 4G 内存,JVM 堆占用 80%,那么堆外内存就只能占用 20% 即 800M。

发生 OOM 时,JVM 占用了 3.2G;对于堆外内存,线程、JVM自身、应用申请的本地内存等都要在这里分配,OOM dump 也需要利用堆外内存,容器使用的总内存达到 4G 内存上限时,触发系统的 oomkiller 机制把容器进程杀死。

这带来一个小问题:如果要保证 JVM OOM 自动 dump 机制能顺利执行,我们就需要在容器里预留出足够的堆外内存,每个容器都得考虑预留,这就带来内存利用率的问题了。如果 JVM 直接运行在宿主操作系统,没有容器的限制,能申请的堆外内存是受限于系统能分配的内存的,不同应用的 JVM 可共享这个可分配内存空间。


欢迎关注我的微信公众号: coderbee笔记,可以更及时回复你的讨论。