人的知识就好比一个圆圈,圆圈里面是已知的,圆圈外面是未知的。你知道得越多,圆圈也就越大,你不知道的也就越多。

0%

集成 Spring Boot Admin

创建 Spring Boot Admin Server

  1. 创建新工程,引入以下依赖:
1
2
3
4
<dependency>
<groupId>de.codecentric</groupId>
<artifactId>spring-boot-admin-starter-server</artifactId>
</dependency>
  1. 在应用启动类上添加注解@EnableAdminServer,代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9

    @EnableAdminServer
    @SpringBootApplication
    public class MonitorApplication {

    public static void main(String[] args) {
    SpringApplication.run(MonitorApplication.class, args);
    }
    }
  2. application.yml 配置如下:

    1
    2
    3
    4
    5
    6
    server:
    port: 8081

    spring:
    application:
    name: op-monitor

创建 Spring Boot Admin Client

  1. 在 client 工程中引入以下依赖:

    1
    2
    3
    4
    <dependency>
    <groupId>de.codecentric</groupId>
    <artifactId>spring-boot-admin-starter-client</artifactId>
    </dependency>
  2. application.yml 中添加以下配置:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    spring:
    application:
    name: op-admin
    boot:
    admin:
    client:
    url: http://localhost:8081

    management:
    endpoints:
    web:
    exposure:
    include: "*"
    endpoint:
    health:
    show-details: always
    logfile:
    enabled: true

Spring Boot Admin 监控示例

启动 server 与 client 两个工程之后,访问 http://localhost:8081/,即可看到以下信息:

Spring Boot Admin 示例1.png
Spring Boot Admin 示例2.png
Spring Boot Admin 示例3.png

集成 Prometheus

Docker 容器

  1. 编写配置文件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    scrape_configs:
    # 可随意指定
    - job_name: 'spring'
    # 多久采集一次数据
    scrape_interval: 15s
    # 采集时的超时时间
    scrape_timeout: 10s
    # 采集的路径
    metrics_path: '/actuator/prometheus'
    # 采集服务的地址,设置成 Spring Boot 应用所在服务器的具体地址
    static_configs:
    - targets: [ 'host.docker.internal:8080' ]
  2. 拉取 Docker 镜像并创建容器

    1
    2
    3
    4
    5
    6
    7
    8
    docker run -d `
    --network op_net `
    --hostname prometheus `
    --name prometheus `
    -p 9090:9090 `
    -v /d/Workspace/IdeaProjects/onepiece/op-alliance/op-env:/etc/prometheus `
    prom/prometheus `
    --config.file=/etc/prometheus/prometheus.yml

Spring Boot 工程

  1. 在工程中引入以下依赖:
1
2
3
4
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
  1. 开放 Prometheus 端点:
    1
    2
    3
    4
    5
    management:
    endpoints:
    web:
    exposure:
    include: "prometheus"

Prometheus 监控示例

启动 Docker 容器及 Spring Boot 工程之后,访问 http://localhost:9090/,即可看到以下信息:

Prometheus 监控示例1.png
Prometheus 监控示例2.png

集成 Grafana

Docker 容器

拉取 Docker 镜像并创建容器:

1
2
3
4
5
6
docker run -d `
--network op_net `
--hostname grafana `
--name grafana `
-p 3000:3000 `
grafana/grafana

Spring Boot 工程

添加以下配置属性:

1
2
3
4
management:
metrics:
tags:
application: ${spring.application.name}

参考:JVM (Micrometer)

Grafana 配置

  1. 登录
    启动 Docker 容器及 Spring Boot 工程之后,访问 http://localhost:3000/,即可看到登录界面:
    Grafana登录界面.png

初始账号密码:admin/admin

  1. 添加数据源
    Grafana添加数据源1.png
    Grafana添加数据源2.png

  2. 下载 Dashboard Json
    访问Grafana Labs,搜索 JVM (Micrometer)(4701),下载 Json 数据。

  3. 导入 Dashboard Json
    Grafana 导入 Dashboard Json.png

监控示例

Grafana监控示例.png

监控 Mysql

  1. 拉取 mysqld-exporter 镜像并启动容器

    1
    2
    3
    4
    5
    6
    7
    docker run -d `
    --network op_net `
    --hostname mysqld-exporter `
    --hostname mysqld-exporter `
    -p 9104:9104 `
    -e DATA_SOURCE_NAME="root:root@(host.docker.internal:3306)/" `
    prom/mysqld-exporter
  2. 补充 prometheus.yaml

    1
    2
    3
    4
    5
    6
    scrape_configs:
    - job_name: 'mysql'
    static_configs:
    - targets: ['host.docker.internal:9104']
    labels:
    instance: mysql
  3. 创建 MySQL Overview Dashboard

监控 Redis

  1. 拉取 redis_exporter 镜像并启动容器

    1
    2
    3
    4
    5
    6
    7
    docker run -d `
    --network op_net `
    --hostname redis_exporter `
    --hostname redis_exporter `
    -p 9121:9121 `
    oliver006/redis_exporter `
    --redis.addr redis://host.docker.internal:6379
  2. 补充 prometheus.yaml

    1
    2
    3
    4
    scrape_configs:
    - job_name: 'redis'
    static_configs:
    - targets: [ 'host.docker.internal:9121' ]
  3. 创建 Redis Exporter Dashboard

监控 RabbitMQ

  1. 启用 rabbitmq_prometheus 插件

    1
    2
    3
    docker exec -it rabbitmq bash
    rabbitmq-plugins list
    rabbitmq-plugins enable rabbitmq_prometheus
  2. 创建 RabbitMQ-Overview Dashboard

数据表设计

公司信息表

字段名 字段描述
id 主键
company_name 公司名称
taxpayer_id 纳税人识别号
company_phone 公司电话
company_registered_address 公司注册地址
bank_name 开户行名称
bank_account 开户行账号
jd_account 京东账号名
sn_account 苏宁账号名

第三方账号表

字段名 字段描述
id 主键
account_type 账号类型(jd-京东;sn-苏宁)
account 账号名
password 账号密码
access_token 访问令牌
refresh_token 刷新令牌
access_token_expires_at 访问令牌过期时间
refresh_token_expires_at 刷新令牌过期时间

第三方账号,除了账号名、账号密码之外,还有App Key、App Secret等关键信息,这些信息都是一样的,就保存在配置文件中(也可以保存在数据库里),如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
sdk:
accounts:
jd:
auth-url:
server-url:
account:
password:
app-key:
app-secret:
redirect-uri:
rsa-key:
sn:
auth-url:
server-url:
account:
password:
app-key:
app-secret:
redirect-uri:
rsa-key:

接口设计

请求第三方token

请求地址:POST /{account-type}-account/request-access-token

请求参数

参数名 参数描述
taxpayerId 纳税人识别号
timeout 超时时间(单位:秒),默认值 3

如果未传递 taxpayerId,则请求默认的第三方token;如果在指定时间内,第三方未返回token,则抛出错误提示:请求第三方token超时,请稍后重试。

请求响应:token字符串

第三方回调地址

调用请求第三方token接口后,由第三方回调该接口。

请求地址:GET /{account-type}-account/callback-token

请求参数

参数名 参数描述
code 第三方返回的鉴权码
state 第三方回传的state(与传递给第三方的保持一致)

请求响应:token字符串

刷新第三方token

请求地址:POST /{account-type}-account/refresh-token

请求参数

参数名 参数描述
taxpayerId 纳税人识别号

如果未传递 taxpayerId,则请求默认的第三方token。

请求响应:token字符串

获取第三方token

请求地址:POST /{account-type}-account/get-access-token

请求参数

参数名 参数描述
taxpayerId 纳税人识别号
timeout 超时时间(单位:秒),默认值 3

如果未传递 taxpayerId,则请求默认的第三方token;如果在指定时间内,未返回第三方token,则抛出错误提示:获取第三方token超时,请稍后重试。

请求响应:token字符串

初始化所有的第三方token

请求地址:POST /{account-type}-account/init-all-token

刷新所有的第三方token

请求地址:POST /{account-type}-account/refresh-all-token

接口实现-UML类图

接口实现UML类图

  • ThirdAccountController:第三方账号 Controller 抽象类
  • JdAccountController:京东账号 Controller 实现类,注入京东账号 Service
  • SnAccountController:苏宁账号 Controller 实现类,注入苏宁账号 Service
  • ThirdAccountService:第三方账号 Service 抽象类
  • JdAccountService:京东账号 Service 实现类
  • SnAccountService:苏宁账号 Service 实现类

Token 请求流程

第三方token请求流程

Token 初始化与定时刷新

Token 初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 第三方帐号 Service
*
* @author cdrcool
*/
@Slf4j
public abstract class ThirdAccountService {
/**
* 应用启动后,初始化所有的第三方token
*/
@PostConstruct
public void postConstruct() {
initAllToken();
}
}

Token 定时刷新

每天凌晨(可配置)自动刷新所有第三方账号的token。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* 第三方token刷新定时任务
*
* @author cdrcool
*/
@Slf4j
@Component
public class ThirdTokenRefreshTask {
private final Map<String, ThirdAccountService> thirdAccountServices;

public ThirdTokenRefreshTask(Map<String, ThirdAccountService> thirdAccountServices) {
this.thirdAccountServices = thirdAccountServices;
}

@Scheduled(cron = "${sdk.refresh-token-cron}")
public void execute() {
thirdAccountServices.values().forEach(thirdAccountService -> {
try {
thirdAccountService.refreshAllToken();
} catch (Exception e) {
log.error("定时刷新第三方token异常,class name:{}", thirdAccountService.getClass().getSimpleName());
}
});
}
}
1
2
sdk:
refresh-token-cron: 0 0 0 * * ?

异步返回 Token

使用DeferredResult(异步 Servlet)来异步返回 token。
DeferredResult未拿到返回数据之前,DispatcherServlet和所有的Filter会退出Servlet容器线程并释放其资源,同时也允许容器去处理其它请求,但响应保持打开状态。 一旦DeferredResult拿到返回数据,DispatcherServlet会被再次调用并处理,以异步产生的方式,向请求端返回值。这么做的好处就是请求不会长时间占用容器连接池,提高服务器的吞吐量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/**
* 第三方帐号 Controller
*
* @author cdrcool
*/
public abstract class ThirdAccountController {

@ApiOperation("请求第三方token(未传递纳税人识别号,则请求的默认第三方token)")
@PostMapping("/request-access-token")
public DeferredResult<String> requestAccessToken(@ApiParam("纳税人识别号") String taxpayerId,
@ApiParam(value = "超时时间(单位:秒)", defaultValue = "3")
@RequestParam(defaultValue = "3") Integer timeout) {
DeferredResult<String> deferredResult = new DeferredResult<>(timeout.longValue() * 1000);

// 请求第三方token
thirdAccountService.requestAccessToken(taxpayerId, deferredResult);

// 超时回调
deferredResult.onTimeout(() -> {
throw new ThirdAccountException("请求第三方token超时,请稍后重试");
});
// 失败回调
deferredResult.onError(e -> {
throw new ThirdAccountException("请求第三方token异常:" + e.getMessage());
});

// 等待第三方回调应用 callback-token 接口,在 callback-token 接口里获取到token之后,再调用 deferredResult.setResult(token)
return deferredResult;
}
}

Oauth 2.0 state 参数

在请求第三方code时,还需要传递state参数,它是由client使用的不透明参数,用于请求阶段和回调阶段之间的状态保持。请求示例:

1
https://open-oauth.jd.com/oauth2/authorizeForVOP?app_key=xxx&redirect_uri=xxx&username=xxx&password=xxx&response_type=code&scope=snsapi_base&state=xxx

state可以是一个随机字符串,然后保存在内存里,回调时检查state参数和内存里的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
/**
* 第三方帐号 Service
*
* @author cdrcool
*/
@Slf4j
public abstract class ThirdAccountService {
private static final Map<String, TokenRequestInfo> STORE = new ConcurrentHashMap<>();

/**
* 请求第三方token
*
* @param taxpayerId 纳税人识别号
* @param deferredResult {@link DeferredResult}
*/
public void requestAccessToken(String taxpayerId, DeferredResult<String> deferredResult) {
ThirdAccount thirdAccount = getThirdAccount(taxpayerId);
String state = UUID.randomUUID().toString();
requestAccessToken(thirdAccount, state);

TokenRequestInfo tokenRequestInfo = new TokenRequestInfo(state, accountType(), thirdAccount.getAccount());
// 保存当前请求的deferredResult,在token回调成功后对其setResult
tokenRequestInfo.setDeferredResult(deferredResult);
STORE.put(state, tokenRequestInfo);
}

/**
* 获取第三方token回调
*
* @param code 授权码
* @param state 回传state(与传递给第三方的state一致)
*/
@Async
public void callbackToken(String code, String state) {
TokenResponse response = getTokenResponse(code);

TokenRequestInfo tokenRequestInfo = STORE.get(state);
if (tokenRequestInfo == null) {
throw new ThirdAccountException("未找到与state:{}对应的token请求信息:" + state);
}
log.info("找到state:{}对应的第三方帐号:{}", state, tokenRequestInfo.getAccount());

// 将第三方帐号及其对应的token存到redis缓存
redisTemplate.opsForValue().set(tokenRequestInfo.getAccount(), response.getAccessToken(),
System.currentTimeMillis() - (response.getTime() + response.getExpiresIn() * 1000), TimeUnit.MICROSECONDS);

// 查找第三方帐号,并更新其对应的token响应
LambdaQueryWrapper<ThirdAccount> jdAccountWrapper = Wrappers.lambdaQuery();
jdAccountWrapper.eq(ThirdAccount::getAccount, tokenRequestInfo.getAccount());
ThirdAccount thirdAccount = thirdAccountMapper.selectOne(jdAccountWrapper);
if (thirdAccount == null) {
throw new ThirdAccountException("未找到第三方帐号:" + tokenRequestInfo.getAccount());
}
// 更新第三方账号对应的token信息
updateThirdAccount(thirdAccount, response);

DeferredResult<String> deferredResult = tokenRequestInfo.getDeferredResult();
if (deferredResult != null) {
// 将token设置到deferredResult里
deferredResult.setResult(response.getAccessToken());
}
}
}

线程安全

可以将 Java 语言中各种操作共享的数据分为以下五类:

  • 不可变
  • 绝对线程安全
  • 相对线程安全
  • 线程兼容
  • 线程对立

线程安全的实现方法

  • 互斥同步
  • 非阻塞同步
  • 无同步方案

锁优化

自旋锁与自适应自旋

现在绝大多数的个人电脑和服务器都是多路(核)处理器系统,如果物理机器上有一个以上的处理器或者处理器核心,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一会”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需让线程执行一个忙循环(自旋),这项技术就是所谓的自旋。

自旋等待不能替换阻塞,且先不说对处理器数量的要求,自旋等待本身虽然避免了线程切换的开销,但是它是要占用处理器的时间的,所以如果锁被占用的时间很短,自旋等待的效果就会非常好,反之如果锁被占用的时间很长,那么自旋的线程只会白白消耗处理器资源,而不会做任何有价值的工作,这就会带来性能的浪费。因此自旋等待的时间必须有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程。

JDK6 引入了自适应的自旋,自适应意味着自旋的时间不再是固定的(10 此)了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。如果在用一个锁对象上,自旋等待刚刚成功获得锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很可能再次成功,进而允许自选等待持续相对更长的时间,比如持续 100 次循环。另一方面,如果对于某个锁,自旋很少成功获得过锁,那在以后要获取这个锁时将有可能直接省略掉自旋过程,以避免浪费处理器资源。

锁消除

锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断到一段代码中,在堆上的所有数据都不会逃出去被其他线程访问,那就可以把它们当作栈上的数据对待,认为它们是线程私有的,同步加锁自然就无需再进行。

锁粗化

如果虚拟机探测到存在零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。

轻量级锁

轻量级锁名字中的“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的,因此传统的锁机制就被称为“重量级”锁。不过,需要强调一点,轻量级锁并不是用来替代重量级锁的,它设计的初衷是在没有多线陈竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

偏向锁

引入偏向锁的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用 CAS 操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连 CAS 操作都不去做了。

偏向锁中的“偏”,就是偏心的“偏”、偏袒的“偏”。它的意思是这个锁会偏向第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。

偏向锁可以提高带有同步但无竞争的程序性能,但它同样是一个带有效益权衡(TradeOff)性质的优化,也就是说它并非总是对程序运行有利。如果程序中大多数的锁都总是被多个不同的线程访问,那偏向模式就是多余的。

偏向锁无法使用自旋锁优化,因为一旦有其它线程申请锁,就破坏了偏向锁的的假定。

适用场景

偏向锁、轻量级锁、重量级锁适用于不同的并发场景:

  • 偏向锁:无实际竞争,且将来只有第一个申请锁的线程会使用锁。
  • 轻量级锁:无实际竞争,多个线程交替使用锁;允许短时间的锁竞争。
  • 重量级锁:有实际竞争,且锁竞争时间长。

另外,如果锁竞争时间短,可以使用自旋锁进一步优化轻量级锁、重量级锁的性能,减少线程切换。

参考资料

  1. 浅谈偏向锁、轻量级锁、重量级锁

Java 虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这个过程被称作虚拟机的类加载机制。与那些在编译时需要进行连接的语言不同,在 Java 里面,类型的加载、连接和初始化过程都是在程序运行期间完成的,这种策略让 Java 语言进行提前编译会面临额外的困难,也会让类加载时稍微增加一些性能开销,但是却为 Java 应用提供了极高的扩展性和灵活性,Java 天生可以动态扩展的语言特性就是依赖运行期动态扩展和动态连接这个特点实现的。

类加载的时机

一个类型从被加载到虚拟机内存中开始,到卸出内存为止,它的整个生命周期将会经历加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备和解析三个部分统称为连接(Linking)。这七个阶段的发生顺序如下图所示:

类加载顺序

加载、验证、准备、初始化和卸载这五个阶段的顺序时确定的,类型的记载过程必须按照这种顺序按部就班的开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这时为了支持 Java 的运行时绑定特性(也称为动态绑定或晚期绑定)。注意,这些阶段通常都是互相交叉的混合进行的,会在一个阶段执行的过程中调用、激活另一个阶段。

类加载的过程

加载

在加载阶段,Java 虚拟机需要完成以下三件事情:

  1. 通过一个类的全限定名来获取这个类的二进制字节流。
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  3. 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。

验证

验证阶段的目的是确保 Class 文件的字节流中包含的信息符符合《Java 虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。

验证阶段是非常重要的,这个阶段是否严谨,直接决定了 Java 虚拟机是否能承受恶意代码的攻击,从代码量和耗费的执行性能的角度上讲,验证阶段的工作量在虚拟机的类加载过程中占了相当大的比重。

从整体上看,验证阶段大致上会完成下面四个阶段的检验动作:

  • 文件格式验证
  • 元数据验证
  • 字节码验证
  • 符号引用验证

准备

准备阶段是正式为类型定义的变量(即静态变量,被 static 修饰的变量)分配内存并设置类变量初始值的阶段,从概念上讲,这些变量所使用的内存都应当在方法区中进行分配,但必须注意到方法区本身是一个逻辑上的区域,在 JDK7 之前,HotSpot 使用永久代来实现方法区时,实现是完全符合这种概念的;而在 JDK8 之后,类变量会随着 Class 对象一起存放在 Java 堆中,这时候“类变量在方法区”就完全是一种对逻辑概念的表述了。

关于准备阶段,还有两个容易混淆的概念需要着重强调,首先这时候进行内存分配的仅包括类变量,而不包括实例变量,实例变量将会在对象的初始化时随着对象一起分配在 Java 堆中。其次是这里所说的初始值“通常情况下”是数据类型的零值。

解析

解析阶段是 Java 虚拟机将常量池内的符号引用替换为直接引用的过程。

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这 7 类符号引用进行。

初始化

前面介绍的几个加载的动作里,除了在加载阶段用户应用程序可以通过自定义类加载器的方式局部参与外,其余动作都完全由 Java 虚拟机来主导控制。直到初始化阶段,Java 虚拟机才真正开始执行类中编写的 Java 程序代码,将主导权移交给应用程序。

初始化阶段就是执行类构造器 () 方法的过程。

  • () 方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{} 块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中智能访问定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态教育局快可以赋值,但是不能访问。

  • () 方法与类的构造函数不同,它不需要显示地调用父类构造器,Java 虚拟机会保证在子类地 () 方法执行前,父类的 () 方法已经执行完毕。因此在 Java 虚拟机中第一个被执行的 () 方法的类的类型肯定是 java.lang.Object。

  • 由于父类的 () 方法先执行,也就意味着父类中定义的静态语句块要由于子类的变量赋值操作。

  • () 方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成 () 方法。

  • 接口中不能使用静态语句块,但仍然由变量初始化的赋值操作,因此接口与类一样都会生成 () 方法。但接口与类不同的是,执行接口的 () 方法不需要先执行父接口的 () 方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化。此外,接口的实现类在初始化时也一样不会执行接口的 () 方法。

  • Java 虚拟机必须保证一个类的 () 方法在多线程环境中被正确的加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的 () 方法,其他线程都需要阻塞等待,直到活动线程执行完毕 () 方法。

类加载器

Java 虚拟机设计团队有意把类加载阶段中的“通过一个类的全限定名来获取描述该类的二进制字节流”这个动作放到 Java 虚拟机外部去实现,以便让应用程序自己决定如何去获取所需类。实现这个动作的代码被称为“类加载器”(Class Loader)。

类与类加载器

对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在 Java 虚拟机中的唯一性,每一个类加载器,都有一个独立的类名称空间。

双亲委派模型

双亲委派模型

在 JDK9 之前,Java 应用都是由启动类加载器、扩展类加载器、应用程序类加载器这三类加载器互相配合来完成加载的,如果用户认为有必要,还可以加入自定义的类加载器来进行拓展,典型的如增加除了磁盘位置之外的 Class 文件来源,或者通过类加载器实现类的隔离、重载等功能。

双亲委派模型的工作过程是:如果一个类加载器收到了类加载请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。

类加载器之间的父子关系一般不是以集成(Inheritance)的关系实现的,而是通常使用组合(Composition)关系来复用父加载器的代码。

使用双亲委派模型来组织类加载器之间的关系,一个显而易见的好处就是 Java 中的类随着它的类加载器一起具备了一种带有优先级的层次关系。例如 java.lang.Object,它存放在 rt.jar 之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此 Object 类在程序的各种类加载环境中都能够保证是同一个类。

Java 模块化系统

在 JDK9 中引入了 Java 模块系统(Java Platform Module Systemm, JPMS),它是为了能够实现模块化的关键目标——可配置的封装隔离机制。JDK9 的模块不仅仅像之前的 JAR 包那样只是简单地充当代码地容器,除了代码外,Java 的模块定义还包括以下内容:

  • 依赖其他模块的列表。

  • 导出的包列表,即其他模块可以使用的列表。

  • 开放的包列表,即其他模块可反射访问模块的列表。

  • 使用的服务了列表。

  • 提供服务的实现列表。

    为了保证兼容性,JDK9 并没有从根本上动摇从 JDK 1.2 以来运行了二十年之久的三层类加载结构以及双亲委派模型。但是为了模块化系统的顺利实施,模块下的类加载器仍然发生了一些应该被注意到的变动,主要包括以下几个方面。

    首先,是扩展类加载器(Extension Class Loader)被平台类加载器(Platform Class Loader)取代。

    其次,平台类加载器和应用程序类加载器都不再派生自 java.net.URLClassLoader。现在启动类加载器、平台类加载器、应用程序类加载器全部继承于 jdk.internal.loader.BuiltinClassLoader,在 BuiltinClassLoader 中实现了新的模块化架构下类如何从模块中加载的逻辑,以及模块中资源可访问行的处理。

    另外,启动类加载器现在是 Java 虚拟机内部和 Java 类库共同协作实现的类加载器,尽管有了 BootClassLoader 这样的 Java 类,但为了与之前的代码保持兼容,所有在获取启动类加载器的场景(譬如 Object.class.getClassLoader())中仍然会返回 null 代替,而不会得到 BootClassLoader 的实例。

    最后,JDK9 中虽然仍然维持着三层类加载器和双亲委派的架构,但类加载的委派关系也发生了变动。当平台及应用程序类加载器收到类加载请求,在委派给父加载器加载前,要先判断该类是否能够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责那个模块的加载器完成加载。

类加载器

Java 内存模型的主要目的是定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节。此处的变量(Variables)与 Java 编程中所说的变量有所区别,它包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,因为后者是线程私有的,不会被共享,自然就不存在竞争问题。

Java 内存模型规定了所有的变量都存储在主内存(Main Memory)中。每条线程还有自己的工作内存(Working Memory),线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需通过主内存来完成,线程、主内存、工作内存三者交互关系如下图所示:

内存模型

这里所讲的主内存、工作内存与 Java 内存区域中的 Java 堆、栈、方法区等并不是同一个层次的对内存的划分,这两者基本上是没有任何关系的。如果一定要勉强对应起来,那么从变量、主内存、工作内存的定义来看,主内存主要对应于 Java 堆中的对象实例数据部分,而工作内存则对应于虚拟栈中的部分区域。从更基础的层次上说,主内存直接对应于物理硬件的内存,而为了获取更好的运行速度,虚拟机可能会让工作内存优先存储与寄存器和高速缓存中,因为程序运行时主要访问的是工作内存。

内存间交互操作

Java 内存模型中定义了以下 8 种操作,Java 虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的(对于 double 和 long 类型的变量来说,load、store、read 和 write 操作在某些平台上允许有例外)。

  • lock(锁定)
  • unlock(解锁)
  • read(读取)
  • load(载入)
  • use(使用)
  • assign(赋值)
  • store(存储)
  • write(写入)

如果要把一个变量从主内存拷贝到工作内存,那就要按顺序执行 read 和 load 操作,如果要把变量从工作内存同步回主内存,就要按顺序执行 store 和 write 操作。注意,Java 内存模型只要求上述两个操作必须按顺序执行,但不要求是连续执行。

volatile 型变量的特殊规则

当一个变量被定义成 volatile 之后,它将具备两项特性:第一项是保证此变量对所有线程的可见性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。

需要说明的是,基于 volatile 变量的运算并不能保证在并发下一定是线程安全的,在不符合以下两条规则的运算场景中,仍然要通过加锁来保证原子性:

  • 运算结果并不依赖变量的当前值,或者能确保只有单一的线程修改变量的值。
  • 变量不需要与其他的胡藏太变量共同参与不变约束。

使用 volatile 变量的第二个语义是禁止指令重排序优化。

原子性、可见性与有序性

  • 原子性
  • 可见性
  • 有序性

先行发生原则

先行发生是 Java 内存模型中定义的两项操作之间的偏序关系,比如说操作 A 先行发生于操作 B,其实就是说在发生操作 B 之前,操作 A 产生的影响能被操作 B 观察到,“影响”包括了修改了内存中共享变量的值、发送了消息、调用了方法等。

下面是 Java 内存模型下一些“天然的”先行发生关系,这些先行发生关系无须任何同步器协助就已经存在,可以在编码中直接使用。

  • 程序次序原则(Program Order Rule):在一个线程内,按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作。注意,这里说的是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。

  • 管程锁定原则(Monitor Lock Rule):一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。

  • volatile 变量规则(Volatile Variable Rule):对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作。

  • 线程启动规则(Thread Start Rule):Thread 对象的 start 方法先行发生于此线程的每一个动作。

  • 线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测。

  • 线程中断规则(Thread Interruption Rule):对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生。

  • 对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。

  • 传递性(Transitivity):如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那就可以得出操作 A 先行发生于 操作 C 的结论。

垃圾收集器主要是关注 Java 堆和方法区这两个区域的内存管理。

对象已死?

在堆里面存放着 Java 世界中几乎所有地对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象之中哪些还“存活”着,哪些已经“死去”。

引用计数法

在对象中添加一个引用计数器,每当有一个地方引用过它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不能再被使用的。

客观地说,引用计数法(Reference Counting)虽然占用了一些额外地内存空间来进行计数,但它的原理简单,判定效率也很高,在大多数情况下它都是一个不错的选择。

但是,在 Java 领域,至少主流的 Java 虚拟机里面都没有选用引用计数法来管理内存,主要原因是,这个看似简单的算法有很多例外情况要考虑,必须要配合大量额外处理才能保证正确地工作,譬如单纯地引用计数就很难解决对象之间相互循环引用的问题。

可达性分析算法

当前主流的商用程序语言(Java、C#,上溯至古老的 Lisp)的内存管理子系统,都是通过可达性分析(Reachability Analysis)算法来判定对象是否存活的。这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到 GC Roots 间没有任何引用链相连,或者用图论的话来说就是从 GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。

在 Java 技术体系里面,固定可作为 GC Roots 的对象包括以下几种:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
  • 在方法区中类静态属性引用的对象,譬如 Java 类的引用类型静态变量。
  • 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
  • 在本地方法栈中 JNI (即通常所说的 Native 方法)引用的对象。
  • Java 虚拟机内部的引用,如基本数据类型对象的 Class 对象,一些常驻的异常对象(比如 NullPointException、OutOfMemoryError)等,还有系统类加载器。
  • 所有被同步锁(synchronized 关键字)持有的对象。
  • 反映 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等。

除了这些固定的 GC Roots 集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整 GC Roots 集合。譬如分代收集和局部回收(Partial GC),如果只针对 Java 堆中某一块区域发起垃圾收集时(如最典型的只针对新生代的垃圾收集),必须考虑到内存区域是虚拟机自己的实现细节(在用户视角里任何内存区域都是不可见的),更不是孤立封闭的,所以某个区域里的对象完全有可能被位于堆中其他区域的对象所引用,这时候就需要将这些关联区域的对象也一并加入 GC Roots 集合中去,才能保证可达性分析的正确性。

再谈引用

无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否引用链可达,判定对象是否存活都和“引用”离不开关系。

在 JDK 1.2 版之后,Java 堆引用的概念进行了扩充,将引用分为强引用(Strongly Reference)、软引用(Soft Reference)、弱引用(Week Reference)和虚引用(Phantom Reference) 4 种,这 4 种引用强度依次逐渐减弱。

  • 强引用是最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj = new Object()”这种引用关系。无论任何情况下,只要强引用关系还在,垃圾收集器就永远不会回收掉被引用的对象。

  • 软引用时用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。在 JDK 1.2 版本之后提供了 SoftReference 类来实现软引用。

  • 弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉被弱引用关联的对象。在 JDK 1.2 版本之后提供了 WeakReference 类来实现弱引用。

  • 虚引用也称为“虚灵引用”或者“幻影引用”,它时最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在 JDK 1.2 版之后提供了 PhantomReference 类来实现虚引用。

生存还是死亡?

即使在可达性分析算法种判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行 finalize() 方法。假如对象没有覆盖 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,那么虚拟机将这两种情况都是为“没有必要执行”。

如果这个对象被判定为确有必要执行 finalize() 方法,那么该对象将会被放置在一个名为 F-Queue 的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的 Finalize 线程去执行它们的 finalize() 方法。这里所说的“执行”是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。这样做的原因是,如果某个对象的 finalize() 方法执行缓慢,或者更极端地发生了死循环,将很可能导致 F-Queue 队列中地其他对象永久处于等待,甚至导致整个内存回收子系统地崩溃。

由于 finalize() 方法的运行代价高昂,不确定性大,无法保证各个对象的调用顺序,如今已被官方明确声明为不推荐使用语法。finalize() 能做的所有工作,使用 try-finally 或者其他方式都可以做得更好、更及时。

垃圾收集算法

从如何判定对象消亡地角度触发,垃圾收集算法可以划分为“引用计数式垃圾收集”(Reference Counting GC)和“追踪式垃圾收集”(Tracing GC)两大类,这两类也常被称作“直接垃圾收集”和“间接垃圾收集”。由于引用计数式垃圾收集算法在主流 Java 虚拟机中均未设计,所以本节介绍的所有算法均属于追踪式垃圾收集的范畴。

分代收集理论

分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:

  1. 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
  2. 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。

这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将 Java 堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。

把 Java 堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域——因而才有了“Minnor GC”、“Major GC”、“Full GC”这样的回收类型的划分;也才能够针对不同区域安排与里面存储对象存亡特征相匹配的垃圾收集算法——因而发展出了“标记-复制算法”、“标记-清除算法”、“标记-整理算法”等针对性的垃圾收集算法。

需要强调的是,分代收集并非只是简单划分一下内存区域那么容易,它至少存在一个明显的困难:对象不是孤立的,对象之间会存在跨代引用。

假如要现在进行一次只局限于新生代区域内的收集(Minor GC),但新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的 GC Roots 之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反过来也是一样。遍历整个老年代所有对象的方案虽然理论上可行,但无疑会为内存回收带来很大的性能负担。为了解决这个问题,就需要对分代收集理论添加第三条经验法则:

  1. 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说进占极少数。

这其实是可根据前两条假说逻辑推理得出的隐含结论:存在互相引用关系的两个对象是应该倾向于同时生寸或者同时消亡的。

依据这条假说,我们就只需在新生代上建立一个全局的数据结构(该结构被成为“记忆集”,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生 Minor GC 时,只有包含了跨代引用的小块内存里的对象才会被加入到 GC Roots 进行扫描。虽然这种方法需要在对象改变引用关系(如将自己或某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。

标记-清除算法

最早出现也是最基础的垃圾收集算法是“标记-清除”(Mark-Sweep)。如它的名字一样,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。

之所以说它是最基础的收集算法,是因为后续的收集算法大多是以标记-清除算法为基础,对其缺点进行改进而得到的。它的主要缺点有两个:第一个是执行效率不稳定,如果 Java 堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随着对象数量增长而降低;第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

标记-清除算法的执行过程如下图所示:
标记-清除算法

标记-复制算法

为了解决标记-清除算法面对大量可回收对象时执行效率低的问题,1969 年 Fenichel 提出了一种称为“半区复制”(Semispace Coping)的垃圾收集算法,它将可用内存划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。这样实现简单,运行搞笑,不过其缺陷也显而易见,这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太多了一点。

标记-复制算法的执行过程如下图所示:
标记-复制算法

在 1989 年,Andrew Appel 针对具有“朝生熄灭”特点的对象,提出了一种更优化的半区复制分代策略,现在称为“Apple 式回收”。HotSpot 虚拟机的 Serial、ParNew 等新生代收集器均采用了这种策略来设计新生代的内存布局。Apple 式回收的具体做法是把新生代分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次分配内存只使用 Eden 和其中一块 Survivor。发生垃圾收集时,将 Eden 和 Survivor 中仍然存活的对象一次性复制到另外的一块 Survivor 空间上,然后直接清理 Eden 和已用过的那块 Survivor 空间。HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8:1,也即每次新生代中可用内存空间为整个新生代容量的 90%,只有一个 Survivor 空间,即 10% 的新生代是会被“浪费”的。当然,任何人都没有办法百分百保证每次回收都只有不多于 10% 的对象存活,因此 Appel 式回收还有以后个充当罕见情况的“逃生门”的安全设计,当 Survivor 空间不足以容纳一次 Minnor GC 之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion)。

标记-整理算法

标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是,如果不想浪费 50% 的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都 100% 存活的极端情况,所以在老年代一般不能直接选用这种算法。

针对老年代对象的存亡特征,1974 年 Edward Lueders 提出了另外一种有针对性地“标记-整理”(Mark-Compact)算法,其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。

标记-整理算法的执行过程如下图所示:
标记-整理算法

标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策:移动则内存回收时会更复杂,不移动则内存分配时会更复杂。从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至可以不需要停顿,但是从整个程序的吞吐量来看,移动对象会更划算。

另外,还有一种“和稀泥式”解决方案可以不在内存分配和访问上增加太大额外负担,做法是让虚拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经达到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。CMS 收集器面临空间碎片过多时采用的就是这种处理办法。

经典垃圾收集器

HotSpot 虚拟机的垃圾收集器

上图展示了七种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用,图中收集器所处的区域,则表示它是属于新生代收集器抑或是老年代收集器。

Serial 收集器

Serial 收集器是一个单线程工作的收集器,但它的“单线程”的意义并不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集巩工作,更重要的是强调在它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。

Serial 收集器是 HotSpot 虚拟机运行在客户端模式下的默认新生代收集器,有着优于其他收集器的地方,那就是简单而高效(与其他收集器的单线程相比),对于内存资源受限的环境,它是所有收集器里额外内存消耗(Memory Footprint)最小的;对于单核处理器或处理器核心数较少的环境来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。

![Serial-Serial Old收集器运行示意图](/images/java/Serial-Serial Old收集器运行示意图.webp)

ParNew 收集器

ParNew 收集器实质上是 Serial 收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为包括 Serial 收集器可用的所有控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与 Serial 收集器完全一致,在实现上这两种收集器也共用了相当多的代码。

自 JDK9 开始,ParNew 加 CMS 收集器的组合不再是官方推荐的服务器端模式下的收集器解决方案。官方希望它能完全被 G1 所取代。在不久以后,ParNew 会合并入 CMS,称为它专门处理新生代的组成部分。ParNew 可以说是 HotSport 虚拟机中第一款退出历史舞台的垃圾收集器。

![ParNew-Serial Old收集器运行示意图](/images/java/ParNew-Serial Old收集器运行示意图.webp)

Parallel Scavenge 收集器

Parallel Scavenge 收集器也是一款新生代收集器,它同样是基于标记-复制算法实现的收集器,也是能够并行收集的多线程收集器。

Parallel Scavenge 收集器的特点是它的关注点与其他收集器不同,CMS 等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目标则是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是处理器用于运行代码的时间与处理器总消耗的时间的比值,即:吞吐量 = 运行用户代码时间/(运行用户代码时间 + 运行垃圾收集时间)。

Serial Old 收集器

Serial Old 是 Serial 收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。这个收集器的主要意义也是供客户端模式下的 HotSpot 虚拟机使用。

![Serial-Serial Old收集器运行示意图](/images/java/Serial-Serial Old收集器运行示意图.webp)

Parallel Old 收集器

Parallel Old 是 Parallel Scavenge 收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。

直到 Parallel Old 收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的搭配组合,在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器这个组合。

![Parallel Scavenge-Paraller Old收集器运行示意图](/images/java/Parallel Scavenge-Paraller Old收集器运行示意图.webp)

CMS 收集器

CMS(Concurrent Mark Sweep) 收集器是一种以获取最短回收停顿时间为目标的收集器。

CMS 是一款优秀的收集器,它最主要的优点在名字上已经体现出来:并发收集、低停顿,但是它至少有以下三个明显的缺点:

  1. 首先,CMS 收集器对处理器资源非常敏感。
  2. 然后,由于 CMS 收集器无法处理“浮动垃圾”(Floating Garbage),有可能出现“Concurrent Mode Failure”失败而导致另一次完全“Stop The World”的 Full GC 的产生。
  3. 最后,由于 CMS 是一款基于“标记-清除”算法实现的收集器,这意味着收集结束时会有大量空间碎片产生。

Garbage First 收集器

同之前的收集器不同,G1 收集器垃圾收集的范围不再是整个新生代(Minor GC),或整个老年代(Major GC),又或整个 Java 堆(Full GC),而是面向堆内存任何部分来组成回收集(Collection Set,一般简称 CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是 G1 收集器的 Mixed GC 模式。

G1 开创的基于 Region 的堆内存布局是它能够实现这个目标的关键。虽然 G1 也仍是遵循分代收集理论设计的,但其堆内存的布局与其他收集器有非常明显的差异:G1 不再坚持固定大小以及固定数量的分代区域划分,而是把连续的 Java 堆划分为多个大小相等的独立区域(Region),每一个 Region 都可以根据需要,扮演新生代的 Eden 空间、Survivor 空间,或者老年代空间。收集器能够对扮演不同角色的 Region 采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。

Region 中还有一类特殊的 Humongous 区域,专门用来存储大对象。G1 认为只要大小超过了一个 Region 容量一般的对象即可判定为大对象。每个 Region 的大小可以通过参数设定,取值范围为 1MB ~ 32MB,且应为 2 的 N 次幂。而对于那些超过了整个 Region 容量的超级大对象,将会被存放在 N 个连续的 Humongous Region 之中,G1 的大多数行为都把 Humongous Region 作为老年代的一部分来进行看待。

虽然 G1 仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它们都是一系列区域(不需要连续)的动态集合。G1 收集器之所以能建立可预测的停顿时间模型,是因为它将 Region 作为单次回收的最小单元,即每次收集到的内存空间都是 Region 大小的整数倍,这样可以有计划地避免在整个 Java 队中进行全区域的垃圾手机。更具体地处理思路是让 G1 收集器去跟踪各个 Region 里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间,优先处理回收价值收益最大的那些 Region,这也就是 “Garbage First”名字的由来。

G1 收集器

低延迟垃圾收集器

衡量垃圾收集器的三项最重要的指标是:内存占用(Footprint)、吞吐量(Throughput)和延迟(Latency),三者共同构成了一个“不可能三角”。三者总体的表现会随计数进步而越来越好,但是要在这三个方面同时具有卓越表现的“完美”收集器是极其困难甚至是不可能的,一款优秀的收集器通常最多可以同时达成其中的两项。

在内存占用、吞吐量和延迟这三项指标里,延迟的重要性日益凸显,越发备受关注。

Shenandoah 收集器

ZGC 收集器

内存分配与回收策略

  • 对象优先在 Eden 分配
  • 大对象直接进入老年代
  • 长期存活的对象将进入老年代
  • 动态对象年龄判定
  • 空间分配担保

Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有些区域随着虚拟机进程的启动一致存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。根据《Java 虚拟机规范》的规定,Java 虚拟机所管理的内存将会包括以下几个运行时数据区域,如下图所示:

运行时数据区域

程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指令器。在 Java 虚拟机的概念模型中,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功都需要依赖这个计数器来完成。

如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地(Native)方法,这个计数器的值则应为空(Undefined)。此内存区域是唯一一个在《Java虚拟机规范》中没有规定任何 OutOfMemoryError 情况的区域。

Java 虚拟机栈

虚拟机栈描述的是 Java 方法执行的线程内存模型:每个方法被执行的时候,Java 虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每个方法从被调用直到执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

在《Java虚拟机规范》中,对这个内存区域规定了两类异常状况:如果线程请求的栈深度大于虚拟机栈所允许的深度,将抛出 StackOverflowError 异常;如果 Java 虚拟机栈容量可以动态扩展,但扩展时无法申请到足够的内存会抛出 OutOfMemoryError 异常。

本地方法栈

本地方法栈(Native Method Stack)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。

有的虚拟机(譬如 HotSpot 虚拟机)直接把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈也会在栈深度溢出或者扩展失败时分别抛出 StackOverflowError 和 OutOfMemorError 异常。

Java 堆

对于 Java 应用程序来说,Java 堆(Java Heap)是虚拟机所管理的内存中最大的一块。Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。

由于即时编译技术的进步,尤其是逃逸分析技术的日益强大,栈上分配、标量替换优化手段已经导致一些微妙的变化悄然发生,以及日后可能出现值类型的支持,所以说 Java 对象实例都分配在对上也渐渐变得不是那么绝对了。

Java 堆既可以被是现成固定大小的,也可以是可扩展的,不过当前主流的 Java 虚拟机都是按照可扩展来实现的(通过参数 -Xmx 和 -Xms 设定)。如果在Java 堆中没有内存完成实例分配,并且堆也无法再扩展时,Java 虚拟机将会抛出 OutOfMemoryError 异常。

方法区

方法区(Method Area)与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。虽然《Java虚拟机规范》中把方法区描述成堆的一个逻辑部分,但是它有一个别名叫做“非堆”(Non-Heap),目的是与 Java 堆区分开来。

在 JDK8 之前,Java 虚拟机使用永久代来实现方法区,到了 JDK 8,已经完全废弃了永久代的概念,改用在本地内存中实现的元空间(Meta space)来代替。

《Java虚拟机规范》对方法区的约束是非常宽松的,甚至还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域的确是比较少见的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。这区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个取悦的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收有时又确实是有必要的。

根据《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时将抛出 OutOfMemoryError 异常。

运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。

需要特别注意的是,运行时常量池和字符串常量池的区别,在 JDK7 之前,运行时常量池逻辑包含字符串常量池存放在方法区,此时 HotSpot 虚拟机对方法区的实现为永久代。在 JDK7 字符串常量池被从方法区拿到了堆中,运行时常量池剩下的东西还在方法区, 也就是 HotSpot 中的永久代。JDK8 HotSpot 移除了永久代引入元空间, 这时候字符串常量池还在堆中, 运行时常量池还在方法区, 只不过方法区的实现从永久代变成了元空间。

直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致 OutOfMemoryError 异常出现。

显然,本机直接内存地分配不会受到 Java 堆大小的限制,但是,既然是内存,则肯定还是会受到本机内存(包括物理内存、SWAP 分区或者分页文件)大小以及处理器寻址空间的限制,一般服务器管理员配置虚拟机参数时,会根据实际内存去设置 -Xmx 等参数信息,但经常忽略掉直接内存,是的各个内存区域综合大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现 OutOfMemoryError 异常。

方式一:命令启动

1
java -Xdebug -Xrunjdwp:server=y,transport=dt_socket,address=8000,suspend=n -jar target/springboot-0.0.1-SNAPSHOT.jar

参数说明:

  • -Xdebug:表示项目工作在 debug 的模式下。
  • address=8000:开放 8000 作为调试端口。
  • server=y:表示在远程 Debug 会话的过程中作为服务端。
  • suspend=y:表示在客户端建立连接前,服务端被挂起;=n 则不会被挂起。 专门调试时建议设置成 y。

方式二:配置 Maven Plugin

1
2
3
4
5
6
7
8
9
10
11
12
<plugins>
<!-- Package as an executable jar -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<jvmArguments>
-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000
</jvmArguments>
</configuration>
</plugin>
</plugins>

然后进行打包 mvn package,在项目的根路径下面启动项目,使用命令 mvn spring-boot。

JVM 参数

标准参数

在 JVM 各版本中基本不变,相对稳定。

  • -help
  • -server -client
  • -version -showversion
  • -cp -classpath

X 参数

在 JVM 各版本中可能会变,变化较小。

  • -Xint:解释执行
  • -Xcomp:第一次使用就编译成本地代码
  • -Xmixed:混合模式,JVM 自己来决定是否编译成本地代码

XX 参数

相对不稳定,主要用于 JVM 调优和 Debug。

Boolean 类型:
格式:-XX:[+-],标识启用或者禁用 name 属性。

非 Boolean 类型:
格式:-XX:=,表示 name 属性的值是 value。

  • -XX:+UserConcMarkSweepGC

  • -XX:+UseG1GC

  • -XX:MaxGCPauseMillis

  • -XX:GCTimeRatio

  • -Xms:等价于 -XX:InitialHeapSize,表示 JVM 启动时分配的内存。

  • -Xmx:等价于 -XX:MaxHeapSize,表示 JVM 运行过程中分配的最大内存。

  • -Xss:等价于 -XX:ThreadStackSize,表示 JVM 启动的每个线程分配的内存大小。

  • -XX:+HeapDumpOnOutOfMemoryError

  • -XX:HeapDumpPath

jps

1
jps

jstat

类加载

1
2
3
4
jstat -class pid

Loaded Bytes Unloaded Bytes Time
16136 28826.9 1 0.9 36.67

垃圾收集

1
2
3
4
jstat -gc 17204

S0C S1C S0U S1U EC EU OC OU MC MU CCSC CCSU YGC YGCT FGC FGCT GCT
27136.0 20992.0 0.0 20985.8 330240.0 105111.7 139264.0 41337.2 82688.0 77595.2 11776.0 10855.5 19 1.042 3 0.528 1.570
  • S0C、S1C、S0U、S1U:S0 和 S1 的总量与使用量
  • EC、EU:Eden 区总量与使用量
  • OC、OU:Old 区总量与使用量
  • MC、MU:Metaspace 区总量与使用量
  • CCSC、CCSU:压缩类空间总量与使用量
  • YGC、YGCT:YoungGC 的次数与时间
  • FGC、FGCT:FullGC 的次数与实践
  • GCT:总的 GC 时间

JIT 编译

1
2
3
4
jstat -compiler 17204

Compiled Failed Invalid Time FailedType FailedMethod
8432 3 0 4.32 1 org/apache/http/client/utils/URLEncodedUtils parse

jmap

1
jmap -dump:format=b,file=heap.hprof pid
1
jmap -heap pid

jstat

1
jstack pid

jvisualvm

BTrace

问题

  • 超卖

  • 高并发

  • 恶意请求

方案

数据库锁

  • 行锁

    1
    update goods set inventory = inventory-1 where id = #{goodsId} and inventory > 0

    问题:大量锁竞争时,会影响数据库性能。

  • 行锁 + 乐观锁

    1
    update goods set inventory = inventory-1 where id = #{goodsId} and inventory > 0 and version = #{version}

    问题:库存 100,且同时只有 100 人抢购商品时,实际卖出的商品可能少于 100。同样存在性能问题

分布式锁

  • redis 分布式锁
    问题:不设置锁的过期时间,可能会导致锁一致得不到释放;设置锁的过期时间,又可能因为业务执行时间较长而导致锁提前释放。使用 Lua 脚本或 Redlock 都较为复杂。

  • ZooKeeper 分布式锁

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    InterProcessMutex mutex = new InterProcessMutex(zookeeperClient, "/seckill/" + goodsDto.getGoodsId());
    mutex.acquire();

    try {
    // TODO 查库存
    // TODO 减库存
    // TODO 下单
    } finally {
    mutex.release();
    }

库存预热 & 内存标记

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Override
public void afterPropertiesSet() {
List<GoodsEntity> goodsList = goodsRepository.findAll();
goodsList.forEach(goods -> {
redisTemplate.opsForValue().set(SECKILL_GOODS_KEY_PREFIX + goods.getId(), String.valueOf(goods.getInventory()));
localGoodsOverMap.put(goods.getId(), false);
});
}

...

// 读取内存标记,判断商品是否售完
if (localGoodsOverMap.get(goodsId)) {
log.info("商品【{}】已售完,抢购失败!", goodsId);
return;
}

*** 分布式锁开始 ***

// 递减 redis 中库存数量,判断商品是否已售完
Long inventory = redisTemplate.opsForValue().decrement(SECKILL_GOODS_KEY_PREFIX + goodsDto.getGoodsId());
if (inventory == null || inventory < 0) {
log.info("商品【{}】已售完,抢购失败!", goodsId);
localGoodsOverMap.put(goodsId, true);
return;
}

// TODO 查库存
// TODO 减库存
// TODO 下单

*** 分布式锁结束 ***

异步下单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
OrderEntity order = new OrderEntity();
order.setUserId(userId);
order.setGoodsId(goodsId);
order.setStatus(OrderStatus.TO_BE_PAID);
order.setGoodsNum(1);

// 异步下单
// 也可以异步更新库存,这就要求消费者逐个消费消息,不然也会出现并发问题
rabbitTemplate.convertAndSend(DIRECT_EXCHANGE, DIRECT_ROUTING_KEY, order);

...

@RabbitListener(queues = DIRECT_QUEUE, concurrency = "10")
public void process(OrderEntity order, Channel channel, @Header(AmqpHeaders.DELIVERY_TAG) long tag) {
log.info("Receive message by direct-queue: {}", order);

try {
orderRepository.save(order);
basicAck(channel, tag);
} catch (Exception e) {
log.error("创建订单失败【{}】", order);
basicNack(channel, tag);
}

/**
* 接收消息确认
*/
private void basicAck(Channel channel, long tag) {
try {
channel.basicAck(tag, false);
} catch (IOException e) {
log.error("Ack message failure", e);
}
}

/**
* 拒绝消息确认
*/
private void basicNack(Channel channel, long tag) {
try {
channel.basicNack(tag, false, false);
} catch (IOException e) {
log.error("Nack message failure", e);
}
}

按钮控制

秒杀开始之前,按钮置灰;用户抢购商品之后,按钮再次置灰。

URL 动态化

  1. 在秒杀之前,前端先请求后端获取商品秒杀地址。在后端生成随机数作为 pathId 存入缓存(缓存过期时间 60s),然后将这个随机数返回给前端。
  2. 前端获得 pathId 后,将其作为 URL 参数去请求后端秒杀服务。
  3. 后端接收 pathId 参数后,将其与缓存中的 pathId 比较。

示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public SeckillGoodsDto createSeckillUrl(String goodsId) {
String randomCode = generateRandomCode(goodsId);
redisTemplate.opsForValue().set(goodsId, randomCode);

SeckillGoodsDto goodsDto = new SeckillGoodsDto();
goodsDto.setGoodsId(goodsId);
goodsDto.setRandomCode(randomCode);
return goodsDto;

}

private String generateRandomCode(String goodsId) {
return ...;
}

public boolean buyGoods(SeckillGoodsDto goodsDto) {
// 校验商品 URL 随机码是否一致
boolean isValid = validateRandomCode(goodsDto.getGoodsId(), goodsDto.getRandomCode());
if (isValid) {
...
return true;
}
return false;
}

private boolean validateRandomCode(String goodsId, String randomCode) {
String cachedRandomCode = redisTemplate.opsForValue().get(goodsId);
return randomCode.equals(cachedRandomCode);
}

用户/IP 限流

  • 前端限流
    秒杀按钮在活动之前置灰,在用户购买之后再次置灰。

  • 后端限流
    相同用户/IP,设置请求次数限制。如可以基于 Spring Cloud Gateway 添加以下配置:

application.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
spring:
cloud:
gateway:
routes:
# 秒杀服务
- id: seckill
uri: lb://seckill
filters:
# ip 限流
- name: RequestRateLimiter
args:
# 限流匹配策略
key-resolver: '#{@ipKeyResolver}'
# 令牌桶的填充速率:用户每秒执行多少请求
redis-rate-limiter.replenishRate: 10
# 令牌桶的容量:用户在一秒钟内执行的最大请求数
# 将此值设置为零将阻塞所有请求;将此值设置为高于 replenishRate,以允许临时突发
redis-rate-limiter.burstCapacity: 20
# 用户限流
- name: RequestRateLimiter
args:
# 限流匹配策略
key-resolver: '#{@userIdKeyResolver}'
# 令牌桶的填充速率:用户每秒执行多少请求
redis-rate-limiter.replenishRate: 10
# 令牌桶的容量:用户在一秒钟内执行的最大请求数
# 将此值设置为零将阻塞所有请求;将此值设置为高于 replenishRate,以允许临时突发
redis-rate-limiter.burstCapacity: 20

ThrottlingConfiguration.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@Configuration
public class ThrottlingConfiguration {
private static final String USER_ID_NAME = "userId";

/**
* 接口限流
*/
@Bean
public KeyResolver apiKeyResolver() {
return exchange -> Mono.just(exchange.getRequest().getPath().value());
}

/**
* ip 限流
*/
@Primary
@Bean
public KeyResolver ipKeyResolver() {
return exchange -> Mono.just(Objects.requireNonNull(exchange.getRequest().getRemoteAddress()).getAddress().getHostAddress());
}

/**
* 用户限流(未经身份验证直接拒绝请求)
*/
@Bean
public KeyResolver principalNameKeyResolver() {
return new PrincipalNameKeyResolver();
}

/**
* 用户限流(要求请求路径中必须携带 userId 参数)
*/
@Bean
public KeyResolver userIdKeyResolver() {
return exchange -> Mono.just(Objects.requireNonNull(exchange.getRequest().getQueryParams().getFirst(USER_ID_NAME)));
}
}

资源静态化

  • JS/CSS 压缩,减少流量

  • CDN 就近访问

兜底方案

  • 降级
    所谓“降级”,就是当系统的容量达到一定程度时,限制或者关闭系统的某些非核心功能,从而把有限的资源保留给更核心的业务。

  • 限流
    限流就是当系统容量达到瓶颈时,我们需要通过限制一部分流量来保护系统,并做到既可以人工执行开关,也支持自动化保护的措施。

  • 拒绝服务
    当系统负载达到一定阈值时,例如 CPU 使用率达到 90% 或者系统 load 值达到 2*CPU 核数时,系统直接拒绝所有请求,这种方式是最暴力但也最有效的保护方式。