新一代 GC 神器 ZGC

0. 标记-复制算法

标记-复制算法分三个阶段:

  • 标记阶段:从 GC Roots 出发,标记存活对象。
  • 转移阶段:把存活对象复制到新的内存地址,原来的内存空间变成可回收的。
  • 重定位阶段:存活对象被复制到其他地方后,所有指向对象旧地址的指针都要调整到对象新的地址上。

1. 概述

Z Garbage Collector,即ZGC,是一个可伸缩的、低延迟的垃圾收集器。只支持 64 位的系统。

ZGC 未分代,每次 GC 都是 FullGC 。

1.1 ZGC 的设计目标

  • 亚毫秒级的最大暂停时间。
  • 暂停时间不随堆、存活对象数量、根集合大小的增长而增长。
  • 可处理的堆大小从 8MB 到 16TB。

总的来说,ZGC 具有如下特性:

  • 并发
  • 基于 Region
  • 压缩
  • NUMA 友好
  • 使用着色指针
  • 使用读屏障 load barriers

截至 JDK 14,ZGC 已在主流的操作系统上获得支持。

继续阅读

ConcurrentHashMap

1. 为什么 key 和 value 不允许为 null

HashMap 中允许 key、value 为 null,key 为 null 时哈希值为 0 。

ConcurrentHashMap 中都不能为 null 是因为作者 Doug Lea 认为:在并发编程中,null 值容易引来歧义,当调用 get(key) 返回 null 时,无法确定是 key 对应的 value 就是 null ,还是说这个 key 不存在。
非并发编程中可以通过调用 containsKey 方法来判断,但并发编程中无法保证这两个方法之间没有其他线程来修改 key 值。

2. 并行度 concurrencyLevel

ConcurrentHashMap 构造函数里有个参数 concurrencyLevel 提供了建议支持的并行度。

在 JDK 1.7 的实现里,分段锁段数组的大小由 concurrencyLevel 决定,为大于 concurrencyLevel 的最小的 2 次幂值,但不能超过 2^16,初始化后不能修改。

在 JDK 1.8 里,concurrencyLevel 会影响 Node 数组的初始容量,由于并发粒度是数组的元素,从而影响并发度。

concurrencyLevel 并不是指定了精确的并发度。

继续阅读

HashMap

本文源码基于 JDK 1.8.0_101 。

在 JDK 1.8 之前,HashMap 采用 槽数组 + 单链表 来实现;

在 JDK 1.8 开始采用 槽数组 + 单链表 + 红黑树 来实现。

1. 为什么引入红黑树?

解决哈希冲突时、链表过长导致访问效率低下的问题。

为什么是红黑树不是其他树:
二叉排序树在极端情况下会退化成线性结构。
平衡二叉树(AVL树)是严格平衡树,在增加或删除节点时,旋转次数比红黑树要多。红黑树的统计性能高于 AVL 树。

红黑树特性,RBT树上的每个节点,都要遵循下面的规则:
① 每个节点都是红色或者黑色;
② 根节点必须始终是黑色;
③ 没有两个相邻的红色节点;
④ 对每个结点,从该结点到其子孙节点的所有路径上包含相同数目的黑结点。

继续阅读

HashTable 有什么奇怪的知识?

本文涉及的源码基于 JDK 1.8.0_101 。

1. HashTable

  1. 采用数组 + 链表(表头插入)方式解决哈希冲突。
  2. 所有的 public 方法都用 synchronized 修饰以保证线程安全。
  3. 在构造时就初始化槽数组(默认大小为 11)。
  4. 键、值 都不能为 null
  5. 指定 key 的目标槽的定位逻辑:(key.hashCode() & 0x7FFFFFFF) % table.length,掩码+求模。
  6. 槽数组的最大尺寸为 MAX_ARRAY_SIZE: Integer.MAX_VALUE - 8(减 8 是因为一些 JVM 会在数组里保留一些 header words)。
  7. 扩容逻辑为 两倍 + 1。
  8. 阈值为: (int)(capacity * loadFactor),但不能超过 MAX_ARRAY_SIZE + 1

继续阅读

双重检查加锁 与 volatile

一个问题:双重检查加锁为什么用了 volatile 就可以正确工作?

反过来问:不用 volatile 修饰 resource 属性有什么问题?

双重检查加锁的简单示例:

public class DoubleCheckedLocking {
    private static Resource resource;

    public static Resource getInstance() {
        if (resource == null) {     // LL01
            synchronized (DoubleCheckedLocking.class) {
                if (resource == null) {
                    resource = new Resource();
                }
            }
        }
        return resource;
    }
}

继续阅读

FutureTask (JDK1.8)

本文基于 JDK 1.8.0_101 对应的源码。

FutureTask 表示一个可取消的异步计算的任务,其构造器接收一个 Runnable/Callable 表示异步计算的任务。虽然说 FutureTask 支持取消,但任务一旦开始执行,执行过程就可能不受取消操作的影响。

当任务没有执行结束时,所有获取结果的操作都会阻塞等待。

以栈的方式维护等待获取结果的线程,因为不用考虑公平性的问题,用栈更简单。

FutureTask 在 JDK 1.7 的时候是基于 AQS 实现的,可以看这里

总体上:

  • volatile int state 控制执行状态,
  • volatile Thread runner 防止重复执行,
  • volatile WaitNode waiters 以栈形式维护等待线程。

继续阅读

再读 AQS

基于 JDK 1.8.0_101 。

AQS 的核心:两种模式、一个状态、一个同步等待队列、条件对象。

  • 两种模式:是指支持独占模式或共享模式,两者不能并存,要么独占、要么共享。
  • 一个状态:状态是 AQS 的灵魂,根据所处的模式来定义其值的含义。
  • 一个同步等待队列:是线程为进入指定的模式、但还不能进入时,需要在同步等待队列上进行等待。该队列是双向链表,以方便支持取消操作。
  • 条件对象:只能在独占模式下使用,线程可以在条件对象上进行等待、由其他线程在满足条件时唤醒。
    > 每个条件对象其实是一个单向链表实现的条件等待队列,在给定条件对象上等待的线程会进入条件等待队列,等待其他线程来唤醒。
    > 进入等待之前,线程会先进入条件等待队列、保存自己持有时的状态值,然后释放独占模式,进入挂起线程、等待唤醒,唤醒后重新尝试进入独占模式,进入后再继续执行。

继续阅读

Disruptor 为什么那么快?

一个简短的笔记。

Disruptor 快的核心秘诀是:基于数组、空间局部性良好、消除伪共享、无锁、支持批量消费。

1. 基于数组的内存局部性

Disruptor 底层是一个固定大小的环形数组,初始化的时候会顺序创建与数组长度一样数量的对象,以便让这些对象在内存上尽量挨着的。

顺序创建与随机创建在内存上的差别可以对比下面两张图(来自极客时间专栏《Java并发编程实战》):

往队列里写入事件时,需要把事件的属性拷贝到这些预创建好的对象里,以保持内存的局部性。

这里的一个隐性要求是:事件的属性应该尽量是基本类型,如果是对象类型,存的是引用,访问引用指向的值时可能就是随机的内存访问。

继续阅读

RateLimiter 浅析

本文基于 Guava-18.0.jar 。

0. 概述

RateLimiter 是令牌桶思想的一个实现,可实现流量整形、资源访问速率控制。

与信号量对比:

  • 一旦从 RateLimiter 获得许可,不需要释放。信号量分配后必须释放。
  • RateLimiter 控制的是速率,以配置的速率分发许可,速率不变时单位时间内分发的许可量是恒定的。信号量控制的是并发访问的数量,单位时间内分配的次数跟使用许可的时长有关,每次申请使用的时间越短,则单位时间内能分配的次数就越多。

RateLimiter 请求许可的数量不会对请求本身产生抑制影响,但会对下一个请求产生抑制。例如一个请求很大数量许可的请求到达空闲 RateLimiter 时,它将马上获得许可,但下一个请求会被抑制,从而为前面昂贵的请求付出代价。

RateLimiter 申请许可时,可能会阻塞、也可能不会,是否阻塞取决于上一次分配许可的时间和当前请求的许可数量。

RateLimiter 可以配置一个 warnup 热身周期,在这个周期内每秒发出的许可数量稳步增长直至达到稳定的速率。简单说是慢启动吧。

1. 使用示例:

// 每秒2个的速率提交任务
final RateLimiter rateLimiter = RateLimiter.create(2.0);
void submitTasks(List tasks, Executor executor) {
    for (Runnable task : tasks) {
        rateLimiter.acquire(); // may wait
        executor.execute(task);
    }
}

继续阅读

一个与 Ehcache 相关的死锁案例

最近朋友分享一个与 Ehcache 相关的死锁案例,记录下。

0. 一个思考题

缓存击穿是指高并发的请求访问同一个 key,当这个 key 失效后,所有的请求都会传递到数据库,数据库因而崩溃。

为了防止数据库崩溃,我们需要在应用层面拦截这些请求,比如控制一个应用加载一个 key 时只有一个请求到数据库。

如果用加锁的方法,每个 key 一个锁,那么 100 万个 key 就会有 100 万个锁实例在 JVM 里。如果 key 过期了,锁实例可能还得过期,特麻烦。

如果所有的 key 共享同一个锁,那么这个锁就会成为瓶颈。

该如何控制是好?

1. 背景说明

一个面向海量用户的 api 服务,需要提供用户信息查询的功能。

缓存有两层,第一层是 JVM 内基于 Ehcache 的,第二层是 memcached ,查找缓存时首先从本机 Ehcache 查询,找不到再去 memcached 查找,还找不到则从 Loader 接口加载数据并存入相应的缓存。

用户信息由 会员信息、个人信息、家庭信息 等组成,这些信息位于不同的模块,存储在不同的表或库里。

在 Ehcache 里,用户信息有一层缓存,会员信息、个人信息、家庭信息 也有对应的缓存。

以 ID 为 “007” 的用户举例,用户信息缓存对应的 key 为 “User:007″,会员信息对应的缓存 key 为 “member:007″,个人信息对应的缓存 key 为 “personal:007″,家庭信息对应的缓存 key 为 “family:007″。
其中用户信息对应的缓存的过期时间是比较短的。

当用户信息在缓存里都查找不到时,它的 Loader 实现会调用 MemerbService 加载会员信息、调用 PersonalService 加载个人信息,FamilyService 加载家庭信息,然后合并成用户信息保存在缓存里。MemerbService/PersonalService/FamilyService加载信息时也是先从缓存查找,两级缓存都查找不到时也调用对应的 Loader 从表里加载数据。

使用的 Ehcache 的版本是 ehcache-core-2.6.9 。

继续阅读