Tallate

该吃吃该喝喝 啥事别往心里搁

与 Jedis 的区别

  1. Jedis 提供了对 Redis-API 的简单封装,使用 Jedis 时,需要关注 Redis 服务器的部署细节,而 Redisson 屏蔽了这些细节,使得使用者可以将精力更集中地放到自己希望实现的功能上。
  2. Jedis 只提供简单的 API 调用,并不关注用户如何使用这些 API,比如 string 可以实现原子变量,不过需要用户手动封装,而 Redisson 中已经有了现成的 AtomicLong。
  3. Jedis 不支持 Cluster 环境下的事务、Lua

Sentinel 模式获取连接

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
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
public class RedissonSentinelConnectionTest {

RedissonClient redisson;
RedisSentinelConnection connection;
RedisRunner.RedisProcess master;
RedisRunner.RedisProcess slave1;
RedisRunner.RedisProcess slave2;
RedisRunner.RedisProcess sentinel1;
RedisRunner.RedisProcess sentinel2;
RedisRunner.RedisProcess sentinel3;

@Before
public void before() throws FailedToStartRedisException, IOException, InterruptedException {
master = new RedisRunner()
.nosave()
.randomDir()
.run();
slave1 = new RedisRunner()
.port(6380)
.nosave()
.randomDir()
.slaveof("127.0.0.1", 6379)
.run();
slave2 = new RedisRunner()
.port(6381)
.nosave()
.randomDir()
.slaveof("127.0.0.1", 6379)
.run();
sentinel1 = new RedisRunner()
.nosave()
.randomDir()
.port(26379)
.sentinel()
.sentinelMonitor("myMaster", "127.0.0.1", 6379, 2)
.run();
sentinel2 = new RedisRunner()
.nosave()
.randomDir()
.port(26380)
.sentinel()
.sentinelMonitor("myMaster", "127.0.0.1", 6379, 2)
.run();
sentinel3 = new RedisRunner()
.nosave()
.randomDir()
.port(26381)
.sentinel()
.sentinelMonitor("myMaster", "127.0.0.1", 6379, 2)
.run();

Thread.sleep(5000);

Config config = new Config();
config.useSentinelServers()
.setLoadBalancer(new RandomLoadBalancer())
.addSentinelAddress(sentinel3.getRedisServerAddressAndPort()).setMasterName("myMaster");
redisson = Redisson.create(config);

RedissonConnectionFactory factory = new RedissonConnectionFactory(redisson);
connection = factory.getSentinelConnection();
}

@After
public void after() {
sentinel1.stop();
sentinel2.stop();
sentinel3.stop();
master.stop();
slave1.stop();
slave2.stop();

redisson.shutdown();
}

@Test
public void testMasters() {
Collection<RedisServer> masters = connection.masters();
assertThat(masters).hasSize(1);
}

@Test
public void testSlaves() {
Collection<RedisServer> masters = connection.masters();
Collection<RedisServer> slaves = connection.slaves(masters.iterator().next());
assertThat(slaves).hasSize(2);
}

@Test
public void testRemove() {
Collection<RedisServer> masters = connection.masters();
connection.remove(masters.iterator().next());
}

@Test
public void testMonitor() {
Collection<RedisServer> masters = connection.masters();
RedisServer master = masters.iterator().next();
master.setName(master.getName() + ":");
connection.monitor(master);
}

@Test
public void testFailover() throws InterruptedException {
Collection<RedisServer> masters = connection.masters();
connection.failover(masters.iterator().next());

Thread.sleep(10000);

RedisServer newMaster = connection.masters().iterator().next();
assertThat(masters.iterator().next().getPort()).isNotEqualTo(newMaster.getPort());
}
}
  1. 确定 Sentinel 集群内的所有节点地址
    创建连接管理器(ConnectionManager)时读取所有 Master、Slave 和 Sentinel 节点的地址(org.redisson.connection.SentinelConnectionManager#SentinelConnectionManager)
    Sentinel 通过监听 master 可以得到所有节点的地址。
    可以从SentinelConnectionManager中看到,客户端会定时(默认1秒)地刷新服务端状态,即使集群暂时不可用,也可以通过这种刷新来恢复连接。
  2. 尝试连接一个 Sentinel
    只要有一个 Sentinel 能通过 PING-PONG 校验,则返回对该 Sentinel 的连接。
  3. 执行操作
    获取连接(org.redisson.command.RedisExecutor#getConnection)。
    如果是只读的操作,会从 slave 中通过负载均衡选一个操作(org.redisson.connection.MasterSlaveConnectionManager#connectionReadOp);
    如果是非只读操作,从 master 里选一个操作(org.redisson.connection.ConnectionManager#connectionWriteOp)。

Cluster 模式获取连接

  1. 添加节点初始化 Redisson
    对于客户端来说,Cluster 模式可以看做几个 Master、Slave 的组合(org.redisson.ClusterRunner#addNode)。
  2. 连接时从节点中选一个
    以Buckets.get操作为例,跟踪代码直到CommandAsyncService#readAsync(String key, Codec codec, RedisCommand<T> command, Object ... params)
    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
    @Override
    public int calcSlot(String key) {
    ...

    // slot的计算方法,注意这里的MAX_SLOT是固定的
    int result = CRC16.crc16(key.getBytes()) % MAX_SLOT;
    log.debug("slot {} for {}", result, key);
    return result;
    }

    private NodeSource getNodeSource(String key) {
    // 计算该key属于哪个slot
    int slot = connectionManager.calcSlot(key);
    // 计算该slot属于哪个节点
    MasterSlaveEntry entry = connectionManager.getEntry(slot);
    return new NodeSource(entry);
    }

    @Override
    public <T, R> RFuture<R> readAsync(String key, Codec codec, RedisCommand<T> command, Object... params) {
    RPromise<R> mainPromise = connectionManager.newPromise();
    // 获取key所在的节点
    NodeSource source = getNodeSource(key);
    async(true, source, codec, command, params, mainPromise, 0);
    return mainPromise;
    }
    先计算 key 属于哪个 slot(org.redisson.cluster.ClusterConnectionManager#calcSlot);
    再由 slot 计算应该请求哪对主从(org.redisson.connection.MasterSlaveConnectionManager#getEntry)。
  3. 重连
    Redisson启动后会创建一个定时任务每5秒更新一次节点状态,所以就算节点挂掉了,之后重启时客户端是可以感知到服务器的启动的。
    org.redisson.cluster.ClusterConnectionManager#scheduleClusterChangeCheck

分布式锁

文档:8. 分布式锁和同步器
下面记录一下 Redisson 中对应功能所在的代码位置和基本思路。

锁的特性

  • 互斥性
    任意时刻,只会有一个客户端持有锁
  • 不会发生死锁
    即使客户端在持有锁期间崩溃而没有释放锁,也能保证其他客户端能获取到锁。
  • 容错性
    锁服务的某个节点不可用时,客户端还能继续加解锁。
  • 可重入性
    一个客户端可以重复加锁,期间其他客户端无法获取这个锁。

可重入锁(Reentrant Lock)

测试代码见:org.redisson.RedissonLockTest#testGetHoldCount
源码主要为:org.redisson.RedissonLock

可重入性是通过加锁时传的 threadId 实现的,下面是 Redisson 中用于加锁的 lua 脚本(org.redisson.RedissonLock#tryLockInnerAsync):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
-- KEYS[1]: RedissonObject中的name字段,这里表示锁的名字
-- ARGV[1]: leaseTime,过期时间,这里为30000
-- ARGV[2]: 锁的value,这里为threadId

-- 未加过锁的情况
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hset', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
-- 当前线程已加过锁的情况
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
-- 其他线程加过锁的情况
"return redis.call('pttl', KEYS[1]);"

所有锁都保存在一个 key 为 lock 的 hash 对象下,第一次加锁时保存的结果为<lock, {threadId}, 1>,过期时间为 30s,同一线程第二次加锁时,更新为<lock, {threadId}, 2>,且过期时间被刷新。
当加锁成功时(包括同一线程调用重入多次)返回 null,而加锁失败时,返回锁的剩余过期时间,根据返回值是否为空可以判断加锁是否成功,当还未获取到锁时,客户端会轮询检查(org.redisson.RedissonLock#lock(long leaseTime, TimeUnit unit, boolean interruptibly)中的 while 循环),也就是说这种加锁方式并不是公平的
加锁监控保证了当业务执行时间超过加锁时间时,不会因为锁过期而让其他线程进入临界区,在 Redisson 中是通过一个 TimerTask 每隔 10s(即加锁时间 / 3)刷新一次锁的过期时间来实现的(org.redisson.RedissonLock#renewExpiration)。

另外,由于加锁时保存了 threadId,unlock时同样会传 threadId、只能释放当前线程加上的锁,下面是用于释放锁的 lua 脚本(org.redisson.RedissonLock#unlockInnerAsync):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
-- KEYS[1]: {lockName}
-- KEYS[2]:
-- ARGS[1]: UNLOCK_MESSAGE
-- ARGS[2]: 加锁时间,默认30s
-- ARGS[3]: {threadId}

-- 检查当前线程是否有加该锁
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
"return nil;" +
"end; " +
-- 计数器-1
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
-- 如果计数器未减完,说明重入了多次,且这里刷新了一次过期时间
"if (counter > 0) then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0; " +
-- 计数器减完,删除该锁,并通知其他正在等待的线程
"else " +
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; "+
"end; " +
"return nil;"

公平锁(Fair Lock)

测试代码见:org.redisson.RedissonFairLockTest#testIsLockedOtherThread
源码主要为:org.redisson.RedissonFairLock

公平性是通过队列实现的,(org.redisson.RedissonFairLock#tryLockInnerAsync):

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
64
65
66
67
68
69
70
71
// remove stale threads
"while true do " +
"local firstThreadId2 = redis.call('lindex', KEYS[2], 0);" +
"if firstThreadId2 == false then " +
"break;" +
"end;" +

"local timeout = tonumber(redis.call('zscore', KEYS[3], firstThreadId2));" +
"if timeout <= tonumber(ARGV[4]) then " +
// remove the item from the queue and timeout set
// NOTE we do not alter any other timeout
"redis.call('zrem', KEYS[3], firstThreadId2);" +
"redis.call('lpop', KEYS[2]);" +
"else " +
"break;" +
"end;" +
"end;" +

// check if the lock can be acquired now
"if (redis.call('exists', KEYS[1]) == 0) " +
"and ((redis.call('exists', KEYS[2]) == 0) " +
"or (redis.call('lindex', KEYS[2], 0) == ARGV[2])) then " +

// remove this thread from the queue and timeout set
"redis.call('lpop', KEYS[2]);" +
"redis.call('zrem', KEYS[3], ARGV[2]);" +

// decrease timeouts for all waiting in the queue
"local keys = redis.call('zrange', KEYS[3], 0, -1);" +
"for i = 1, #keys, 1 do " +
"redis.call('zincrby', KEYS[3], -tonumber(ARGV[3]), keys[i]);" +
"end;" +

// acquire the lock and set the TTL for the lease
"redis.call('hset', KEYS[1], ARGV[2], 1);" +
"redis.call('pexpire', KEYS[1], ARGV[1]);" +
"return nil;" +
"end;" +

// check if the lock is already held, and this is a re-entry
"if redis.call('hexists', KEYS[1], ARGV[2]) == 1 then " +
"redis.call('hincrby', KEYS[1], ARGV[2],1);" +
"redis.call('pexpire', KEYS[1], ARGV[1]);" +
"return nil;" +
"end;" +

// the lock cannot be acquired
// check if the thread is already in the queue
"local timeout = redis.call('zscore', KEYS[3], ARGV[2]);" +
"if timeout ~= false then " +
// the real timeout is the timeout of the prior thread
// in the queue, but this is approximately correct, and
// avoids having to traverse the queue
"return timeout - tonumber(ARGV[3]) - tonumber(ARGV[4]);" +
"end;" +

// add the thread to the queue at the end, and set its timeout in the timeout set to the timeout of
// the prior thread in the queue (or the timeout of the lock if the queue is empty) plus the
// threadWaitTime
"local lastThreadId = redis.call('lindex', KEYS[2], -1);" +
"local ttl;" +
"if lastThreadId ~= false and lastThreadId ~= ARGV[2] then " +
"ttl = tonumber(redis.call('zscore', KEYS[3], lastThreadId)) - tonumber(ARGV[4]);" +
"else " +
"ttl = redis.call('pttl', KEYS[1]);" +
"end;" +
"local timeout = ttl + tonumber(ARGV[3]) + tonumber(ARGV[4]);" +
"if redis.call('zadd', KEYS[3], timeout, ARGV[2]) == 1 then " +
"redis.call('rpush', KEYS[2], ARGV[2]);" +
"end;" +
"return ttl;"

联锁(MultiLock)

源码位置:org.redisson.RedissonMultiLock

联锁是对批量加锁的封装,其关键是如何实现死锁避免,其中的关键代码如下(org.redisson.RedissonMultiLock#tryLock(long waitTime, long leaseTime, TimeUnit unit)):

1
2
3
4
5
6
7
8
9
10
11
// 将已经获取的锁释放掉
unlockInner(acquiredLocks);
if (waitTime == -1) {
return false;
}
failedLocksLimit = failedLocksLimit();
acquiredLocks.clear();
// 重置iterator,重新加一遍锁
while (iterator.hasPrevious()) {
iterator.previous();
}

红锁(RedLock)

测试代码:org.redisson.RedissonRedLockTest#testLockLeasetime
源码位置:org.redisson.RedissonRedLock

红锁实际上是联锁的子类,原理基本一致,它和联锁的区别主要是:

  • 联锁不允许加锁失败(org.redisson.RedissonMultiLock#failedLocksLimit),而红锁允许少于半数次的加锁失败(org.redisson.RedissonRedLock#failedLocksLimit)。
  • 使用时,红锁的加锁目标最好包含多个 Redis 实例,从而实现高可用。

    如果一个Redis实例加多次锁,那么这个Redis挂掉了就会导致全部加锁请求都失败了。

红锁执行流程

  1. 获取当前时间戳;
  2. 开始获取锁:Client按顺序从每台Redis实例上获取锁;
    注意每台服务器都有一个获取的截止时间,超过一段时间获取不到就放弃,而且这个截止时间要比总的获取锁的TTL时间要短很多,避免由于等待部分已停机的Redis实例时间过长而导致获取锁失败了。
    比如总TTL为5s,那么每台Redis实例的获取时间就可以定为1s。
    因为是顺序获取的,所以每台实例上锁的过期时间也是不一样的。
  3. 怎么样算获取成功:过半数,且未超时
    • 过半数:比如总共有5个Redis实例的情况下,需要有至少3个实例成功获取到锁才算获取成功;
    • 未超时:(总TTL) - (每台服务器获取锁花费的时间之和)需要大于0,如果获取成功,锁的真正有效时间就是这个时间差。
  4. 获取失败释放锁
    不满足获取成功条件的情况下,把之前获取过锁的Redis实例都给释放掉。

锁续期 - 看门狗

看门狗原理,下图来自于这里
红锁-看门狗
加锁时启动定时任务刷新锁的过期时间:
org.redisson.RedissonLock#tryAcquireOnceAsync
-> org.redisson.RedissonLock#scheduleExpirationRenewal
释放锁时关掉该定时任务:
org.redisson.RedissonLock#unlock
-> org.redisson.RedissonLock#cancelExpirationRenewal

可重入性

可重入性原理,下图来自于这里
红锁-可重入性

红锁存在的问题

  1. 一般Redis集群都是多主多从,但是使用多主多从的情况下,锁是加到主服务器上的,而主从复制是异步完成的,如果在客户端获取到锁之后,主复制锁到从的过程中崩溃了,导致没有复制到从Redis中,那么之后即使再选举出一个从升级为主,主服务器里也是没有锁的,并且能够成功被获取到锁,导致互斥失效。
    所以,使用红锁时Redis集群一般都是单节点,而不是主从的。
  2. 5主无从的情况下,如果一个客户端获取到锁之后,所有Redis重启,这时其他客户端又可以获取到锁了,显然违背了锁的互斥原则;如果Redis实例开启了AOF持久化存储,在持久化间隔时间内断电,照样会导致数据丢失。

    显然AOF不能开启Always(每个命令都同步到硬盘),这样会造成性能急剧下降。

读写锁(ReadWriteLock)

TODO

信号量(Semaphore)

TODO

可过期性信号量(PermitExpirableSemaphore)

TODO

闭锁(CountDownLatch)

TODO

异常情况分析

  • 因为主从同步导致锁被重复获取
    Redis集群如果采用Cluster集群或Master-Slave主从复制的方式,就会存在key刚写完Master、但是在同步到Slave之前Master挂掉的情况,这时如果发生主从切换,就有可能会出现多个线程同时持有锁的情况。
  • 因为GC导致锁被重复获取
    如果出现GC停顿时间过长,或者其他情况导致客户端和Redis连接断开,也有可能出现多个线程同时持有一个锁的情况。

参考

  1. Redlock(redis分布式锁)原理分析

分库分表

为什么需要分库分表

  • MySQL单表超过容量上限
    这一上限的推荐值是2kw行,但实际情况需要综合考虑单库的CPU、磁盘、内存压力、具体业务,如果硬件无法继续垂直扩容了、且业务也无法优化了,就会选择分库分表、或者在原来分库分表基础上水平伸缩。

以什么维度分库分表?

分表键需要考虑实际的业务场景,比如TO C的业务一般可以uid作为分表键,TO B业务常用orgId。
还有一些场景需要支持多种方式查询,可以采用叫“基因法”的方式来分表[1]。

ShardingJDBC

事务

柔性事务

2.0 后提供柔性事务支持,执行事务前先发消息给一个 EventBus,失败后由 EventBus 负责重试。

TCC 模式

3.0 后借助 Seata 提供 TCC 模式的分布式事务。

源码分析

启动

  1. 数据源元数据信息和表元数据信息的收集
  2. 表分库分表策略和算法的配置信息收集

ShardingDataSourceFactory#createDataSource 创建数据源 ShardingDataSource 实例
-> ShardingDataSource#ShardingDataSource 创建 ShardingContext,其持有ShardingRuleShardingMetaData两个属性,根据一个表以及这个表的列可以从 ShardingRule 中获取这个表的分库分表策略和算法,ShardingMetaData 则维护了数据源和表的元数据信息

ShardingJDBC 如何嵌入 MyBatis

ShardingJDBC 接入 MyBatis 的原理是 DataSource 的替换,调用链如下:
org.apache.ibatis.executor.SimpleExecutor#prepareStatement
-> SimpleExecutor#getConnection 从 transaction 获取连接
-> SpringManagedTransaction#getConnection 可以看到成员变量 dataSource 是 ShardingDataSource
-> ShardingDataSource#getConnection 得到 ShardingConnection
-> ShardingConnection#prepareStatment 得到 ShardingPreparedStatement

路由及改写引擎

ShardingPreparedStatement#executeQuery、executeUpdate、execute
-> ShardingPreparedStatement#shard
-> BaseShardingEngine#shard
-> PreparedQueryShardingEngine#route
-> PreparedStatementRoutingEngine#route ParsingSQLRouter 使用四个引擎对 sql 进行解析和重写
-> ParsingSQLRouter#parse
-> SQLParsingEngine#parse 使用SQLParsingEngine解析 sql,返回 SQLStatement 作为解析的结果
-> SQLParserFactory#newInstance 获取 SQLParser 实例,如果是在 MySQL 中执行一个 DML 语句会匹配到 AntlrParsingEngine(Antlr 是一个开源语法分析器)
-> AntlrParsingEngine.parse 分析 SQL,返回 SQLStatement
-> SQLParserEngine#parse 解析 SQL 语法,生成 AST(抽象语法树)
-> SQLSegmentsExtractorEngine#extract
-> SQLStatementFillerEngine#fill
-> SQLStatementOptimizerEngine#optimize
-> ParsingSQLRouter#route
-> OptimizeEngine#optimize 使用OptimizeEngine对 SQLStatement 进行优化,返回 ShardingConditions 对象
-> RoutingEngine#route 使用RoutingEngine根据库表分片配置以及 ShardingConditions 找到目标库表,返回 RoutingResult 对象
-> ShardingMasterSlaveRouter#route(SQLRouteResult sqlRouteResult)
-> BaseShardingEngine#rewriteAndConvert
-> SQLRewriteEngine#rewrite 使用SQLRewriteEngine根据路由结果重写 sql

执行引擎

ShardingPreparedStatement#executeQuery、executeUpdate、execute
-> …
-> ShardingPreparedStatement#initPreparedStatementExecutor
-> PreparedStatementExecutor#init 把 SQLRouteResult 中的 RouteUnit 对象转换为 ShardingExecuteGroup对象集合并从数据源获取连接和 PreparedStatement
-> PreparedStatementExecutor#obtainExecuteGroups 根据路由的结果 SQLRouteResult 中的 RouteUnit 集合创建 StatementExecuteUnit 集合对象
-> AbstractConnectionAdapter#getConnections 获取数据源连接
-> getDataSourceMap().get(dataSourceName) 根据逻辑数据源名称获取真实数据源 DataSource
-> AbstractConnectionAdapter#createConnections 从数据源获取连接 Connection
-> getExecuteGroups().addAll() 将 StatementExecuteUnit 集合保存在 AbstractStatementExecutor 的属性 executeGroups 中
-> AbstractStatementExecutor#cacheStatements 从连接获取 Statement 并缓存
-> PreparedStatementExecutor#executeQuery、executeUpdate、execute
-> AbstractStatementExecutor#executeCallback
-> SQLExecuteTemplate#executeGroup
-> ShardingExecuteEngine#groupExecute 使用ShardingExecuteEngine执行,执行 ShardingExecuteGroup
-> ShardingExecuteEngine#parallelExecute 异步执行,如果 inputGroups 集合不止一个,则第一个同步执行、其他的异步执行

归并引擎

ShardingPreparedStatement#executeQuery、executeUpdate、execute
-> …
-> ShardingPreparedStatement#getResultSet(MergeEngine mergeEngine)
-> AbstractStatementExecutor#getResultSets
-> ShardingPreparedStatement#getCurrentResultSet 合并结果,返回 ShardingResultSet
-> MergeEngine#merge 使用MergeEngine对结果进行合并,executeQuery()返回的是一个 List集合,此时需要对 QueryResult 集合进行合并,MergeEngine 接口有两个实现类 DQLMergeEngine 和 DALMergeEngine,这两个实现类分别负责数据查询 sql 的合并和数据库管理 sql 的合并

参考

分库分表

  1. 帖子中心,1亿数据,架构如何设计?
    提到给帖子中心设计数据库结构时将tid(帖子ID)还是uid(用户ID)作为分表key,因为实际业务中既包含根据tid查询的场景又包含根据uid查询的场景,因此最终方案是采用所谓的“基因法”。

ShardingJDBC

  1. 张亮:Sharding-Sphere 成长记
  2. sharding-sphere/ShardingSphereDemo
  3. Document
  4. Sharding-JDBC 源码解析
  5. antlr 解析语法树的使用

Eureka

  • 服务发现,提供高可用的服务注册表功能,是之后 Ribbon、Zuul 等组件的基础。

创建 Eureka Server

  1. 创建 Spring Initializr 项目
    选中 Cloud Discovery -> Eureka Server 模块
  2. EurekaServerApplication 类上添加 @EnableEurekaServer
  3. 指定一个 Eureka Server
    在默认情况下 erureka server 也是一个 eureka client,必须要指定一个 server,在配置文件中添加:
    1
    2
    3
    4
    5
    6
        server.port=8761
    eureka.instance.hostname=localhost
    eureka.client.register-with-eureka=false
    eureka.client.fetch-registry=false
    eureka.client.service-url.default-zone=http://eureka.instance.hostname:eureka.instance.hostname:
    {server.port}/eureka/
    其中通过 eureka.client.registerWithEureka=false 和 fetchRegistry=false 来表明自己是一个 Eureka Server。
  4. 访问 Eureka Server 服务器
    localhost:8761
    此时还没有服务被注册进来。

创建 Eureka Client

  1. 类似的,创建的 Spring Initializr 项目是 Eureka Discovery
  2. EurekaClientApplication 类上添加 @EnableEurekaClient 注解
  3. 添加配置
    1
    2
    3
    4
    5
    eureka.client.service-url.default-zone=http://localhost:8761/eureka
    server.port=8762
    spring.application.name=service-name
    eureka.client.register-with-eureka=false
    eureka.client.fetch-registry=false
    需要指明 spring.application.name,这个很重要,这在以后的服务与服务之间相互调用一般都是根据这个 name。

集群化 Eureka Server

  1. 创建两个配置文件,分别代表两个实例的属性
    application-peer1.properties:
    1
    2
    3
    server.port=8761
    eureka.instance.hostname=peer1
    eureka.client.service-url.defaultZone=http://peer2:8769/eureka/
    application-peer2.properties:
    1
    2
    3
    server.port=8769
    eureka.instance.hostname=peer2
    eureka.client.service-url.defaultZone=http://peer1:8761/eureka/
  2. 在/etc/hosts 文件中添加下面两条记录:
    1
    2
    127.0.0.1 peer1
    127.0.0.1 peer2
  3. 启动
    先在application.properties中指定:
    1
    spring.profiles.active=peer1
    启动 peer1,然后指定:
    1
    spring.profiles.active=peer2
    启动 peer2。
    这时访问 localhost:8761 和 localhost:8769 可以看到两个节点的注册信息,他们分别是对方的复制,他们是对等的。

Ribbon - 服务消费

  • 用来作为软件的负载均衡,微服务之间相互调用是通过 ribbon 作为软件负载均衡使用负载到微服务集群内的不同的实例。

配置Bean

IClientConfig ribbonClientConfig: DefaultClientConfigImpl
IRule ribbonRule: ZoneAvoidanceRule
IPing ribbonPing: NoOpPing
ServerList ribbonServerList: ConfigurationBasedServerList
ServerListFilter ribbonServerListFilter: ZonePreferenceServerListFilter
ILoadBalancer ribbonLoadBalancer: ZoneAwareLoadBalancer

创建服务消费者

  1. 创建 Spring Initializr
    勾选 Eureka Discovery、Ribbon
  2. 添加配置
    1
    2
    3
    eureka.client.service-url.defaultZone=http://localhost:8761/eureka/
    server.port=8764
    spring.application.name=service-ribbon
  3. 除了为 ServiceRibbonApplication 类添加 @EnableDiscoveryClient 外,还需要注册一个 RestTemplate 的 Bean:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    /**
    * 向Spring容器注入一个RestTemplate
    * 通过@LoadBalanced表明开启负载均衡功能
    * @return
    */
    @Bean
    @LoadBalanced
    RestTemplate restTemplate() {
    return new RestTemplate();
    }
  4. 创建服务类来消费service-name服务的”/hi”接口的服务,当该服务名有多个服务实例时会从中选择一个具体的服务实例:
    1
    2
    3
    4
    5
    6
    7
    8
    @Service
    public class HelloService {
    @Autowired
    RestTemplate restTemplate;
    public String hello(String name) {
    return restTemplate.getForObject("http://service-name/hi?name=" + name, String.class);
    }
    }
  5. 使用一个 Controller 来调用负载均衡服务
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    @RestController
    public class HelloController {

    @Autowired
    HelloService helloService;

    @RequestMapping("/hi")
    public String hi(@RequestParam String name) {
    return helloService.hello(name);
    }
    }
  6. 此时访问 service-ribbon 的”/hi”接口就会被转发到 service-name 的对应服务上去,并且会根据负载情况来轮流调用不同实例的端口。

Feign - 服务消费

与 Ribbon 区别

  1. Feign 整合了 Ribbon
  2. Feign 是基于接口的注解,Ribbon 是基于 RestTemplate 的

创建 Feign 服务

  1. 创建一个 Spring Initializr 项目
    选中 Eureka Discovery 和 Feign
  2. ServiceFeignApplication 类上添加 **@EnableDiscoveryClient
    ** 和 @EnableFeignClients 注解开启 Eureka 服务发现和 Feign 功能
  3. 定义一个 Feign 接口
    1
    2
    3
    4
    5
    @FeignClient("service-name")
    public interface ScheduleServiceHi {
    @RequestMapping(value = "/hi", method = RequestMethod.GET)
    String sayHiFromClientOne(@RequestParam("name") String name);
    }
    注解 @FeignClient 指定了服务名,具体的接口使用@RequestMapping指定。
  4. 创建一个 Controller,对外暴露一个”/hi”接口,它调用上面定义的 Feign 客户端的 ScheduleServiceHi 来消费服务
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    @RestController
    public class HiController {

    @Autowired
    private ScheduleServiceHi scheduleServiceHi;

    @RequestMapping(value = "/hi", method = RequestMethod.GET)
    public String sayHi(@RequestParam String name) {
    return scheduleServiceHi.sayHiFromClientOne(name);
    }
    }

Zuul

Hystrix - 断路器

Dashboard 和 Turbine:一个是单个节点的监控,后者可以从多个 Hystrix 服务器获取数据、整合到一个 Dashboard 界面上显示

应用

  1. 防止服务器雪崩
    雪崩:如果单个服务出现问题,调用这个服务就会出现线程阻塞,此时若有大量的请求涌入,Servlet容器的线程资源会被消耗完毕,导致服务瘫痪。服务与服务之间的依赖性,故障会传播,会对整个微服务系统造成灾难性的严重后果。
    断路就是及早阻止对这些不可用服务的请求。
  2. 断路原理
    当对特定的服务的调用的不可用达到一个阀值(Hystric 是 5 秒 20 次) 断路器将会被打开,然后???

为 Ribbon 工程添加 Hystrix 功能

  1. 创建 Spring Initializr 项目
    勾选 Hystrix、Eureka Discovery、Ribbon,或者在 Ribbon 项目中添加下面的依赖:
    1
    2
    3
    4
    <dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-hystrix</artifactId>
    </dependency>
  2. ServiceRibbonApplication 类添加 @EnableHystrix 注解开启Hystrix
  3. 为 Service 方法添加 @HystrixCommand 注解
    1
    2
    3
    4
    5
    6
    7
    8
    @HystrixCommand(fallbackMethod = "hiError")
    public String hello(String name) {
    return restTemplate.getForObject("http://service-name/hi?name=" + name, String.class);
    }

    public String hiError(String name) {
    return "hi, " + name + ", sorry, error!";
    }
  4. 接下来在启动所有服务后,关闭 service-name 服务(这是断路器依赖的服务)
    这样就会在访问 service-ribbon 服务时被断路,并调用 hiError()方法。

为 Feign 工程添加 Hystrix 功能

  1. 为 Feign 客户端添加 fallback 属性,该属性值是服务接口的实现类
    1
    @FeignClient(value = "service-name", fallback = ScheduleServiceHiHystrix.class)
  2. 创建接口的实现类
    1
    2
    3
    4
    5
    6
    7
    @Component
    public class ScheduleServiceHiHystrix implements ScheduleServiceHi {
    @Override
    public String sayHiFromClientOne(String name) {
    return "sorry " + name;
    }
    }
  3. 添加下面的配置项
    feign.hystrix.enabled=true
  4. 必须还得添加下面的依赖包
    1
    2
    3
    4
    5
    6
    7
    8
    9
    <dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-hystrix</artifactId>
    </dependency>
    <dependency>
    <groupId>com.netflix.hystrix</groupId>
    <artifactId>hystrix-javanica</artifactId>
    <version>1.5.12</version>
    </dependency>
  5. 测试接口时,可以先开着 service-name 服务关掉再开起来,在这个过程中访问 service-feign 服务,可以看到响应的变化

Hystrix Dashboard

  1. 开启仪表盘很简单,先添加依赖库:
    1
    2
    3
    4
    5
    6
    7
    8
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>
    <dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-hystrix-dashboard</artifactId>
    </dependency>
  2. 以 service-ribbon 服务为例,为 ServiceRibbonApplication 类添加 @EnableHystrixDashboard 注解
  3. 访问http://localhost:8764/hystrix 接口,进入监控界面
    HystrixDashboard

Hystrix Turbine

  1. 创建一个 Spring Initializr 工程
    勾选 Actuator、Turbine
  2. 为启动类添加注解 @EnableTurbine ,它其实包含了@EnableDiscoveryClient,所以 Turbine 默认是作为 Eureka 集群中的一员的
  3. 添加配置
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    spring.application.name=service-turbine
    server.port=8770
    security.basic.enabled=false
    # 指定聚合的集群,多个使用","分割,默认为default。可使用http://.../turbine.stream?cluster={clusterConfig之一}访问
    turbine.aggregator.cluster-config=default
    # 监控的Hystrix服务名列表
    turbine.app-config=service-ribbon
    # 1. clusterNameExpression指定集群名称,默认表达式appName;此时:turbine.aggregator.clusterConfig需要配置想要监控的应用名称
    # 2. 当clusterNameExpression: default时,turbine.aggregator.clusterConfig可以不写,因为默认就是default
    # 3. 当clusterNameExpression: metadata['cluster']时,假设想要监控的应用配置了eureka.instance.metadata-map.cluster: ABC,则需要配置,同时turbine.aggregator.clusterConfig: ABC
    turbine.cluster-name-expression=new String("default")
    eureka.client.service-url.defaultZone=http://localhost:8761/eureka/
  4. 进入 Dashboard 查看监控流
    比如http://localhost:8764/hystrix,输入监控流 http://localhost:8770/turbine.stream
    会发现监控的服务列表都呈现在了 Thread Pools 中

Sleuth - 服务链路追踪

概念

  1. Zipkin:Sleuth 集成了服务追踪组件 Zipkin,而 Sleuth 通过了通过 Spring Boot 来集成 Zipkin 到微服务系统的方案。
  2. Span:基本工作单元,比如发送一个 PRC 请求或 RPC 响应都是一个 Span,使用一个 64 位的 ID 表示(文档这里相当费解???),启动 Trace 的第一个 Span 称为 Root Span;
  3. Trace:一组 Span 可以构成一个树状结构;
  4. Annotation:用于标识事件,比如请求的开始、结束,有以下 Annotation 种类:
    • cs - Client Sent -客户端发起一个请求,这个 annotion 描述了这个 span 的开始
    • sr - Server Received -服务端获得请求并准备开始处理它,如果将其 sr 减去 cs 时间戳便可得到网络延迟
    • ss - Server Sent -注解表明请求处理的完成(当请求返回客户端),如果 ss 减去 sr 时间戳便可得到服务端需要的处理请求时间
    • cr - Client Received -表明 span 的结束,客户端成功接收到服务端的回复,如果 cr 减去 cs 时间戳便可得到客户端从服务端获取回复的所有所需时间

服务链路追踪

  1. 创建一个 Spring Initializr 项目
    一个 zipkin-server 勾选 Web、Zipkin Stream、Zipkin UI 和 Rabbit Stream,还有供跟踪的服务 zipkin-test1 和 zipkin-test2 勾选 Zipkin Client 和 Web,
  2. 加入注解
    在zipkin-server的Application上添加 @EnableZipkinServer
  3. 配置
    在 zipkin-server 中指定服务端口号即可:
    1
    server.port=9411
    在 zipkin-test1(zipkin-test2 类似)中需要指定 zipkin 服务器的地址
    1
    2
    3
    server.port=8988
    spring.zipkin.base-url=http://localhost:9411
    spring.application.name=service-hi
  4. 在两个 Service 之间使用 RestTemplate 进行互调
    第一个服务:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    private static final Logger LOG = Logger.getLogger(ZipkinTest1Application.class);
    @Autowired
    private RestTemplate restTemplate;
    @Bean
    public RestTemplate getRestTemplate() {
    return new RestTemplate();
    }

    @RequestMapping("/hi")
    public String callMiya(){
    LOG.log(Level.INFO, "calling trace service-hi ");
    return restTemplate.getForObject("http://localhost:8989/miya", String.class);
    }
    @RequestMapping("/info")
    public String info(){
    LOG.log(Level.INFO, "calling trace service-hi ");
    return "i'm service-hi";
    }
    另一个服务:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    private static final Logger LOG = Logger.getLogger(ZipkinTest2Application.class);
    @Autowired
    private RestTemplate restTemplate;
    @Bean
    public RestTemplate getRestTemplate() {
    return new RestTemplate();
    }

    @RequestMapping("/miya")
    public String miya() {
    LOG.log(Level.INFO, "info is being called");
    return restTemplate.getForObject("http://localhost:8988/info", String.class);
    }
  5. 为 Service 注入 Sampler 用于记录 Span
    1
    2
    3
    4
    @Bean
    public AlwaysSampler defaultSampler() {
    return new AlwaysSampler();
    }
    如果不注入 Sampler,会出现之后访问服务后,在 Zipkin 界面看不到服务依赖图的情况。
  6. 访问,并读取链路
    访问服务http://localhost:8988/hi
    现在访问http://localhost:9411/会出现 Zipkin 界面,可以查看服务间的相互依赖关系,和服务间调用的具体数据。

Docker部署

应用

  1. web 应用的自动化打包和发布;
  2. 自动化测试和持续集成、发布;
  3. 在服务型环境中部署和调整数据库或其他的后台应用;
  4. 从头编译或者扩展现有的 OpenShift 或 Cloud Foundry 平台来搭建自己的 PaaS 环境。

为 Spring Cloud 项目构建 Docker 镜像

  1. 添加依赖
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    <!--Spotify的docker镜像构建插件-->
    <plugin>
    <groupId>com.spotify</groupId>
    <artifactId>docker-maven-plugin</artifactId>
    <version>0.4.3</version>
    <configuration>
    <!--
    imageName:镜像名
    dockerDirectory:Dockerfile位置
    resources:指定需要和Dockerfile放在一起构建镜像的文件,包括jar包-->
    <imageName>${docker.image.prefix}/${project.artifactId}</imageName>
    <dockerDirectory>src/main/docker</dockerDirectory>
    <resources>
    <resource>
    <targetPath>/</targetPath>
    <directory>${project.build.directory}</directory>
    <include>${project.build.finalName}.jar</include>
    </resource>
    </resources>
    </configuration>
    </plugin>
  2. 配置
    这个是注册中心的配置:
    1
    2
    3
    4
    server.port=8761
    eureka.instance.prefer-ip-address=true
    eureka.client.register-with-eureka=false
    eureka.client.fetch-registry=false
    服务的配置比较简单(注意 defaultZone 改成了镜像名):
    1
    2
    3
    eureka.client.service-url.defaultZone=http://eureka-server:8761/eureka/ # 使用docker启动,defaultZone的host改为镜像名
    server.port=8762
    spring.application.name=service-name
  3. 在 src/main/docker 下创建 Dockerfile 文件
    注册中心:
    1
    2
    3
    4
    5
    6
    FROM frolvlad/alpine-oraclejdk8:slim # 指定image,可以是官方仓库或本地仓库
    VOLUME /tmp # 使容器中的一个目录具有持久化数据的功能
    ADD eureka-server-0.0.1-SNAPSHOT.jar app.jar # 从src目录复制文件到容器的dest
    # RUN bash -c 'touch /app.jar' # 容器启动时执行的命令,可以多次设置但只有最后一个有效
    ENTRYPOINT ["java", "-Djava.security.egd=file:/dev/./urandom", "-jar", "/app.jar"]
    EXPOSE 8761 # 暴露的端口号
    服务类似,只是要将 ADD 操作拷贝的镜像名和 EXPOSE 暴露的端口号改一改。
  4. 构建
    1
    2
    mvn clean
    mvn package docker:build
    这样运行可能会报错:没有权限,这时候可以切换到 root 用户运行,或将当前用户加入到 docker 组中。
  5. 运行
    注册中心:
    1
    2
    # --name指定容器名用于之后被其他容器连接,-p指定端口映射,第一个是主机端口,第二个是容器内的端口,这两个端口也可以实现为一个范围且这两个范围中的端口数必须相同,比如`-p 1234-1236:1222-1224`,还可以在端口前面指定监听的主机ip,-t指定分配一个虚拟终端,这样在该终端退出后这个服务还是在运行着的,最后的参数是docker镜像名
    docker run --name eureka-server -p 8761:8761 -t springboot/eureka-server
    服务:
    1
    2
    # --link连接到某个容器:端口上,它会在两个容器之间建立一个安全通道,且该容器的该端口必须是暴露的
    docker run --link eureka-server:8761 -p 8762:8762 -t springboot/eureka-client

使用 Docker-Compose 启动镜像

Compose 是一个用于定义和运行多容器的 Docker 应用的工具。使用 Compose,你可以在一个配置文件(yaml 格式)中配置你应用的服务,然后使用一个命令,即可创建并启动配置中引用的所有服务。

  1. 按上面的步骤构建 docker 镜像
  2. 在上面两个项目的父一级目录下创建配置文件 docker-compose.yml 配置文件
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    version: '2'
    services:
    eureka-server:
    image: springboot/eureka-server
    restart: always
    ports:
    - 8761:8761

    eureka-client:
    image: springboot/eureka-client
    restart: always
    ports:
    - 8762:8762
  3. 启动
    1
    docker-compose up

使用Docker-Compose构建并启动镜像

docker-compose 也可以用于构建镜像,这样就不用一个一个镜像构建再去运行了。

  1. 在 docker-compose.yml 同级目录下创建配置文件 docker-compose-dev.yml:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    version: '2'
    services:
    eureka-server:
    build: eureka-server
    ports:
    - 8761:8761

    eureka-client:
    build: eureka-client
    ports:
    - 8762:8762
  2. 构建并启动
    1
    docker-compose -f docker-compose.yml -f docker-compose-dev.yml up

参考

Eureka

  1. 服务发现:Eureka客户端

Ribbon

  1. Netflix/ribbon https://github.com/Netflix/ribbon/wiki
  2. 客户端负载平衡器:Ribbon https://springcloud.cc/spring-cloud-dalston.html#spring-cloud-ribbon

Feign

  1. 声明性 REST 客户端:Feign https://springcloud.cc/spring-cloud-dalston.html#spring-cloud-feign

Hystrix

  1. Netflix/Hystrix https://github.com/Netflix/Hystrix/wiki
  2. Circuit Breaker: Hystrix Clients https://springcloud.cc/spring-cloud-dalston.html#_circuit_breaker_hystrix_clients

Sleuth

  1. Zipkin https://zipkin.io/
  2. Spring Cloud Sleuth https://springcloud.cc/spring-cloud-dalston.html#_spring_cloud_sleuth
  3. Google Dapper-大规模分布式系统的基础跟踪设施 http://duanple.blog.163.com/blog/static/70971767201329113141336/
  4. http://research.google.com/pubs/pub36356.html
  5. http://bigbully.github.io/Dapper-translation/
  6. http://research.google.com/pubs/pub40378.html
  7. Eagleeye
  8. Sleuth 源码分析

Docker

  1. 用 Docker 构建、运行、发布一个 Spring Boot 应用
  2. docker-maven-plugin
  3. Docker Compose

开始使用 SpringBoot

使用 sdkman 搭建环境

介绍

  1. sdkman
    工具包管理器,可以用于管理 Spring Boot CLI。
  2. Spring Boot CLI
    命令行工具。
  3. groovy
    一种可以运行在 JVM 上的语言,和 Java 的编译器不同(语法、语义不同)。

安装

  1. 安装 sdkman
    按下面链接中的步骤来,可能需要连接 VPN:
    http://sdkman.io/install.html
  2. 安装 Spring Boot CLI
    默认安装最新版本的(安装位置在$HOME/.sdkman 下):
    1
    sdk install springboot
  3. 切换 Spring Boot CLI 版本
    1
    2
    3
    sdk list springboot
    sdk install springboot XXX.RELEASE
    sdk use springboot XXX.RELEASE
  4. 设置默认版本
    1
    sdk default springboot XXX.RELEASE
  5. 使用本地编译的 springboot
    1
    2
    3
    $ sdk install springboot dev /path/to/spring-boot/spring-boot-cli/target/spring-boot-cli-2.0.0.BUILD-SNAPSHOT-bin/spring-2.0.0.BUILD-SNAPSHOT/
    $ sdk default springboot dev
    $ spring --version

第一个 SpringBoot 应用

  1. 创建一个 app.groovy 文件:
    1
    2
    3
    4
    5
    6
    7
    @RestController
    class ThisWillActuallyRun {
    @RequestMapping("/")
    String home() {
    "Hello World!"
    }
    }
  2. 执行
    1
    spring run app.groovy
  3. 访问
    1
    localhost:8080

IDEA 下搭建 SpringBoot 项目

创建一个简单的 Web 项目

  1. 创建项目
    new Projects… -> Spring Initializr(这里可能需要连接 VPN) -> 设置项目元数据 -> 添加 Web 组件(对于这个例子来说足够了)
  2. 添加控制器
    创建 controller.HelloController:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    @RestController
    public class HelloController {

    @RequestMapping("/hello")
    public String hello() {
    System.out.println("Hello");
    return "hello";
    }
    }
  3. 执行 XxxApplication.main()
  4. 访问
    localhost:8080

添加 Jpa 支持

  1. 添加依赖
    1
    2
    3
    4
    5
    6
    7
    8
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    </dependency>
  2. 添加配置
    1
    2
    3
    4
    5
    6
    7
    spring.datasource.driver-class-name=com.mysql.jdbc.Driver
    spring.datasource.url=jdbc:mysql://localhost:3306/test
    spring.datasource.username=root
    spring.datasource.password=85382855

    spring.jpa.hibernate.ddl-auto=create
    spring.jpa.show-sql=true
  3. 创建实体类
    1
    2
    3
    4
    5
    6
    7
    8
    9
    @Entity
    public class User {
    @Id
    @GeneratedValue
    private Integer id;
    private String username;
    private String password;
    ...
    }
  4. 创建 Dao 接口
    1
    2
    public interface UserDao extends JpaRepository<User, Integer> {
    }
  5. 注入 Dao 接口
    1
    2
    @Autowired
    private UserDao userDao;

测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@RunWith(SpringRunner.class)
@SpringBootTest
public class HelloTest {

private MockMvc mockMvc;
@Before
public void setUp() throws Exception {
//测试 HelloWorldController
mockMvc = MockMvcBuilders.standaloneSetup(new HelloWorldController()).build();
}
@Test
public void getHello() throws Exception {
mockMvc.perform(MockMvcRequestBuilders.post("/hello?name=小明")
.accept(MediaType.APPLICATION_JSON_UTF8)).andDo(print());
}
}

SpringBoot 原理分析

核心思想

  1. 约定优于配置(convention over configuration)
    传统 Spring 项目配置繁琐是 SpringBoot 主要解决的痛点之一,autoconfigure 模块会在启动时为自动注入的 Bean 设置默认配置。
  2. 模块化
    类似于 maven 管理项目的依赖、git 管理代码的版本,SpringBoot 提供了对一些常用组件的模块化管理,只要添加依赖几乎就可以马上使用,展现出一种插件化的效果。

spring-boot-load 模块

正常情况下一个类加载器只能找到加载路径的 jar 包里当前目录或者文件类里面的 *.class 文件,SpringBoot 允许我们使用 java -jar archive.jar 运行包含嵌套依赖 jar 的 jar 或者 war 文件。

传统类加载器的局限

传统类加载器无法加载 jar 包中嵌套的 jar 包。比如项目打包后包含如下这些类和 jar 包,则 c.jar 中的类无法被加载:

1
2
3
4
5
6
a1.class
a2.class
b.jar
b1.class
b2.class
c.jar

为了能够加载嵌套 jar 里面的资源,之前的做法都是把嵌套 jar 里面的 class 文件和应用的 class 文件打包为一个 jar,这样就不存在嵌套 jar 了,但是这样做就不能很清晰的知道哪些是应用自己的,哪些是应用依赖的,另外多个嵌套 jar 里面的 class 文件可能内容不一样但是文件名却一样时候又会引发新的问题。

加载嵌套 jar 包中的 class 文件

在 Java 中,AppClassLoader 和 ExtClassLoader 都继承自 URLClassLoader,并通过构造函数来传递需要加载的 class 文件所在的目录。
那么只要将 jar 包中嵌套的 jar 包所在的路径作为 URLClassLoader 的扫描路径,就可以实现对嵌套 jar 包的扫描了。
但是默认情况下 Java 使用 AppClassLoader 加载使用命令启动时指定的 classpath 下的类和 jar 包,那么如何使用自定义的 URLClassLoader 来加载这些类和 jar 包呢?具体做法是加一个中间层。
原来是直接使用 AppClassLoader 来加载应用:AppClassLoader -> Application。
现在先加载一个自定义的启动类 Launcher,其中自定义 URLClassLoader,再使用该 URLClassLoader 来加载我们真正的 main 函数:AppClassLoader -> Launcher -> URLClassLoader -> Application。

spring-boot-load 提供的启动器 Launcher

spring-boot-load 模块允许我们加载嵌套 jar 包中的 class 文件,它包含三种类启动器:

  1. JarLauncher
  2. WarLauncher
  3. PropertiesLauncher

spring-boot-maven-plugin 打包插件及 SpringBoot 指定的包结构

为了为 SpringBoot 应用打包,需要引入如下 Maven 插件:

1
2
3
4
5
6
7
8
9
10
11
12
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>1.5.9.RELEASE</version>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>

SpringBoot项目打包后的jar包结构如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
archive.jar
|
+-META-INF(1)
| +-MANIFEST.MF
+-org(2)
| +-springframework
| +-boot
| +-loader
| +-<spring boot loader classes>
+-com(3)
| +-mycompany
| + project
| +-YouClasses.class
+-lib(4)
+-dependency1.jar
+-dependency2.jar

目录(1)是 jar 文件中 MANIFEST.MF 文件的存放处。
目录(2)是 Spring-boot-loader 本身需要的 class 放置处。
目录(3)是应用本身的文件资源放置处。
目录(4)是应用依赖的 jar 固定放置处,即 lib 目录。

打包按如下顺序进行:

  1. 执行mvn clean package按原流程进行打包生成 jar 文件;
  2. spring-boot-maven-plugin 插件对 jar 包进行重写。
    1. 改写 MANIFEST.MF 文件:Main-Class: org.springframework.boot.loader.JarLauncherStart-Class: com.abc.MyApplication,这里的 MyApplication 即项目中被@SpringBootApplication注解了的那个类,指定主类后 AppClassLoader 就会先加载 JarLauncher 并执行其中的 main 方法了;
    2. 写入应用依赖的 jar 包到 lib 目录;
    3. 拷贝 spring-boot-load 包里的 class 文件到 jar 包的目录(2)处。

      为什么要拷贝到目录(2)的位置?因为后面启动流程中LaunchedURLClassLoader位于spring-boot-loader.jar包内,AppClassLoader无法加载嵌套的jar中的文件。

启动顺序如下,以 JarLauncher 为例,假设 SpringBoot 项目打包后的 jar 包名为 test.jar:
JarLauncher执行时序图

  1. 使用 AppClassLoader 加载 JarLauncher,执行其 main 函数;
  2. 查找 test.jar 中的嵌套 jar 后生成一个列表List<Archive>,每个Archive包含了一个嵌套 jar 的信息,对应 jar 包 Archive 的子类是 JarFileArchive
  3. 将 test.jar 本身构造成的 Archive 插入List<Archive>列表的第一个位置;
  4. 将这个列表作为参数构建LaunchedURLClassLoader类加载器,这个类加载器继承自 URLClassLoader;
  5. 使用 LaunchedURLClassLoader 加载并实例化 MyApplication 的一个对象,并通过反射调用 main 函数,这时候 SpringBoot 才正式开始初始化 SpringBoot 的环境,并创建容器。

spring-boot-autoconfigure 模块

Spring 的出现给我们管理 Bean 的依赖注入提供了便捷,但是当我们需要使用通过 pom 引入的 jar 里面的一个 Bean 时候,还是需要手动在 XML 配置文件里面配置。
spring-boot-autoconfigure 思路类似 **SPI(Service Provider Interface)**,都是不同的实现类实现了定义的接口,加载时候去查找 classpath 下的实现类,不同在于前者使用 autoconfigure 实现,后者使用的是 ServiceLoader
autoconfigure 模块的主要功能如下:

  1. 可以依据 classpath 里面的依赖内容自动配置 Bean 到 Spring 容器,并为默认化属性配置。
  2. 尝试推断哪些 Beans 是用户可能会需要的。比如如果 HSQLDB 包在当前 classpath 下,并且用户并没有配置其他数据库链接,这时候 Auto-configuration 功能会自动注入一个基于内存的数据库连接到应用的 IOC 容器。再比如当我们在 pom 引入了 Tomcat 的 start 后,如果当前还没 Web 容器被注入到应用 IOC,那么 SpringBoot 就会为我们自动创建并启动一个内嵌 Tomcat 容器来服务。

EnableAutoConfiguration

核心注解@EnableAutoConfiguration的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@AutoConfigurationPackage
@Import({AutoConfigurationImportSelector.class})
public @interface EnableAutoConfiguration {
String ENABLED_OVERRIDE_PROPERTY = "spring.boot.enableautoconfiguration";

Class<?>[] exclude() default {};

String[] excludeName() default {};
}

EnableAutoConfiguration 注解主要作用是通过 @Import 注入 EnableAutoConfigurationImportSelector 实例,后者是实现 AutoConfiguration 功能的核心类。
EnableAutoConfigurationImportSelector时序图
EnableAutoConfigurationImportSelector 类实现了 DeferredImportSelector 接口的 String[] selectImports(AnnotationMetadata metadata) 方法,当 Spring 框架解析 @Import 注解时候会调用该方法。
代码EnableAutoConfigurationImportSelector#getCandidateConfigurationsloadFactoryNames的作用是扫描 classpath 下含有 Meta-INF/spring.factories 文件的 jar,并解析文件中名字为 org.springframework.boot.autoconfigure.EnableAutoConfiguration 的配置项。
getCandidateConfigurations会返回名字为 org.springframework.boot.autoconfigure.EnableAutoConfiguration 的配置项的值的一个列表并且对列表内容进行去重处理。

正常情况下,去重后的列表里面的类都会被自动注入到 Spring IOC 容器,除非指定不自动注入某些功能,比如若要排除自动创建数据源和事务管理的功能可以:@EnableAutoConfiguration(exclude={DataSourceAutoConfiguration.class, DataSourceTransactionManagerAutoConfiguration.class}),getExclusions 就是读取 EnableAutoConfiguration 注解里面的 excludeName 和 exclude 配置项,然后从去重后的列名列表里面剔除。这样在自动注入时候就不会对排除掉的自动配置功能进行注入了。

这个列表中的每个元素就是一个自动配置功能,比如 org.springframework.boot.autoconfigure.web.servlet.ServletWebServerFactoryAutoConfiguration 是 Web 容器自动注入的功能类(比如这个类可以自动扫描 Tomcat 的 start 并创建一个 Tomcat 容器)。

例子 - 注入 Web 容器

ServletWebServerFactoryAutoConfiguration 的部分核心代码如下:

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
@Configuration
@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE)
@ConditionalOnClass(ServletRequest.class)
@ConditionalOnWebApplication(type = Type.SERVLET)
@EnableConfigurationProperties(ServerProperties.class)
@Import({ ServletWebServerFactoryAutoConfiguration.BeanPostProcessorsRegistrar.class,
ServletWebServerFactoryConfiguration.EmbeddedTomcat.class,
ServletWebServerFactoryConfiguration.EmbeddedJetty.class,
ServletWebServerFactoryConfiguration.EmbeddedUndertow.class })
public class ServletWebServerFactoryAutoConfiguration {

@Bean
public ServletWebServerFactoryCustomizer servletWebServerFactoryCustomizer(ServerProperties serverProperties) {
return new ServletWebServerFactoryCustomizer(serverProperties);
}

@Bean
@ConditionalOnClass(name = "org.apache.catalina.startup.Tomcat")
public TomcatServletWebServerFactoryCustomizer tomcatServletWebServerFactoryCustomizer(
ServerProperties serverProperties) {
return new TomcatServletWebServerFactoryCustomizer(serverProperties);
}

/**
* Registers a {@link WebServerFactoryCustomizerBeanPostProcessor}. Registered via
* {@link ImportBeanDefinitionRegistrar} for early registration.
*/
public static class BeanPostProcessorsRegistrar implements ImportBeanDefinitionRegistrar, BeanFactoryAware {

private ConfigurableListableBeanFactory beanFactory;

@Override
public void setBeanFactory(BeanFactory beanFactory) throws BeansException {
if (beanFactory instanceof ConfigurableListableBeanFactory) {
this.beanFactory = (ConfigurableListableBeanFactory) beanFactory;
}
}

@Override
public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata,
BeanDefinitionRegistry registry) {
if (this.beanFactory == null) {
return;
}
registerSyntheticBeanIfMissing(registry, "webServerFactoryCustomizerBeanPostProcessor",
WebServerFactoryCustomizerBeanPostProcessor.class);
registerSyntheticBeanIfMissing(registry, "errorPageRegistrarBeanPostProcessor",
ErrorPageRegistrarBeanPostProcessor.class);
}

private void registerSyntheticBeanIfMissing(BeanDefinitionRegistry registry, String name, Class<?> beanClass) {
if (ObjectUtils.isEmpty(this.beanFactory.getBeanNamesForType(beanClass, true, false))) {
RootBeanDefinition beanDefinition = new RootBeanDefinition(beanClass);
beanDefinition.setSynthetic(true);
registry.registerBeanDefinition(name, beanDefinition);
}
}

}

}

ServletWebServerFactoryAutoConfiguration 类是 Web 容器自动注入的 Auto-configuration 类。

  • @ConditionalOnWebApplication 说明当前是 Web 环境上下文时候才注入本类到 IOC。
  • @Import 注解导入了另一个配置ServletWebServerFactoryConfiguration.EmbeddedTomcat.class,这里包含了真正的注入 Tomcat 实例的逻辑:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    @Configuration
    class ServletWebServerFactoryConfiguration {

    @Configuration
    @ConditionalOnClass({ Servlet.class, Tomcat.class, UpgradeProtocol.class })
    @ConditionalOnMissingBean(value = ServletWebServerFactory.class, search = SearchStrategy.CURRENT)
    public static class EmbeddedTomcat {

    @Bean
    public TomcatServletWebServerFactory tomcatServletWebServerFactory() {
    return new TomcatServletWebServerFactory();
    }

    }
    ...
  • @ConditionalOnClass:只有在 classpath 下存在指定的类的时候 Bean 才能被创建。
  • @ConditionalOnMissingBean:只有对应的 Bean 在系统中都没有被创建,它修饰的初始化代码块才会执行,【用户自己手动创建的 Bean 优先】。

当应用引入 spring-boot-starter-web 时候默认引入的是 Tomcat 的 starter,所以会发现 classpath 下存在 Servlet.class、 Tomcat.class 这两个类,并且 IOC 里面没有 TomcatEmbeddedServletContainerFactory 的实例,因此会创建其实例到 IOC 容器中,最终会创建一个 Tomcat 容器。
当然,如果需要使用 Jetty 则需要在引用 spring-boot-starter-web 的时候排除掉 Tomcat 的 start,然后在引入 Jetty 的 start 即可。

总结

总而言之,自动配置的生效步骤可以概括如下:

  1. Spring Boot 在启动时会去依赖的 Starter 包中寻找 resources/META-INF/spring.factories 文件,然后根据文件中配置的 Jar 包去扫描项目所依赖的 Jar 包。
  2. 根据 spring.factories 配置加载 AutoConfigure 类
  3. 根据 @Conditional 注解的条件,进行自动配置并将 Bean 注入 Spring Context

spring-boot 模块

提供了一些特性用来支持 SpringBoot 中其它模块,这些特性包括:

  1. SpringApplication 类提供了静态方法以便于写一个独立了 Spring 应用程序,该类的主要职责是 create 和 refresh 一个合适的 Spring 应用程序上下文(ApplicationContext)。
  2. 一流的外部配置的支持(application.properties)。
  3. 提供了便捷的应用程序上下文(ApplicationContext)的初始化器,以便在 ApplicationContext 使用前对其进行用户定制。
  4. 给 Web 应用提供了一个可选的 Web 容器(目前有 Tomcat 或 Jetty)。

SpringApplication 的执行流程

一般的 SpringBoot 项目都会有一个如下所示的 Application 类:

1
2
3
4
5
6
7
@SpringBootApplication
public class Web1Application {

public static void main(String[] args) {
SpringApplication.run(Web1Application.class, args);
}
}

JarLauncher启动过后里面实际上就是调用了 SpringApplication#run 方法,SpringApplication 的执行流程如下:
SpringApplication执行时序图

  1. SpringApplication 的构造函数里面会调用 initialize 方法在 classpath 的 jar 包里面查找 META-INF/spring.factories,如果找到则看里面是否有配置 ApplicationContextInitializer 类型的 Bean,有则加载到 SpringApplication 的变量 initializers 里面存放,比如 spring-boot.jar 里面。
    ApplicationContextInitializer配置
  2. createAndRefreshContext 做了这几件事情:
    1. 设置环境,加载 application.properties 等配置文件;
    2. 根据 classpath 的 jar 里面是否有 ConfigurableWebEnvironment 判断当前是否需要创建 Web 应用程序上下文还是创建一个非 Web 应用程序上下文;
    3. 使用前面加载的应用程序初始化器对创建的应用程序上下文进行初始化;
    4. 刷新应用程序上下文解析 Bean 定义到应用程序上下文里面的 IOC 容器,在刷新过程的 invokeBeanFactoryPostProcessors 过程中(10)(11)会去解析类上面标注的 @import 注解,然后就会调用所有的 ImportSelector 的 selectImports 方法,这也是第二部分时序图开始执行的地方。

Web 容器的创建

通过自动配置将 TomcatEmbeddedServletContainerFactory 或者 JettyEmbeddedServletContainerFactory 的实例注入 IOC 容器后,我们就可以使用 Web 容器来提供 Web 服务了。
具体的 Web 容器的创建是在容器刷新过程的 onRefresh 阶段进行的(这个阶段是在刷新过程的 invokeBeanFactoryPostProcessors 阶段的后面):
Web容器创建过程

  • getBeanNamesForType 获取了 IOC 容器中的 EmbeddedServletContainerFactory 类型的 Bean 的 name 集合,如果 name 集合为空或者多个则抛出异常。还记得 Web 容器工厂是通过自动配置注入到 IOC 的吧,并且 TomcatEmbeddedServletContainerFactory 或者 JettyEmbeddedServletContainerFactory 都是实现了 EmbeddedServletContainerFactory 接口。
  • 如果 IOC 里面只有一个 Web 容器工厂 Bean 则获取该 Bean 的实例,然后调用该 Bean 的 getEmbeddedServletContainer 获取 Web 容器,这里假设 Web 容器工厂为 Tomcat ,则创建 Tomcat 容器并进行初始化。

Actuator 模块

Actuator类图
Actuator 模块提供了一些组件用于检查应用或中间件的健康状况:

  1. Inspect:检测应用或中间件的健康状况;
  2. Aggregate:聚合所有结果。

基于 Actuator 模块,我们可以开发一个服务探活系统:

  1. Actuator 为业务服务器暴露出一个探活端口healthcheck,借助 Spring 容器获取到中间件的客户端实例;
  2. 探活服务HealthCheck-Task定时轮询所有服务,统计单位时间内请求出现问题的次数,从而发送报警短信提醒研发同学。

服务的轮询探活将是这个系统的主要瓶颈,有一些措施可以作为参考:

  1. 灵活使用CompletableFuture这些并发编排组件,因为探活请求一般都是比较”慢”的,因此可以通过并行化来提高效率;
  2. 探活不能占用太多系统资源,合理设置探活超时时间,很多客户端的默认超时时间达到 1 分钟甚至更长,必须额外设置探活请求的超时时间,避免服务器挂掉时所有线程都被hang死,导致服务雪崩。

    探活不能和业务代码共用线程池,做到线程池隔离,即使出问题也只影响到HealthCheck-Task

参考

原理

  1. SpringBoot应用篇(一):自定义starter
  2. 从零开始开发一个Spring Boot Starter
  3. SpringBoot启动流程解析

源码分析

  1. fangjian0423/springboot-analysis

降级有几种方案

降级分类

降级主要分手动降级和自动降级,手动降级其实就是在代码里加上一些开关把功能直接关闭,自动降级可以分为以下几种:

  1. 超时降级
    配置好超时时间和超时重试次数和机制,并使用异步机制探测恢复情况。
  2. 失败次数降级
    失败次数达到阈值自动降级,同样要使用异步机制探测回复情况。
  3. 故障降级
    如要调用的远程服务挂掉了(网络故障、DNS 故障、HTTP 服务返回错误的状态码和 RPC 服务抛出异常),则可以直接降级。
    和上面那种失败次数降级原理类似,只是需要区分错误类型。
  4. 限流降级
    单位时间内调用次数超过阈值,可以使用暂时屏蔽的方式来进行短暂的屏蔽。

降级处理方案

降级后,在代码层面,可以进行的处理策略如下:

  1. 抛异常
  2. 返回 NULL
  3. 调用 Mock 数据
  4. 调用 Fallback 处理逻辑

Hystrix 原理

Hystrix工作流程

4 种调用方法

  • toObservable()
    最基础的 API,直接返回 Observable,未作订阅,需要由用户来发起订阅;
  • observe()
    调用 #toObservable() 方法,并向 Observable 注册 rx.subjects.ReplaySubject 发起订阅,这会立刻触发 run()方法的执行。
  • queue()
    调用 #toObservable() 方法的基础上,调用:Observable#toBlocking() 和 BlockingObservable#toFuture() 返回 Future 对象,实现了异步调用
  • execute()
    调用 #queue() 方法的基础上,调用 Future#get() 方法,同步返回 #run() 的执行结果。

HystrixCommand.execute
-> HystrixCommand.queue
-> AbstractCommand.toObservable

AbstractCommand.observe
-> AbstractCommand.toObservable

隔离

限制调用分布式服务的资源使用,某一个调用的服务出现问题不会影响其他服务调用,包括线程池隔离信号量隔离
开启配置:
HystrixCommand(…, Setter.andCommandPropertiesDefaults(
HystrixCommandProperties.Setter().withExecutionIsolationStrategy(ExecutionIsolationStrategy.SEMAPHORE)), …)
-> withExecutionIsolationStrategy(ExecutionIsolationStrategy.SEMAPHORE) 配置隔离策略,线程池同理

实施信号量隔离:
AbstractCommand.toObservable
…中略
-> AbstractCommand.applyHystrixSemantics
-> TryableSemaphore.tryAcquire 如果没开启信号量模式,这个方法永远返回 true

实施线程隔离:
AbstractCommand.toObservable
…中略
-> AbstractCommand.applyHystrixSemantics
-> AbstractCommand.executeCommandAndObserve
-> AbstractCommand.executeCommandWithSpecifiedIsolation 创建一个新线程来执行
-> AbstractCommand.getUserExecutionObservable
-> HystrixCommand.getExecutionObservable

限流

Hystrix的限流限制的是并发量,其实和隔离特性的实现方式一样,可以通过调整信号量或线程池大小来实现限流。

熔断

熔断是一种异常处理机制,如果接口的失败率达到了阈值就触发降级,
当失败率达到阈值自动触发降级(如因网络故障/超时造成的失败率高),之后熔断器会尝试进行恢复。
Hystrix 会为降级的接口维持一个熔断状态,状态扭转如下图所示:
熔断状态扭转

  1. 熔断关闭状态(Closed)
    服务没有故障时,熔断器所处的状态,对调用方的调用不做任何限制。
  2. 熔断开启状态(Open)
    在固定时间窗口内(Hystrix 默认是 10 秒),接口调用出错比率达到一个阈值(Hystrix 默认为 50%),会进入熔断开启状态。进入熔断状态后,后续对该服务接口的调用不再经过网络,直接执行本地的 fallback 方法。
  3. 半熔断状态(Half-Open)
    在进入熔断开启状态一段时间之后(Hystrix 默认是 5 秒),熔断器会进入半熔断状态。所谓半熔断就是尝试恢复服务调用,允许有限的流量调用该服务,并监控调用成功率。如果成功率达到预期,则说明服务已恢复,进入熔断关闭状态;如果成功率仍旧很低,则重新进入熔断关闭状态。

Hystrix 使用 HystrixCircuitBreaker 这个类来维持状态:
AbstractCommand.applyHystrixSemantics
-> HystrixCircuitBreaker.attemptExecution 是否已开启熔断(OPEN),如果超过时间窗口则切换到半熔断状态(HALF_OPEN)
AbstractCommand.executeCommandAndObserve
-> HystrixCircuitBreaker.markSuccess / markNonSuccess

降级(Fallback)

降级是一种状态,当一个接口被降级后,再对这个接口进行调用会直接走降级逻辑。可以触发降级的条件一般是调用超时(比如网络故障、超时)或资源不足(线程或信号量)。但是代码中会更复杂一些,具体地说,触发降级的条件包括:

  1. run()方法抛出非 HystrixBadRequestException 异常
    AbstractCommand.executeCommandAndObserve
    -> handleFallback 是一个回调接口匿名实现类的对象,会在出现异常时被调用
    HystrixBadRequestException 表示一类可以被忽略的异常,在 AbstractHystrixCommand 和 GenericObservableCommand 可以抛出,可以在 HystrixCommand 注解上的 ignoreExceptions 中声明这些可以被忽略的异常。
  2. run()方法调用超时
    对超时的异常检测和上边的一样,但是抛出的位置不一样。
    AbstractCommand.executeCommandAndObserve
    -> Observable.lift 添加超时操作 HystrixObservableTimeoutOperator
    -> HystrixTimer.addTimerListener 添加一个 TimerListener 用于判断执行时间是否达到阈值,若超过则抛出 HystrixTimeoutException
    -> ScheduledThreadPoolExecutor.scheduleAtFixedRate 定时触发滴答(tick)
    可以看到超时检测最终还是通过 Scheduled 线程池来实现的。
  3. 熔断器开启拦截调用
    AbstractCommand.applyHystrixSemantics
    -> HystrixCircuitBreaker.attemptExecution
  4. 线程池/队列/信号量是否跑满
    在《隔离》那一节中已经说明了代码的位置。
  5. 没有实现 getFallback 的 Command 将直接抛出异常,fallback 降级逻辑调用成功直接返回,降级逻辑调用失败抛出异常
    HystrixCommand.getFallback 直接抛出了一个 UnsupportedOperationException 异常。

缓存

提供了请求缓存、请求合并实现。支持实时监控、报警、控制(修改配置)
结果缓存这个功能我觉得应该由幂等框架来做。

参考

  1. 漫画:什么是服务熔断?
  2. Hystrix 使用入门手册(中文)
  3. 从源码看 hystrix 的工作原理
  4. hystrix 适用场景
  5. Github - star2478/java-hystrix
  6. Netflix / Hystrix

Java 代码编译机制

JVM 规范中定义了 class 文件的格式,但并未定义 Java 源码如何被编译为 class 文件,各厂商在实现 JDK 时通常会将符合 Java 语言规范的源码编译为 class 文件的编译器,例如在 Sun JDK 中就是 javac,javac 将 Java 源码编译为 class 文件的步骤如下图所示:
Java代码编译机制

分析和输入到符号表(Parse and Enter)

Parse 过程所做的为此法和语法分析。此法分析(com.sun.tools.javac.parser.Scanner)要完成的是将代码字符串转变为 token 序列(例如 Token.EQ(name:=));语法分析(com.sun.tools.javac.parser.Parser)要完成的是根据语法由 token 序列生成抽象语法树。
Enter(com.sun.tools.javac.comp.Enter)过程为将符号输入到符号表,通常包括确定类的超类型和接口、根据需要添加默认构造器、将类中出现的符号输入类自身的符号表中等。

注解处理(Annotation Processing)

该步骤主要用于处理用户自定义的 annotation,可能带来的好处是基于 annotation 来生成附加的代码或进行一些特殊的检查,从而节省一些共用的代码的编写,例如当采用 Lombok 时,可编写如下代码:

1
2
3
public class User {
private @Getter String username;
}

编译时引入 Lombok 对 User.java 进行编译后,再通过 javap 查看 class 文件可看到自动生成了 public String getUsername()方法。
此功能基于 JSR 269,在 Sun JDK 6 中提供了支持,在 Annotation Processing 进行后,再次进入 Parse and Enter 步骤。

语义分析和生成 class 文件(Analyse and Generate)

Analyse 步骤基于抽象语法树进行一系列的语义分析,包括:

  • 将语法树中的名字、表达式等元素与变量、方法、类型等联系到一起;
  • 检查变量使用前是否已声明;
  • 推导泛型方法的类型参数;
  • 检查类型匹配性;
  • 进行常量折叠;
  • 检查所有语句都可到达;
  • 检查所有 checked exception 都被捕获或抛出;
  • 检查变量的确定性赋值(例如有返回值的方法必须确定有返回值);
  • 检查变量的确定性不重复赋值(例如声明为 final 的变量等);
  • 解除语法糖(消除 if(false) { … }形式的无用代码;将泛型 Java 转为普通 Java;将含有语法糖的语法树改为含有简单语言结构的语法树,例如 foreach 循环、自动装箱/拆箱等);
  • 等…

在完成了语义分析后,开始生成 class 文件(com.sun.tools.javac.jvm.Gen),生成的步骤为:

  • 首先将实例成员初始化器收集到构造器中,将静态成员初始化器收集为();
  • 接着将抽象语法树生成字节码,采用的方法为后序遍历语法树,并进行最后的少量代码转换(例如 String 相加转变为 StringBuilder 操作);
  • 最后从符号表生成 class 文件。

参考

  1. 深入浅出 JIT 编译器

搭建源码调试环境

源码下载地址:unofficial-openjdk/openjdk

  1. OpenJDK9 Hotspot Ubuntu 编译和调试
    mac 环境下搭建:mac 下编译 openjdk1.9 及集成 clion 动态调试

遇到的问题

  1. g++和 gcc 版本不匹配
  2. 一些 Warning 被当作错误报错了
    大部分 Warning 不会影响最终结果,可以关掉:https://blog.csdn.net/desiyonan/article/details/80802066

阅读

  1. 如何找 JDK 中 native 代码的位置?
  2. OpenJDK 源码阅读导航

编译 openjdk

  1. 下载
    代码的下载见 building 文档,使用一个 Mercurial 工具进行源码控制。
  2. 准备工具
    找到这种开放源码的软件,第一步当然是查看 README 了,openjdk 的 README 中告知了 building 文档的位置,相当详细。
    硬件要求
    操作系统要求
    编译器要求,需要 gcc 和 clang,他们的区别见Clang 比 GCC 好在哪里?
    JDK 要求,虽然挺矛盾的,但是一部分模块是用 JDK 写的,所以 Java 编译器也是需要的,一般编译 JDK8 时需要的 JDK 版本是 7 就够了,但是 openjdk 需要 8 才行(不知道为什么),这个 jdk 可以直接使用 apt-get 安装。
    外部库要求,
    其他工具,make、bash、autoconf
  3. configure
    准备所有配置文件
  4. make
    编译源码,默认下只会编译一些必要的部分,如果编译成功可以在 openjdk/build 文件夹下找到目标文件,我的成品所在目录是${openjdk}/build/linux-x86_64-normal-server-release/jdk/bin
  5. run test
    openjdk 使用了一个叫 jtreg 的测试框架,文档有这里还有这里,我设置了一个愚蠢的环境变量 JAVA18HOME 然后尝试着去make -C make编译它,但是还是提示缺少什么org.testing,可能还有什么依赖库没装,这个框架可能是给 oracle 的内部员工或者外星人用的吧?

定位 Object 的 native 方法

  1. 搜索“Object”打开 jdk 下的 Object.c 文件
    看到下面这个方法数组:
    1
    2
    3
    4
    5
    6
    7
    static JNINativeMethod methods[] = {
    {"hashCode", "()I", (void *)&JVM_IHashCode},
    {"wait", "(J)V", (void *)&JVM_MonitorWait},
    {"notify", "()V", (void *)&JVM_MonitorNotify},
    {"notifyAll", "()V", (void *)&JVM_MonitorNotifyAll},
    {"clone", "()Ljava/lang/Object;", (void *)&JVM_Clone},
    };
  2. 然后搜索“JVM_MonitorWait”打开 hotspot 下的 jvm.cpp 文件
    里面有下面这条语句:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    JVM_ENTRY(void, JVM_MonitorWait(JNIEnv* env, jobject handle, jlong ms))
    JVMWrapper("JVM_MonitorWait");
    Handle obj(THREAD, JNIHandles::resolve_non_null(handle));
    JavaThreadInObjectWaitState jtiows(thread, ms != 0);
    if (JvmtiExport::should_post_monitor_wait()) {
    JvmtiExport::post_monitor_wait((JavaThread *)THREAD, (oop)obj(), ms);

    // The current thread already owns the monitor and it has not yet
    // been added to the wait queue so the current thread cannot be
    // made the successor. This means that the JVMTI_EVENT_MONITOR_WAIT
    // event handler cannot accidentally consume an unpark() meant for
    // the ParkEvent associated with this ObjectMonitor.
    }
    ObjectSynchronizer::wait(obj, ms, CHECK);
    JVM_END
    我们暂时不知道这几个宏到底什么意思,不过核心大概是最后的那个ObjectSynchronizer::wait吧,这个类定义在 C++文件 synchronizer.cpp 中。
  3. 综上所述
    可以看到如果一个类定义了 native 方法,那么基本可以在 jdk 目录下找到该类的定义(类所在包结构和其在 openjdk 中的定义的路径有什么关联???),至于其实现,从知乎上可以找到一段说明,我不知道对错:

    如果间接调用了 hotspot 的实现(jvm 会以动态库的形式被加载,prims 文件夹里定义了 hotspot 与其他模块的接口及其实现),那么从 hotspot/src/share/vm/prims/jvm.cpp 中可以找到 JVM_Xxx 函数的实现。

为 Object 类添加 native 方法 nop()

根据这里的说法,openjdk 有两种方式来定义 native 函数,一种是按一种特别的方式命名函数,这样加载后就可以和 Java 中的 native 方法对上,另一种是使用 registerNatives()的方式,观察 Object.c 文件可以发现,这种方法先定义了一个 JNINativeMethod 类型的静态数组来做 Java 方法和本地函数的对应,然后使用 JNI 方法注册了一个叫 Java_java_lang_Object_registerNatives 的本地方法来实施这种映射。
当使用第一种方式在 Object.c 中定义一个特殊命名的 nop()函数时,编译不通过,报错显示Incompatible definition of java.lang.Object,我定义的 nop 函数:

1
2
3
4
JNIEXPORT void JNICALL
Java_java_lang_Object_nop(JNIEnv *env, jobject this)
{
}

然后下面是我使用第二种方式定义 nop()函数的过程(虽然也不行):

  1. 修改 Object.c
    methods 数组中添加一行:

    1
    "nop", "()V", (void*)&

    这种定义方法参照了这里

  2. 修改 Object.java
    其所在目录为${openjdk}/jdk/src/java.base/share/classes/java/lang/Object.java,为其添加一个 nop()方法:

    1
    2
    3
    4
    5
    /**
    *
    */
    @HotSpotIntrinsicCandidate
    public native void nop();

    为什么要加注释?为什么不能加 final?

  3. 重编译 make
    虽然只是添加了一个方法,但是几乎所有包下的都要重新编译了,所以编译过程会稍微有点长。

  4. 测试
    使用javap命令反编译可以看到 Object 类中确实已经有了一个 nop()方法:

    1
    javap ${openjdk}/build/linux-x86_64-normal-server-release/jdk/modules/java.base/java/lang/Object.class

    但是实际编译时却报错了,错误大概是Object类不兼容???

  5. native 方法的实现机制
    参见 JNI 原理。

JNI

生成.h头文件

使用 javah 命令,在项目的 src 目录下运行,其中-d 指定目标目录,-jni 是默认选项,生成 jni 头文件,可以加-classpath 指定 class 路径,但必须是绝对路径

1
javah -d ../jni -jni com.tallate.HelloJNI

接下来编译.c 文件,-c 可以生成目标文件(未链接),-I 可以指定#include 查找的目录,不然 jni 有些头文件找不到

1
gcc -c -I "/usr/lib/jvm/java-8-openjdk-amd64/include" -I "/usr/lib/jvm/java-8-openjdk-amd64/include/linux"  HelloJNI.c 

接下来编译成一个.so 链接库

1
gcc HelloJNI.c -I "/usr/lib/jvm/java-8-openjdk-amd64/include" -I "/usr/lib/jvm/java-8-openjdk-amd64/include/linux" -fPIC -shared -o libhello.so

接下来可以在Java中加载

1
2
// 在linux下动态库必须有前缀lib
System.loadLibrary("hello");

调用 native 方法的流程

  1. native 方法编译后多一个 ACC_NATIVE 标志
  2. loadLibrary 方法调用 JVM 的 load 本地方法
  3. load 方法最终调用系统调用 dlopen 加载动态链接库
  4. 调用本地方法时,实际上调用了 JVM 中加载的函数,栈帧压入本地方法栈中(HotSpot 中本地方法栈=虚拟机栈)

修改 Object 类,为其添加一个 native 方法 nop()

锁(内置锁)

  1. 锁的存储结构?
  2. 获取锁的时机?
  3. 释放锁的时机?

线程

和 JDK 的线程操作相关的类有 Object 和 Thread,Object 中包括 wait、notify 和 notifyAll,Thread 中包括 yield、sleep、exit、interrupt、join、start,run 的主要任务是执行用户定义的逻辑,还有一些方法已经被 Deprecated,不必再提

源码中和线程操作相关的一些数据结构

  1. ObjectMonitor
    对象监视器,定义和声明在 objectMonitor.*中,主要包含

  2. ObjectWaiter

  3. ObjectSynchronizer

wait

wait 是 native 方法,实现在 synchronizer.cpp 中,ObjectSynchronizer::wait:

  1. 通过ObjectSynchronizer::inflate方法找到 object 中的ObjectMonitor monitor

  2. 调用 monitor.wait
    wait 方法比较复杂,但是核心过程只有 3 步:

    • ObjectMonitor::AddWaiter(..)将新建立的 ObjectWaiter 对象插入_WaitSet 队列的末尾
    • ObjectMonitor::exit(..)释放锁
    • Self->_ParkEvent->park等待

参考

JNI

  1. 介绍
  2. 生成.so 方法
  3. 修改 java.library.path 方法
  4. Java JNI 实现原理初探
  5. Java Native Interface (JNI) 工作原理
  6. dlopen

线程

  1. java sleep 和 wait 的区别的疑惑?
  2. java 中的 wait 和 notify 实现的源码分析
  3. JVM Thread stop 的源码分析

JVM结构
JVM 特指用于运行由 Java 虚拟机规范规定的字节码的运行时系统,它具有以下特征:

  • 平台无关性
    JVM 本身是平台相关的,只是对不同平台都有实现,所以我们编译成的字节码才可以在各个平台上执行,因此称 Java 平台无关指的是字节码是平台无关的。
  • 高度抽象
    JVM 提供了编译器、类加载、动态内存管理、线程等一套工具,作为虚拟机,程序经编译后运行在 JVM 上时,几乎完全不必担心平台的兼容性。
    本地方法接口只是作为一个扩展功能提供,并不是必须的,实际上除了对性能严格要求的场合,本地方法一般不会被使用。

    编译器严格来说和虚拟机并无太大关联,JVM 可以供 Groovy、Kotlin、Scala 等一系列语言作为运行时环境,

下图表示 JVM 的系统结构,包括与 Class 文件打交道的类装载系统、进行运行时数据管理的系统、进行方法执行的系统、本地方法接口(JNI)和本地方法库这些部分。

类装载子系统

类装载子系统负责查找并装载类型,类装载器主要包含两种:启动类装载器(Java 虚拟机实现的一部分)用户自定义类装载器(Java 程序的一部分)

启动类装载器 – Java 虚拟机必须有一个启动类装载器,用于装载受信任的类,如 Java API 的 class 文件。
用户自定义类装载器 – 继承自 ClassLoader 类,ClassLoader 的如下四个方法,是通往 Java 虚拟机的通道。

  • protected final Class defineClass(String name, byte data[], int offset, int length); // data 为二进制的 Java Class 文件数据,表示一个新的可用类型,之后把这个类型导入到方法区中
  • protected final Class defineClass(String name, byte data[], int offset, int length, ProtectionDomain protectionDomain);
  • protected final Class findSystemClass(String name); // 参数为全限定名,通过类装载器进行装载
  • protected final void resolveClass(Class c); // 参数为 Class 实例,完成连接初始化操作

类装载子系统负责定位和导入二进制 class 文件,并且保证导入类的正确性,为类变量分配并初始化内存,以及帮助解析符号引用。类装载器必须严格按照如下顺序进行类的装载。

  1. 装载 – 查找并装载类型的二进制数据
  2. 连接 – 执行验证,准备,以及解析(可选),连接分为如下三个步骤
    验证 – 确保被导入类型的正确性
    准备 – 为类变量(static)分配内存,并将其初始化为默认值
    解析 – 把类型中的符号引用转换为直接引用
  3. 初始化 – 把类变量初始化为正确初始值

内存管理系统

方法区

方法区是线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,当方法区无法满足内存分配需求时,将抛出 OutOfMemoryError。
类信息包括:

  1. 类型全限定名。
  2. 类型的直接超类的全限定名(除非这个类型是 java.lang.Object,它没有超类)。
  3. 类型是类类型还是接口类型。
  4. 类型的访问修饰符(public、abstract、final 的某个子集)。
  5. 任何直接超接口的全限定名的有序列表。
  6. 类型的常量池。
  7. 字段信息。
  8. 方法信息。
  9. 除了常量以外的所有类(静态)变量。
  10. 一个到类 ClassLoader 的引用。
  11. 一个到 Class 类的引用。

着重介绍常量池,虚拟机必须要为每个被装载的类型维护一个常量池。常量池就是该类型所用常量的一个有序集合,包括直接常量和对其他类型、字段和方法的符号引用。它在 Java 程序的动态连接(运行时解析字节码中使用到的符号引用)中起着核心作用。
除了以上结构外,JVM 的实现者还可以添加一些其他的数据结构,如方法表:对每个加载的非抽象类的类信息中都添加一个方法表,方法表是一组对类实例方法的直接引用——包括从父类继承的方法,JVM 可以通过方法表快速找到实例方法。

C++中 virtual 函数是通过虚函数表实现的,Java 中所有方法都是 virtual 的,因此也就不需要再标注虚拟了,正像 Java 虽然宣称没有指针,但是 Java 里其实全是指针。Java 中类似的检查机制不少,这体现了 Java 的一种设计思想:始终将安全放在效率之上。

元空间

JDK1.8 的一大改动是方法区变成了元空间,其实就是放开了类空间的大小,容量取决于是 32 位或是 64 位操作系统的可用虚拟内存大小,32 位系统本地最多有 4G 虚拟内存空间,物理内存取决于操作系统的配置。新参数MaxMetaspaceSize用于限制本地内存分配给类元数据的大小,如果没有指定这个参数,元空间会在运行时根据需要动态调整,大小随意。对于没用的类及类加载器的垃圾回收将在元数据使用达到 MaxMetaspaceSize 参数的设定值时进行。

一个虚拟机实例只对应一个堆空间,堆是线程共享的,当然还有可能会划分出多个线程私有的分配缓冲区(TLAB)。堆空间是存放对象实例的地方,几乎所有对象实例都在这里分配。堆也是垃圾收集器管理的主要区域,因此也被称为“GC 堆”。
堆可以处于物理上不连续的内存空间中,只要逻辑上连续即可,就像磁盘空间一样。
当堆中没有足够的内存进行对象实例分配时,并且堆也无法扩展时,会抛出 OutOfMemoryError 异常。
对 OOM 的排查通常是先通过内存映像分析工具对 Dump 出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏还是内存溢出

  • 如果是内存泄漏,可进一步通过工具查看泄露对象到 GC Roots 的引用链。于是就能找到泄露对象的类型信息及 GC Roots 引用链的信息,就可以比较准确地定位出泄露代码的位置。
  • 如果不存在泄露,就是内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-Xmx 与 -Xms),与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。

程序计数器

每个线程拥有自己的程序计数器,线程之间的程序计数器互不影响,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。
PC 寄存器的内容总是下一条将被执行指令的”地址”,这里的”地址”可以是一个本地指针,也可以是在方法字节码中相对于该方法起始指令的偏移量。

  • 如果该线程正在执行一个本地方法,则程序计数器内容为 undefined。
  • 此区域在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。

虚拟机栈

我们一般会把 Java 内存区分为堆内存和栈内存,而所指的“栈”就是这里的虚拟机栈,或者说是虚拟机栈中局部变量表部分。
Java 栈也是线程私有的,虚拟机只会对栈进行两种操作,以帧为单位的入栈出栈。每个方法在执行时都会创建一个栈帧,并入栈,成为当前帧,每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。栈帧由三部分组成:局部变量区、操作数栈、帧数据区。
局部变量区被组织为一个以字长为单位、从 0 开始计数的数组。字节码指令通过从 0 开始的索引来使用其中的数据。类型为 int、float、reference 和 returnAddress 的值在数组中只占据一项,而类型为 byte、short 和 char 的值存入时都会转化为 int 类型,也占一项,而 long、double 则连续占据两项。
操作数栈也常被称为操作栈,它是一个后入先出栈。当一个方法刚刚执行的时候,这个方法的操作数栈是空的,在方法执行的过程中,会有各种字节码指令向操作数栈中写入和提取值,也就是入栈与出栈操作。在使用操作数栈时需要注意实例方法和类方法:

1
2
3
4
5
6
7
8
9
10
public class Example {
// 类方法
public static int classMethod(int i, long l, float f, double d, Object o, byte b) {
return 0;
}
// 实例方法
public int instanceMethod(char c, double d, short s, boolean b) {
return 0;
}
}

类方法和实例方法栈帧结构有所不同,从图中可以看到它们之间的区别:

  • 类方法帧里没有隐含的 this 引用,而实例方法帧中会隐含一个 this 引用;

并且注意:

  • byte,char,short,boolean 类型存入局部变量区的时候都会被转化成 int 类型值,当被存回堆或者方法区时,才会转化回原来的类型;
  • returnAddress 类型数据是指向字节码的指针(方法区的字节码指令),它只存在于字节码层面,与编程语言无关,我们在 Java 语言中是不会直接与 returnAddress 类型数据打交道的。

    每个线程所持有的程序计数器(pc)实际上就是 returnAddress 类型的数据,当线程执行 native 方法时,pc 中的值为 undefied。

  • 操作数栈被组织成一个以字长为单位的数组,它是通过标准的栈操作——即入栈和出栈来进行访问,而不是通过索引访问。入栈和出栈也会存在类型的转化;
  • 当一个方法执行完毕之后,要返回之前调用它的地方,因此在栈帧中必须保存一个方法返回地址。方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用都栈帧的操作数栈中,调用 PC 计数器的值以指向方法调用指令后面的一条指令等。
  • 每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。在 Class 文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为静态解析。另外一部分将在每一次的运行期期间转化为直接引用,这部分称为动态连接。

栈数据区存放一些用于支持常量池解析、正常方法返回以及异常派发机制的信息。即将常量池的符号引用转化为直接地址引用、恢复发起调用的方法的帧进行正常返回,发生异常时转交异常表进行处理。

  • 虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧中,例如与高度相关的信息,这部分信息完全取决于具体的虚拟机实现。在实际开发中,一般会把动态连接,方法返回地址与其它附加信息全部归为一类,称为栈帧信息

在 Java 虚拟机规范中,规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常;如果虚拟机栈可以动态扩展,当扩展时无法申请到足够的内存,就会抛出 OutOfMemoryError 异常。

系统分配给每个进程的内存是有限制的,除去 Java 堆、方法区、程序计数器,如果虚拟机进程本身耗费的内存不计算在内,剩下内存就由虚拟机栈和本地方法栈“瓜分”了。每个线程分配到的栈容量越大,可以建立的线程数量自然就越少,建立线程时就越容易把剩下的内存耗尽,出现虚拟机栈溢出的情况。

  • 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出 StackOverflowError 异常,出现 StackOverflowError 异常时有错误栈可以阅读,栈深度在大多数情况下达到 1000~2000 完全没有问题,对于正常的方法调用(包括递归),这个深度在一半情况下完全够用。
  • 如果虚拟机在扩展栈(建立更多的线程)时无法申请到足够的内存空间,则抛出 OutOfMemoryError 异常,在不能减少线程数或者更换 64 位虚拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程。

一般可以认为 Java 中的一切变量都是引用,实际的对象存储在堆中,但是基础类型比较特殊,它们可以直接使用操作符进行算数运算,不过基础类型也带来了一些问题,比如装箱拆箱过程中可能产生空指针异常。Java 中引入基础类型的动机应该是性能与空间利用率,毕竟使用引用时还得通过指针碰撞空闲列表方法(根据垃圾收集器不同而不同)来找到实际对象,而且基础类型本身占用的空间不多,如果用引用势必占用的空间会加倍。
基础类型并不是都分配在栈上,具体有以下几种情况:

  1. 在方法中声明的变量,即该变量是局部变量,每当程序调用方法时,系统都会为该方法建立一个方法栈,其所在方法中声明的变量就放在方法栈中,当方法结束系统会释放方法栈,其对应在该方法中声明的变量随着栈的销毁而结束,这就局部变量只能在方法中有效的原因
    在方法中声明的变量可以是基本类型的变量,也可以是引用类型的变量。
    • 当声明是基本类型的变量的时,其变量名及值(变量名及值是两个概念)是放在方法栈中
    • 当声明的是引用变量时,所声明的变量(该变量实际上是在方法中存储的是内存地址值)是放在方法的栈中,该变量所指向的对象是放在堆类存中的。
  2. 在类中声明的变量是成员变量,也叫全局变量,放在堆中的(因为全局变量不会随着某个方法执行结束而销毁)。
    同样在类中声明的变量即可是基本类型的变量 也可是引用类型的变量
    • 当声明的是基本类型的变量其变量名及值是放在堆中的
    • 引用类型时,其声明的变量仍然会存储一个内存地址值,该内存地址值指向所引用的对象。引用变量名和对应的对象仍然存储在相应的堆中

堆外内存

一个例子

1
2
3
4
5
// 分配一块1024字节的堆外内存
ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
System.out.println(buffer.capacity());
buffer.putInt(0, 2018);
System.out.println(buffer.getInt(0));

为什么要使用堆外内存

主要是因为堆外内存在 IO 操作方面的优势,举一个例子:在通信中,将存在于堆内存中的数据 flush 到远程时,需要首先将堆内存中的数据拷贝到堆外内存中,然后再写入 Socket 中;如果直接将数据存到堆外内存中就可以避免上述拷贝操作,提升性能。类似的例子还有读写文件。
但是需要注意的是,直接访问堆外内存并不会比访问 Java 堆更快:哪个更快:Java 堆还是本地内存。堆外内存更适合用于操作大块的数据(>2G)、可以直接使用操作系统提供的本地 IO 进行操作。

可用的堆外内存额度

  1. 可以通过设定 -XX:MaxDirectMemorySize 来显式指定最大的堆外内存。
  2. 设定 -Dsun.nio.MaxDirectMemorySize 来显式指定:如果该属性为-1,则取directMemory = Runtime.getRuntime().maxMemory(),即 JVM 运行时的最大内存;否则,如果指定这个属性等于-1,则默认为 64M。
    如果是 JDK8,具体代码见VM.saveAndRemoveProperties(),如果是 JDK8 之前,则代码见VM.maxDirectMemory()
    另外,Runtime.getRuntime().maxMemory()是一个 native 方法,HotSpot 中对应的 C++代码如下所示:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    JNIEXPORT jlong JNICALL
    Java_java_lang_Runtime_maxMemory(JNIEnv *env, jobject this)
    {
    return JVM_MaxMemory();
    }

    JVM_ENTRY_NO_ENV(jlong, JVM_MaxMemory(void))
    JVMWrapper("JVM_MaxMemory");
    size_t n = Universe::heap()->max_capacity();
    return convert_size_t_to_jlong(n);
    JVM_END
    其中 max_capacity() 实际返回的是 –Xmx 设置值减去一个 survivor space 的预留区大小,与堆内大小存很接近。

源码

  1. 分配
    ByteBuffer.allocateDirect
    DirectByteBuffer(int)
    -> Bits.reserveMemory 在系统中保存总分配内存(按页分配)的大小和实际内存的大小
    -> Bits.tryReserveMemory 判断系统内存(堆外内存)是否足够,Bits 有一个全局 totalCapacity 变量记录当前已经使用的堆外内存量
    -> JavaLangRefAccess.tryHandlePendingReference 非阻塞,将已经被 JVM 垃圾回收的 DirectBuffer 对象的堆外内存释放。
    -> System.gc 触发一次 FullGC,System.gc并不能保证 FullGC 被马上执行,如果设置了-XX:+DisableExplicitGC则这种显式的 GC 会被禁用
    -> Bits.tryReserveMemory 为了等待 FullGC 释放足够的空间,之后还会重试最多 9 次 tryReserveMemory,每次重试会 sleep 1, 2, 4, 8, 16, 32, 64, 128, 256ms,也就是说最多可以等 0.5s
    -> throw new OutOfMemoryError 分配空间失败,抛出 OOM 异常
    -> Unsafe.allocateMemory native 方法,通过 JNI 调用 C++函数分配堆外内存,并返回内存基地址
    -> Unsafe.setMemory(base, size, (byte) 0) 将分配的内存清零
    -> Cleaner.create 创建一个 Cleaner 用于释放堆空间
  2. 释放
    Cleaner.create Cleaner 内部维护了一个 static 的 Cleaner 对象的链表,create 调用创建了一个 Cleaner 对象并将其加入到该链表中
    -> new Cleaner(DirectByteBuffer, new Deallocator(base, size, cap))

Cleaner 实现 GC 回收堆外内存的原理是PhantomReference(虚引用),虚引用必须和引用队列(ReferenceQueue)一起使用,一般用于实现追踪垃圾收集器的回收动作。虚引用不会影响 JVM 是否要 GC 这个对象的判断,当 GC 某个对象时,如果有此对象上还有虚引用,会将虚引用对象插入 ReferenceQueue 队列。
对于 Cleaner 对象,当 GC 时发现它除了虚引用外已不可达(持有它的 DirectByteBuffer 对象在 GC 中被回收了,此时,只有 Cleaner 对象唯一保存了堆外内存的数据),就会把它放进 Reference 类的 pending 静态变量里。
PhantomReference 的父类是 Reference,Reference 类内部的 static 块会启动 ReferenceHandler 线程,线程优先级很高,这个线程是用来处理 JVM 在 GC 过程中交接过来的 reference(pending)。
下面是该线程的大致执行流程:
ReferenceHandler.run() 死循环处理 JVM 提交的 reference,如果是 Clearner 则调用其 clean 方法
-> Cleaner.clean
-> Cleaner.remove(Cleaner) 首先将 Cleaner 从链表中移除,以便 Cleaner 自身可被 GC 回收掉
-> DirectByteBuffer.Deallocator.run()
-> Unsafe.freeMemory 释放分配的堆外内存
-> Bits.unreserveMemory 更新 Bits.totalCapacity

手动释放堆外内存

如前面所述,可以通过编码调用 DirectByteBuffer 的 cleaner 的 clean 方法来释放堆外内存。但需要注意:cleaner 是 private 访问权限,所以,需使用反射来实现。

MappedByteBuffer

MappedByteBuffer 是用来访问大文件的,其实是利用操作系统的 mapedfile 的机制,把一个大文件映射到物理内存,然后用户进程像访问内存一样访问文件,背后操作系统会把磁盘文件映射到内存中来,这是操作系统预估你想存取哪块提前为你准备好的。

本地方法栈

访问本地方式时使用到的栈,为本地方法服务,与虚拟机栈一样,本地方法区域也会抛出 StackOverflowError 和 OutOfMemoryError 异常。

方法执行引擎

用户所编写的程序如何表现正确的行为需要执行引擎的支持,执行引擎执行字节码指令,完成程序的功能。

本地方法接口(JNI)

本地方法接口称为 JNI,是为可移植性准备的。

参考

  1. 《Hotspot 实战》
  2. GC 性能优化
  3. JVM 菜鸟进阶高手之路
  4. The Java® Virtual Machine Specification
  5. The Java® Language Specification
  6. Java 和操作系统交互细节

了解 JVM 中内存管理的原理后,下一步就是如何调优了。

JVM参数

-D、-X、-XX 区别

其一是标准参数(-),所有的 JVM 实现都必须实现这些参数的功能,而且向后兼容;(包括-D 等)JVM 的标准参数都是以”-“开头,通过输入”java -help”或者”java -?”,可以查看 JVM 标准参数列表
其二是非标准参数(-X),默认 jvm 实现这些参数的功能,但是并不保证所有 jvm 实现都满足,且不保证向后兼容;但是列出来的都是当前可用的。
其三是非 Stable 参数(-XX),此类参数各个 jvm 实现会有所不同,将来可能会随时取消,需要慎重使用;

栈大小

  • Xss512k:用来设置每个线程的堆栈大小;

堆空间结构调整

堆空间结构调整

  • Xmx4g:JVM 最大允许分配的堆内存,按需分配;
  • Xms4g:JVM 初始分配的堆内存,一般和 Xmx 配置成一样以避免每次 gc 后 JVM 重新分配内存;
  • XX:MetaspaceSize=64m 初始化元空间大小;
  • XX:MaxMetaspaceSize=128m 最大化元空间大小。

Metaspace 建议不要设置,一般让 JVM 自己启动的时候动态扩容就好了,没必要自己去设置。如果不动态加载 class ,当启动起来的时候,一般是很少有变化的。
从这个角度我们可以认为我们的 JVM 内存的大小是堆+Metaspace+io(运行时产生的大小)。

  • -Xms[g|m|k]
  • -Xmx[g|m|k] 堆大小
  • -Xmn[g|m|k] 年轻代大小
  • -XX:NewRatio=老年代/新生代 比例
  • -XX:MaxMetaspaceSize=[unit] 元空间
  • -XX:NewSize=[unit] 初始年轻代大小
  • -XX:SurvivorRatio= # 代表分代回收算法新生代中 Eden:Survivor 的比例,注意 Survivor 是有两块的,如果 Eden:Survivor = 3,则新生代将被分割为 5 份,Eden 占 3 份

堆的最大和最小限制,网上很多资料都将二者设置成一样的值,我觉得这是不好的习惯,因为 JVM 只有在内存使用量达到-Xms 的值时才会开始 gc,设置成一样的值也就是说只有在 JVM 使用完内存后才会开始 gc,这会导致最大暂停时间偏长,用户体验不好,当然设置成一样也可以减轻堆伸缩带来的压力。当然这些都是直观的看法,根据 [3] 的说法,这个调整策略和 JVM 中使用的垃圾回收算法相关,如果是 IBM JVM(采用 sweep-compact),设置不一样较好;如果是 Sun JVM(采用分代回收),设置成一样较好。

选择哪个 GC 收集器

  • -XX:+UseSerialGC
  • -XX:+UseParallelGC
  • -XX:+USeParNewGC
  • -XX:+UseG1GC
  • -XX:-UseConcMarkSweepGC
    对老生代采用并发标记交换算法进行 GC

GC 的时候生成文件

  • -XX:+UseGCLogFileRotation 用文件循环策略
  • -XX:NumberOfGCLogFiles=10 最大文件数
  • -XX:GCLogFileSize=50M 文件大小
  • -Xloggc:/home/user/log/gc.log 位置

OOM后排错

  • -XX:+HeapDumpOnOutOfMemoryError 打出 dump 文件当发生 out of memory
  • -XX:HeapDumpPath=./java_pid.hprof 文件名
  • -XX:OnOutOfMemoryError=”< cmd args >;< cmd args >” 发生异常的时候执行的命令
  • -XX:+UseGCOverheadLimit 在 GC 之前

Java Management Extensions

Java 管理扩展,打开 JMX 端口,就可以用标准的端口来监控 server 端的 jvm 了。
-Djava.rmi.server.hostname=
-Dcom.sun.management.jmxremote.port=
-Dcom.sun.management.jmxremote.authenticate=false
-Dcom.sun.management.jmxremote.ssl=false

-XX:+PrintFlagsFinal
-XX:+PrintFlagsInitial

生产环境参数设置

  1. 实际生产中很少去设置 gc 相关的详细参数,一般只要把 thread dump 处理好(及异常的时候生成 demp 文件)和 jmx 端口打开;
  2. 能设置好几个分代的内存空间就不错了。这个可以通过 jvm 的监控来设置根据 cpu 和 gc 的情况;
  3. 因为随着 JVM 的版本的升级,jvm 垃圾回收会也来越智能,但是我们必须要了解这些,因为面试的时候大牛为了显摆自己会问这些问题。
  4. 根据我们上面介绍的就算你设置+XX 有的时候也不一定有用,说不定哪个小版本里面就失灵了。jdk1.8 关于 gc 的最多开启+XX:+UseConcMarkSweepGC。

调优工具

对 JVM 的监控可以分为以下几个方面:

  • 内存状况分析(GC)
  • 线程状态分析

关于 GC 的监控,比较重要的有三个方面:

  • 各个区的容量,主要是堆中新生代与老年代的内存分配。
  • Full GC、Young GC 发生的次数,原则上尽量避免发生 Full GC,Young GC 能少则少。
  • 当前系统的内存比、CPU 使用率。

jps

jps 列出正在运行的虚拟机进程
包括 PID、主类、jar 包等

1
jps -ml

jinfo

Java 配置信息工具,并且支持运行时动态修改部分参数
查看某些配置值或开关是否打开:

1
2
jinfo -flag MaxTenuringThreshold 8737
jinfo -flag PrintGCDetails 8737

打开开关

1
2
jinfo -flag MaxTenuringThreshold=10 8737
jinfo -flag +PrintGCDetails 8737

查看虚拟机的默认配置参数还可以在运行时打开虚拟机的 PrintFlagsFinal 开关

1
java -XX:+PrintFlagsFinal Test

jstat

常用选项

1
jstat -gcutil # 垃圾收集统计数据

统计数据列含义:

数据列 描述 支持的 jstat 选项
S0C Survivor0 的当前容量 -gc -gccapacity -gcnew -gcnewcapacity
S1C S1 的当前容量 -gc -gccapacity -gcnew -gcnewcapacity
S0U S0的使用量 -gc-gcnew
S1U S1 的使用量 -gc-gcnew
EC Eden 区的当前容量 -gc -gccapacity -gcnew -gcnewcapacity
EU Eden 区的使用量 -gc -gcnew
OC old 区的当前容量 -gc -gccapacity -gcnew -gcnewcapacity
OU old区的使用量 -gc-gcnew
PC 方法区的当前容量 -gc-gccapacity -gcold -gcoldcapacity -gcpermcapacity
PU 方法区的使用量 -gc -gcold
YGC Young GC 次数 -gc -gccapacity -gcnew -gcnewcapacity -gcold -gcoldcapacity -gcpermcapacity -gcutil -gccause
YGCT Young GC 累积耗时 -gc -gcnew -gcutil -gccause
FGC Full GC次数 -gc -gccapacity -gcnew -gcnewcapacity -gcold -gcoldcapacity -gcpermcapacity -gcutil -gccause
FGCT Full GC 累积耗时 -gc-gcold -gcoldcapacity -gcpermcapacity -gcutil -gccause
GCT GC 总的累积耗时 -gc -gcold -gcoldcapacity -gccapacity -gcpermcapacity -gcutil -gccause
NGCMN 新生代最小容量 -gccapacity -gcnewcapacity
NGCMX 新生代最大容量 -gccapacity -gcnewcapacity
NGC 新生代当前容量 -gccapacity -gcnewcapacity
OGCMN 老年代最小容量 -gccapacity -gcoldcapacity
OGCMX 老年代最大容量 -gccapacity -gcoldcapacity
OGC 老年代当前容量 -gccapacity -gcoldcapacity
PGCMN 方法区最小容量 -gccapacity -gcpermcapacity
PGCMX 方法区最大容量 -gccapacity -gcpermcapacity
PGC 方法区当前容量 -gccapacity -gcpermcapacity
PC 方法区的当前容量 -gccapacity -gcpermcapacity
PU 方法区使用量 -gccapacity -gcold
LGCC 上一次 GC 发生的原因 -gccause
GCC 当前 GC 发生的原因 -gccause
TT 存活阀值,如果对象在新生代移动次数超过此阀值,则会被移到老年代 -gcnew
MTT 最大存活阀值,如果对象在新生代移动次数超过此阀值,则会被移到老年代 -gcnew
DSS survivor 区的理想容量 -gcnew

jmap

1
2
jmap -histo pid -F
jmap -dump:format=b, file=heap.bin pid

JConsole

JVisualVM

GC日志

关于输出 GC 日志的参数有以下几种:

  • -XX:+PrintGC 输出 GC 日志
  • -XX:+PrintGCDetails 输出 GC 的详细日志
  • -XX:+PrintGCTimeStamps 输出 GC 的时间戳(以基准时间的形式)
  • -XX:+PrintGCDateStamps 输出 GC 的时间戳(以日期的形式,如 2013-05-04T21:53:59.234+0800)
  • -XX:+PrintHeapAtGC 在进行 GC 的前后打印出堆的信息
  • -Xloggc:../logs/gc.log 日志文件的输出路径

比如对 PrintGCDetails 这个参数:

1
2
3
4
5
6
7
8
9
public class GCLogTest {
public static void main(String[] args) {
int _1m = 1024 * 1024;
byte[] data = new byte[_1m];
// 将data置null让其可被回收
data = null;
System.gc();
}
}

在 IDE 中设置 VM 参数-XX:+PrintGCDetails,再运行,可以得到:

1
2
3
4
5
6
7
8
9
10
11
12
[GC (System.gc()) [PSYoungGen: 5051K->776K(38400K)] 5051K->784K(125952K), 0.0014035 secs] [Times: user=0.01 sys=0.00, real=0.00 secs] 
[Full GC (System.gc()) Disconnected from the target VM, address: '127.0.0.1:55472', transport: 'socket'
[PSYoungGen: 776K->0K(38400K)] [ParOldGen: 8K->684K(87552K)] 784K->684K(125952K), [Metaspace: 2980K->2980K(1056768K)], 0.0040080 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]
Heap
PSYoungGen total 38400K, used 333K [0x0000000795580000, 0x0000000798000000, 0x00000007c0000000)
eden space 33280K, 1% used [0x0000000795580000,0x00000007955d34a8,0x0000000797600000)
from space 5120K, 0% used [0x0000000797600000,0x0000000797600000,0x0000000797b00000)
to space 5120K, 0% used [0x0000000797b00000,0x0000000797b00000,0x0000000798000000)
ParOldGen total 87552K, used 684K [0x0000000740000000, 0x0000000745580000, 0x0000000795580000)
object space 87552K, 0% used [0x0000000740000000,0x00000007400ab0d0,0x0000000745580000)
Metaspace used 2988K, capacity 4568K, committed 4864K, reserved 1056768K
class space used 318K, capacity 392K, committed 512K, reserved 1048576K

第一行是 YoungGC,其结构如下图所示:
JVM-YoungGC日志
第二行是 FullGC,其结构如下图所示:
JVM-FullGC日志

  • GC日志开头的[GC[Full GC说明了这次垃圾收集的停顿类型,如果有Full,说明这次 GC 发生了Stop-The-World。因为是调用了System.gc()方法触发的收集,所以会显示[Full GC (System.gc()),不然是没有后面的(System.gc())的。
  • [PSYoungGen[ParOldGen是指 GC 发生的区域。
  • 在方括号中PSYoungGen:后面的5051K->776K(38400K)代表的是GC前该内存区域已使用的容量->GC后该内存区域已使用的容量(该内存区域总容量)
  • 在方括号之外的5051K->784K(125952K)代表的是GC前Java堆已使用容量->GC后Java堆已使用容量(Java堆总容量),注意已使用容量是减掉一个 Servivor 区、线程栈等区域后的大小。
  • 再往后的0.0014035 secs代表该内存区域 GC 所占用的时间,单位是秒。
  • 再后面的[Times: user=0.01 sys=0.00, real=0.00 secs],user 代表进程在用户态消耗的 CPU 时间,sys 代表进程在内核态消耗的 CPU 时间、real 代表程序从开始到结束所用的时钟时间。这个时间包括其他进程使用的时间片和进程阻塞的时间(比如等待 I/O 完成)。
  • 至于后面的eden代表的是 Eden 空间,还有fromto代表的是 Survivor 空间。

问题排查

OOM

有两种情况可能导致 OOM:

  1. 内存泄露(Memory Leak),某些对象是需要被释放的但是却由于某些原因释放不了,查看泄露对象对 GC Roots 的引用链,再追本溯源进行分析;
  2. 内存溢出(Memory Overflow),对象太多了导致堆放不下了,查看堆是否可以调大,或者某些对象活太久了。

产生 OutOfMemoryError 错误的具体原因有以下几种:

  • java.lang.OutOfMemoryError: Java heap space 表示 Java 堆空间不足。当应用程序申请更多的内存时,若 Java 堆内存已经无法满足应用程序的需要,则将抛出这种异常。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    /*
    VM Args: -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
    */
    public class HeapOOM {
    static class OOMObject {}

    public static void main(String[] args) {
    List<OOMObject> list = new ArrayList<>();
    while(true) {
    list.add(new OOMObject());
    }
    }
    }
  • java.lang.OutOfMemoryError: PermGen space,表示 Java 永久代(方法区)的空间不足。永久代用于存放类的字节码和常量池,类的字节码被加载后存放在这个区域,这和存放对象实例的堆区是不同的。大多数 JVM 的实现都不会对永久代进行垃圾回收,因此,只要类加载过多就会出现这个问题。一般的应用程序都不会产生这个错误,然而,对于 Web 服务器会产生大量的 JSP,JSP 在运行时被动态地编译为 Java Servlet 类,然后加载到方法区,因此,有很多 JSP 的 Web 工程可能会产生这个异常。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    /*
    VM Args: -XX:PermSize=10M -XX:MaxPermSize=10M
    */
    public class RuntimeConstantPoolOOM {
    public static void main(String[] args) {
    // 使用List保持对常量池的引用,避免Full GC回收常量池
    List<String> list = new ArrayList<String>();
    for(int i = 0;; i++) {
    list.add(String.valueOf(i).intern());
    }
    }
    }
    使用 intern 测试运行时常量池是“永久代”的还是“元空间”的:
    1
    2
    3
    4
    String str1 = new StringBuilder("计算机").append("软件").toString();
    System.out.println(str1.intern() == str1);
    String str2 = new StringBuilder("ja").append("va").toString();
    System.out.println(str2.intern() == str2);
    方法区溢出测试(使用 CGLib 动态生成类):
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    /*
    VM Args: -XX:PermSize=10M -XX:MaxPermSize=10M
    */
    public class MethodAreaOOM {
    static class OOMObject {}
    public static void main(String[] args) {
    while(true) {
    Enhancer enhancer = new Enhancer();
    enhancer.setSuperclass(HeapOOM.OOMObject.class);
    enhancer.setUseCache(false);
    enhancer.setCallback(new MethodInterceptor() {
    @Override
    public Object intercept(Object o, Method method,
    Object[] objects, MethodProxy proxy) throws Throwable {
    return proxy.invokeSuper(o, args);
    }
    });
    enhancer.create();
    }
    }
    }
    jdk1.6 及之前版本,因为 HotSpot 实行“永久代”(PermGen),方法区保存到永久代中,虽然逻辑上属于堆,但是在这块空间上并没有实行 GC。在这种情况下,上面代码发生堆溢出是必然的。
    jdk1.7 及之后版本,因为“去永久代”,引入了“元空间”(Metaspace),这种策略使得大部分类元数据都保存在本地内存中,元空间使用一个全局空闲组块列表(一个大数组)表示,每创建一个类加载器都会从这个列表中获取一个自己的组块,用处当然是存储元信息(指针碰撞方式分配),当一个类加载器不再活动后,其所持有的组块列表也就返还给全局组块列表了,也就是说,类也是可能会被 GC 回收掉的。
    运行时常量池是分配于方法区的,所以可以这么认为:1.6 及之前常量是分配在一个“全局静态区”的,而 1.7 及之后则在堆中分配。
    运行时常量池导致的溢出不常见,上面的例子感觉也有点极端。
    方法区导致的溢出在实际应用中常见:一些框架 Spring、Hibernate 在对类进行增强时会使用到 CGLib 这类字节码技术;JVM 上的动态语言(Groovy)会持续创建类来实现语言的动态特性;拥有大量 JSP 页面或会动态生成 JSP 文件的应用(JSP 第一次运行时会被编译为 Servlet);基于 OSGi 的应用(即使是同一个类文件,被不同的加载器加载也会视为不同的类)等。
  • java.lang.OutOfMemoryError: unable to create new native thread,本质原因是创建了太多的线程,而系统允许创建的线程数是有限制的。
  • java.lang.OutOfMemoryError:GC overhead limit exceeded,是并行(或者并发)垃圾回收器的 GC 回收时间过长、超过 98%的时间用来做 GC 并且回收了不到 2%的堆内存时抛出的这种异常,用来提前预警,避免内存过小导致应用不能正常工作。
  • 栈溢出
    可以先使用-Xoss 参数设置本地方法栈大小(在 HotSpot 中无效,因为它将两个栈合并了),-Xss 参数设置栈容量大小,设得稍微小一些都没有问题。
    实验非常简单,就是定义并调用一个无限递归的方法,在调用深度到达一定程度后就会报错,并且使用一个 stackLength 成员变量记录栈深度
    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
    /*
    VM Args: -Xss228k
    */
    public class StackSOF {
    private int stackLength = 1;

    public void stackLeak() {
    stackLength++;
    stackLeak();
    }
    public static void main(String[] args) {
    StackSOF oom = new StackSOF();
    try {
    oom.stackLeak();
    } catch (Throwable e) {
    System.out.println("stack length:" + oom.stackLength);
    throw e;
    }
    }
    }
    /*
    VM Args: -Xss2M
    */
    public class StackOOM {
    private void dontstop() {
    while(true) {}
    }
    public void stackLeakByThread() {
    while(true) {
    new Thread(new Runnable() {
    @Override
    public void run() {
    dontstop();
    }
    }).start();
    }
    }
    public static void main(String[] args) {
    new StackOOM().stackLeakByThread();
    }
    }
    有两种可能的错误,第一种是线程请求的栈深度超出了虚拟机的允许范围,会产生 StackOverflowError 异常,第二种是虚拟机在扩展栈时无法申请到足够空间,会产生 OutOfMemoryError 异常。
    注意,一个 java 进程的内存容量是由操作系统决定的,Windows 下限制为 2GB,减去 Xmx(最大堆容量)、MaxPermSize(最大方法区容量)、程序计数器(很小)、虚拟机本身耗费的内存,剩下的就由虚拟机栈和本地方法栈瓜分了。
    前者比较容易找出错误,因为会有错误堆栈可以分析;
    后者往往是因为线程分配过多了,导致操作系统分配的内存用尽,事实上,每个线程的主要空间都被栈(虚拟机栈和本地方法栈)占用了,所以为了可以分配更多的线程,可以减少最大堆容量或者减少栈容量。
  • 本机直接内存溢出
    这个例子比较复杂,首先-XX:MaxDirectMemorySize 指定了直接内存大小(默认是-Xmx),然后越过了 DirectByteBuffer,反射获取 Unsafe 实例进行内存分配,allocateMemory 等价于 malloc
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    /*
    VM Args: -Xmx20M -XX:MaxDirectMemorySize=10M
    */
    public class DirectMemoryOOM {
    private static final int _1MB = 1024 * 1024;

    public static void main(String[] args) throws IllegalAccessException {
    Field unsafeField = Unsafe.class.getDeclaredFields()[0];
    unsafeField.setAccessible(true);
    Unsafe unsafe = (Unsafe) unsafeField.get(null);
    while(true) {
    unsafe.allocateMemory(_1MB);
    }
    }
    }
    直接内存,或者说堆外内存,不是在 java 虚拟机规范中定义的存储区域,一般是不受虚拟机控制的,但是 NIO 提供了 Native 函数库可以直接分配堆外内存。
    由 DirectMemory 导致的内存溢出在 Heap Dump 中不会有明显的异常,如果 OOM 之后 Dump 文件很小,且程序中又直接或间接使用了 NIO,就可以考虑是这方面的问题。

分析内存

为排查内存泄露、young gc耗时过长等问题,我们需要分析内存结构。

判断死锁

使用 jstack 判断死锁,下面是一段测试用的死锁代码:

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
public class DeadLockTest {

private static Object obj1 = new Object();
private static Object obj2 = new Object();

public static void main(String[] args) {
new Thread(new Thread1()).start();
new Thread(new Thread2()).start();
}

private static class Thread1 implements Runnable {

@Override
public void run() {
synchronized (obj1) {
System.out.println("Thread1 拿到了 obj1 的锁!");
try {
// 停顿2秒的意义在于,让Thread2线程拿到obj2的锁
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (obj2) {
System.out.println("Thread1 拿到了 obj2 的锁!");
}
}
}
}

private static class Thread2 implements Runnable {

@Override
public void run() {
synchronized (obj2) {
System.out.println("Thread2 拿到了 obj2 的锁!");
try {
// 停顿2秒的意义在于,让Thread1线程拿到obj1的锁
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (obj1) {
System.out.println("Thread2 拿到了 obj1 的锁!");
}
}
}
}
}

使用 jps 查看 Java 进程:

1
2
3
4
5
6
7
# jps

4098 Jps
2644
3991 Launcher
3992 DeadLockTest
2859 RemoteMavenServer

使用 jstack 查看 Java 进程中的所有线程:

1
2
3
# jstack 3992

结果见下图

JVM死锁

服务器 CPU 打满怎么排查

CPU 打满会导致服务器响应速度变慢甚至夯住,一般查看内存没有明显问题后我们就可以怀疑是有线程运行将 CPU 打满了。
1、查 CPU 占用率较高的进程
top 命令查进程占用 CPU
2、查该进程占用 CPU 最高的线程
top -H -p <查出的进程号>

1
2
3
4
5
6
7
8
9
10
11
12
root@app02:~# top -H -p 1153
top - 21:04:21 up 227 days, 6:52, 1 user, load average: 0.78, 0.75, 0.85
Threads: 1007 total, 0 running, 1007 sleeping, 0 stopped, 0 zombie
%Cpu(s): 3.2 us, 0.8 sy, 0.0 ni, 95.8 id, 0.1 wa, 0.0 hi, 0.0 si, 0.0 st
KiB Mem : 49049564 total, 295072 free, 42779556 used, 5974936 buff/cache
KiB Swap: 8191996 total, 431488 free, 7760508 used. 4009280 avail Mem

PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
1487 root 20 0 19.158g 1.113g 6664 S 6.2 2.4 0:36.42 java
1153 root 20 0 19.158g 1.113g 6664 S 0.0 2.4 0:00.00 java
1155 root 20 0 19.158g 1.113g 6664 S 0.0 2.4 0:00.57 java
1160 root 20 0 19.158g 1.113g 6664 S 0.0 2.4 2:01.54 java

转成 16 进制:

1
2
hero@app02:~$ printf "%x\n" 1487
5cf

之后我们会用到这个值,因为 jstack 输出的 log 中使用十六进制表示线程编号。
3、输出

1
root@app02:~# jstack 1153 > test_jstack.txt

从结果中可以搜到上面给出的线程编码5cf

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
11179 "New I/O worker #168" #299 daemon prio=5 os_prio=0 tid=0x00007f3a75127000 nid=0x5cf runnable [0x00007f37278f7000]
11180 java.lang.Thread.State: RUNNABLE
11181 at sun.nio.ch.EPollArrayWrapper.epollWait(Native Method)
11182 at sun.nio.ch.EPollArrayWrapper.poll(EPollArrayWrapper.java:269)
11183 at sun.nio.ch.EPollSelectorImpl.doSelect(EPollSelectorImpl.java:79)
11184 at sun.nio.ch.SelectorImpl.lockAndDoSelect(SelectorImpl.java:86)
11185 - locked <0x00000000e4669320> (a sun.nio.ch.Util$2)
11186 - locked <0x00000000e4669310> (a java.util.Collections$UnmodifiableSet)
11187 - locked <0x00000000e46691e8> (a sun.nio.ch.EPollSelectorImpl)
11188 at sun.nio.ch.SelectorImpl.select(SelectorImpl.java:97)
11189 at org.jboss.netty.channel.socket.nio.SelectorUtil.select(SelectorUtil.java:68)
11190 at org.jboss.netty.channel.socket.nio.AbstractNioSelector.select(AbstractNioSelector.java:434)
11191 at org.jboss.netty.channel.socket.nio.AbstractNioSelector.run(AbstractNioSelector.java:212)
11192 at org.jboss.netty.channel.socket.nio.AbstractNioWorker.run(AbstractNioWorker.java:89)
11193 at org.jboss.netty.channel.socket.nio.NioWorker.run(NioWorker.java:178)
11194 at org.jboss.netty.util.ThreadRenamingRunnable.run(ThreadRenamingRunnable.java:108)
11195 at org.jboss.netty.util.internal.DeadLockProofWorker$1.run(DeadLockProofWorker.java:42)
11196 at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142)
11197 at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617)
11198 at java.lang.Thread.run(Thread.java:745)

SWAP 影响 GC

SWAP 和 GC 同时发生会导致 GC 时间很长,JVM 严重卡顿,甚至导致服务崩溃。
JVM 进行 GC 时,需要对相应堆分区的已用内存进行遍历;假如 GC 的时候,有堆的一部分内容被交换到 SWAP 中,遍历到这部分的时候就需要将其交换回内存,同时由于内存空间不足,就需要把内存中堆的另外一部分换到 SWAP 中去;于是在遍历堆分区的过程中,(极端情况下)会把整个堆分区轮流往 SWAP 写一遍。

QA

栈溢出和由栈引起的OOM有什么关系?

虽然都是由递归调用引起的,但是这两种异常引起的条件并不相同:

  1. 栈溢出(StackOverflowError)
    方法调用栈深度超出了虚拟机的允许范围。
  2. 栈引起的OOM
    虚拟机在扩展栈时无法申请到足够空间。

类文件结构

类文件结构比较繁琐,我暂时没有整理的兴趣,看一下《深入理解 Java 虚拟机上》上的介绍就可以了。

类加载器

Java 类结构是在运行时而不是在编译时确定的,而是由类加载器在运行期间加载的,因此称类的加载过程是动态加载,当调用某个类型对象的方法时,具体调用哪个方法是在运行期间决定的,因此称之为动态连接
类加载器(ClassLoader)不是 Java 虚拟机的组成部分,它由外部调用,执行加载过程。
类加载器用于加载类,任何类都需要由加载它的类加载器和这个类一同确立其在 Java 虚拟机中的唯一性,每一个类加载器,都有一个独立的类名称空间,因此由不同类加载器加载的类、就算它们真的是一个 class 出来的,也不算是同一个类型的,也不能直接进行交互(可以通过反射进行交互)。实现上,实际上 jvm 将类加载器的引用作为类型信息的一部分保存在方法区,作为判断两个类相同的依据。

写一个自定义的类加载器

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
import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;

public class MyClassLoader extends ClassLoader {

private String rootPath;

public MyClassLoader(String rootPath) {
this.rootPath = rootPath;
}

@Override
protected Class<?> findClass(String name) {
Class<?> clz = findLoadedClass(name);
if (clz != null) {
return clz;
}
byte[] classData = getData(name);
clz = defineClass("Hello", classData, 0, classData.length);
return clz;
}

private byte[] getData(String className) {
String pathName = rootPath + className.replace(".", "/") + ".class";
System.out.println("加载的类路径: " + pathName);
byte[] bytes = null;
try (FileInputStream fis = new FileInputStream(pathName);
ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
byte[] flush = new byte[1024 * 1024];
int len;
while (-1 != (len = fis.read(flush))) {
bos.write(flush, 0, len);
}
bytes = bos.toByteArray();
} catch (Exception e) {
e.printStackTrace();
}
return bytes;
}

public static void main(String[] args) throws IllegalAccessException, InstantiationException {
MyClassLoader classLoader = new MyClassLoader("/tmp/");
Class<?> helloClass = classLoader.findClass("Hello");
Object hello = helloClass.newInstance();
System.out.println(hello);
}
}

这个类加载器:

  1. findClass的时候,先判断之前是否已经加载过这个类,如果加载过就直接返回了(双亲委派);
  2. /tmp目录下面读类文件,对读进的二进制流数据使用defineClass转换为类对象。

测试类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import java.util.StringJoiner;

public class Hello {

private int a = 10;

static {
System.out.println("123");
}

@Override
public String toString() {
return new StringJoiner(", ", Hello.class.getSimpleName() + "[", "]")
.add("a=" + a)
.toString();
}
}

使用javac命令编译后,将Hello.class文件移动到/tmp目录下。

类从哪里来

除了从文件加载以外,Java 的类加载机制还支持从网络读取,其实只要根据自己的需要实现类加载器,就能将任何二进制数据作为字节码数据进行加载。

  • -XX:+TraceClassLoading
    跟踪类加载过程,结果形如:[Loaded java.lang.invoke.MethodHandleImpl$Lazy from D:\programme\jdk\jdk8U74\jre\lib\rt.jar]

  • mvn dependency:tree > ~/dependency.txt
    打出所有依赖

  • mvn dependency:tree -Dverbose -Dincludes=groupId:artifactId
    只打出指定 groupId 和 artifactId 的依赖关系

  • -XX:+TraceClassLoading
    vm 启动脚本加入。在 tomcat 启动脚本中可见加载类的详细信息

  • -verbose
    vm 启动脚本加入。在 tomcat 启动脚本中可见加载类的详细信息

  • greys:sc
    greys 的 sc 命令也能清晰的看到当前类是从哪里加载过来的

  • tomcat-classloader-locate
    通过以下 url 可以获知当前类是从哪里加载的
    curl http://localhost:8006/classloader/locate?class=org.apache.xerces.xs.XSObject

类加载器的种类

从虚拟机角度看,只存在两种类加载器:1. 启动类加载器。2. 其他类加载器。
从开发人员角度看,包括如下类加载器:1. 启动类加载器。2. 扩展类加载器。3. 应用程序类加载器。4. 自定义类加载器。

  • 启动类加载器,用于加载 Java API,加载<JAVA_HOME>/lib 目录下的类库。
  • 扩展类加载类,由 sun.misc.Launcher$ExtClassLoader 实现,用于加载<JAVA_HOME>/lib/ext 目录下或者被 java.ext.dirs 系统变量指定路径下的类库。
  • 应用程序类加载器,也成为系统类加载器,由 sun.misc.Launcher$AppClassLoader 实现,用于加载用户类路径(ClassPath)上所指定的类库。
  • 自定义类加载器,继承系统类加载器,实现用户自定义加载逻辑。

双亲委派模型

类加载器执行过程
Java 设计者推荐所有自定义加载器都组合一个父加载器,加载类时先委派父加载器去尝试加载,若不成,再由自身去加载。所以一些基类总是由基加载器去加载,可以避免一个程序中有多个 java.lang.Object 类的情况
注意各个类加载器之间是组合关系,并非继承关系
当一个类加载器收到类加载的请求,它将这个加载请求委派给父类加载器进行加载,每一层加载器都是如此,最终,所有的请求都会传送到启动类加载器中。只有当父类加载器自己无法完成加载请求时,子类加载器才会尝试自己加载。
双亲委派模型可以确保安全性,可以保证所有的 Java 类库都是由启动类加载器加载。如用户编写的 java.lang.Object,加载请求传递到启动类加载器,启动类加载的是系统中的 Object 对象,而用户编写的 java.lang.Object 不会被加载。如用户编写的 java.lang.virus 类,加载请求传递到启动类加载器,启动类加载器发现 virus 类并不是核心 Java 类,无法进行加载,将会由具体的子类加载器进行加载,而经过不同加载器进行加载的类是无法访问彼此的。由不同加载器加载的类处于不同的运行时包。所有的访问权限都是基于同一个运行时包而言的。

类的加载时机

Java 虚拟机规范没有强制规定类的加载时机,但是严格规定了以下 5 种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始)

  1. 遇到字节码指令 new(实例化对象时)、getstatic、putstatic(读取或设置一个类的静态字段)、invokestatic(调用一个类的静态方法);
  2. 遇到反射调用 java.lang.reflect 对类进行反射调用;
  3. 初始化一个类时,其父类还未初始化,则先初始化其父类(对接口不适用,即初始化子接口并不会导致父接口初始化);
  4. 主类(main);
  5. JDK7 的动态语言支持,java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic 方法句柄,并且这个方法句柄对应的类没有进行初始化,则需要先进行初始化。

上边的情况统称为主动引用,其他情况都是被动引用,被动引用都不会触发类的初始化

  • 被动引用的示例如下,主要是使用 类初始化块 进行验证的(即 static 块),只输出了”Super!”,原因是不满足上边的条件,子类是否被加载完全由虚拟机实现说了算
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    class SuperClass {
    public static int value = 123;
    static {
    System.out.println("Super!");
    }
    }
    class SubClass {
    static {
    System.out.println("Sub!");
    }
    public static void main(String[] args) {
    System.out.println(SubClass.value);
    }
    }
  • 书上举了另外一个例子说明 数组类初始化 的问题,下面代码并不会触发 SuperClass 类的初始化,而是初始化了一个对应的数组类,可以通过加-XX:+TraceClassLoading 的运行时参数来跟踪类的加载过程
    1
    2
    3
    4
    5
    public class NotInitialization {
    public static void main(String[] args) {
    SuperClass[] sca = new SuperClass[10];
    }
    }
  • 还有一个例子来说明 常量传播优化 会导致的混淆情况,即使用常量字段(static final)时不会触发初始化,编译时会将常量值保存到调用者的类常量池中,所以在调用时就和被调用者没关系了,下面的代码并不会触发 ConstClass 类的加载
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    class ConstClass {
    static {
    System.out.println("ConstClass!");
    }
    public static final String HELLOWORLD = "hello world";
    }
    public class NotInitialization {
    public static void main(String[] args) {
    System.out.println(ConstClass.HELLOWORLD);
    }
    }

类的加载过程

类的加载过程

加载

  1. 通过类的完全限定名获取表示类的二进制流;
  2. 转换为方法区中的类结构;
  3. 在 Java 堆中生成一个代表这个类的 java.lang.Class 对象,作为方法区这些数据的访问入口

对于数组类而言,数组类由 java 虚拟机直接创建,不通过类加载器创建。数组类的创建过程如下:

  1. 如果数组元素类型是引用类型,就采用双亲委派模型进行加载(之后会介绍),数组类将在加载该元素类型的类名称空间上被标识。
  2. 如果数组元素类型为基本类型,数组类被标记为与引导类加载器关联。
  3. 数组类的可见性与其元素类型可见性一致,如果元素类型不是引用类型,那数组类的可见性默认为 public。

验证

此阶段的主要目的是确保 class 文件的字节流包含的信息符合虚拟机的要求,进行一部分的语义分析,主要是防止字节码中存在一些危险操作(数组越界、错误转型、跳转过头等),后来的 Java 虚拟机规范还规定了文件格式、符号引用等的验证。
不同的虚拟机,对类验证的实现可能有所不同,但大致都会完成下面四个阶段的验证:

  1. 文件格式验证,是要验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机处理。如验证魔数是否为 0xCAFEBABE;主、次版本号是否正在当前虚拟机处理范围之内;常量池的常量中是否有不被支持的常量类型。
    该验证阶段的主要目的是保证输入的字节流能正确地解析并存储于方法区中,经过这个阶段的验证后,字节流才会进入内存的方法区中存储,所以后面的三个验证阶段都是基于方法区的存储结构进行的。
  2. 元数据验证,对字节码描述的信息进行语义分析,以保证其描述的信息符合 Java 语言规范的要求。可能包括的验证如:这个类是否有父类;这个类的父类是否继承了不允许被继承的类;如果这个类不是抽象类,是否实现了其父类或接口中要求实现的所有方法……
  3. 字节码验证,主要工作是进行数据流和控制流分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为。如果一个类方法体的字节码没有通过字节码验证,那肯定是有问题的;但如果一个方法体通过了字节码验证,也不能说明其一定就是安全的。
  4. 符号引用验证,发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在“解析阶段”中发生。验证符号引用中通过字符串描述的权限定名是否能找到对应的类;在指定类中是否存在符合方法字段的描述符及简单名称所描述的方法和字段;符号引用中的类、字段和方法的访问性(private、protected、public、default)是否可被当前类访问。

验证阶段对于虚拟机的类加载机制来说,不一定是必要的阶段。如果所运行的全部代码确认是安全的,可以使用-Xverify:none 参数来关闭大部分的类验证措施,以缩短虚拟机类加载时间

准备

为类变量(static 变量)分配内存和并初始化为默认值,它们被分配在方法区中。
准备阶段不分配类中的实例变量的内存,实例变量将会在对象实例化时随着对象一起分配在 Java 堆中。
比如static int a = 1;在准备阶段后 a 的值为 0,在初始化阶段后才变成 1,但是对常量字段(static final),在准备阶段会直接赋予用户设定的值。

解析

将常量池内的符号引用替换为直接引用,类似于将一个字面量 hash 到一个确定的槽中。
符号引用(Symbolic Reference):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。
直接引用(Direct Reference):直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是与虚拟机实现的内存布局相关的,如果有了直接引用,那么引用的目标必定已经在内存中存在。指向类型、类变量、类方法的直接引用可能是指向方法区的本地指针。

常见的符号引用包括类或接口的全限定名、字段名和描述符、方法名和描述符。类型的直接引用可能简单地指向保存类型数据的方法区中的与实现相关的数据结构。类变量的直接引用可以指向方法区中保存的类变量的值。类方法的直接引用可以指向方法区中的一段数据结构方法区中包含调用方法的必要数据。指向实例变量和实例方法的直接引用都是偏移量。实例变量的直接引用可能是从对象的映像开始算起到这个实例变量位置的偏移量。实例方法的直接引用可能是到方法表的偏移量。

为了加快解析效率,可以对解析结果进行缓存,之后再解析符号引用时直接返回即可,但是对于 invokedynamic 则不能进行缓存。解析主要是针对 CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info、CONSTANT_InvokeDynamic_info 七种常量类型。

  1. 类或接口的解析
    将符号引用替换为直接引用包括如下几步。假设符号引用记为 S,当前类记为 C,S 对应的类或接口记为 I。
    • 若 S 不是数组类型,则把 S 传递给当前类 C 的类加载器进行加载,这个过程可能会触发其他的加载,这个过程一旦出现异常,则解析失败。
    • 若 S 是数组类型,并且数组元素类型为对象,则 S 的描述符会形如[java/lang/String,按照第一条去加载该类型,如果 S 的描述符符合,则需要加载的类型就是 java.lang.String,接着有虚拟机生成一个代表此数组唯独和元素的数组对象。
    • 若以上两个步骤没有出现异常,即 I 已经存在于内存中了,但是解析完成时还需要进行符号引用验证,确认 C 是否具备对 I 的访问权限。若不具备,则抛出 java.lang.IllegalAccessError 异常。
  2. 字段解析
    首先将 CONSTANT_Fieldref_info 中的 class_index 索引的 CONSTANT_Class_info 符号引用进行解析,即解析字段所在类或接口,若解析出现异常,则字段解析失败。如解析成功,则进行下面的解析步骤。假设该字段所属的类或接口标记为 C。
    • 如果 C 包含了字段的简单名和描述符与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
    • (字段解析对接口优先搜索)否则,如果 C 实现了接口,按照继承关系从下往上递归搜索各个接口和它的父接口,看是否存在相匹配的字段。存在,则返回直接引用,查找结束。
    • 否则,如果 C 不是 Object 对象,按照继承关系从下往上递归搜索父类,看是否存在相匹配的字段。存在,则返回直接引用,查找结束。
    • 否则,查找失败,抛出 java.lang.NoSuchFieldError 异常。
  3. 类方法解析
    首先将 CONSTANT_Methodref_info 中的 class_index 索引的 CONSTANT_Class_info 符号引用进行解析,即解析方法所在的类或接口,若解析出现异常,则方法解析失败;如解析成功,则进行下面解析步骤。假设该方法所属的类标记为 C。
    • 如果在方法表中发现 CONSTANT_Class_info 中索引的 C 是一个接口而不是一个类,则抛出 java.lang.IncompatibleClassChangeError 异常。
    • 否则,如果 C 中包含了方法的简单名和描述符与目标相匹配的字段,则返回这个方法的直接引用,查找结束。
    • (方法解析对父类优先搜索)否则,在 C 的父类中递归搜索,看是否存在相匹配的方法,存在,则返回直接引用,查找结束。
    • 否则,在 C 实现的接口列表及父接口中递归搜索,看是否存在相匹配的方法,存在,说明 C 是一个抽象类(没有实现该方法,否则,在第一步就查找成功),抛出 java.lang.AbstractMethodError 异常。
    • 否则,查找失败,抛出 java.lang.NoSuchMethodError 异常。
    • 若查找过程成功,则对方法进行权限验证,如果发现不具备对此方法的访问权限,则抛出 java.lang.lllegalAccessError 异常。
  4. 接口方法解析
    首先将 CONSTANT_InterfaceMethodref_info 中的 class_index 索引的 CONSTANT_Class_info 符号引用进行解析,即解析方法所在的类或接口,若解析出现异常,则方法解析失败;如解析成功,则进行下面解析步骤。假设该方法所属的类标记为 C。
    • 如果在方法表中发现 CONSTANT_Class_info 中索引的 C 是一个类而不是接口,则抛出 java.lang.IncompatibleClassChangeError 异常。
    • 否则,如果 C 中包含了方法的简单名和描述符与目标相匹配的字段,则返回这个方法的直接引用,查找结束。
    • 否则,在 C 的父接口中递归搜索,直到 Object 类,看是否存在相匹配的方法,存在,则返回直接引用,查找结束。
    • 否则,查找失败,抛出 java.lang.NoSuchMethodError 异常。
    • 若查找过程成功,不需要进行权限验证,因为接口方法为 public,不会抛出 java.lang.IllegalAccessError 异常。

初始化

类初始化是类加载过程的最后一步,前面的类加载过程,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制,到了初始化阶段,才真正开始执行类中定义的 Java 程序代码。
初始化是执行类构造器<clinit>()的过程,()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块/类初始化块)中的语句合并产生的。具体地说,应该有以下几条规则:

  • 由编译器收集类中的所有类变量的赋值动作(如果仅仅只是声明,不会被收集)和静态语句块中的语句合并产生的,收集顺序按照语句在源文件中出现的顺序所决定;在静态语句块中只能访问定义在静态语句之前的变量;而对于定义在静态语句块之后的变量,可以进行赋值,但是不能够访问。
  • 不需要显示调用父类构造器,虚拟机会保证在子类的()方法执行之前,父类的()方法已经执行完毕,所以,第一个被执行的()方法的类肯定是 java.lang.Object。
  • 父类中定义的静态语句块优先于子类的静态语句。
  • 此方法对类和接口都不是必须的,若类中没有静态语句块和静态变量赋值操作,则不会生成()方法。
  • 接口会生成此方法,因为对接口的字段可以进行赋值操作。执行接口的()方法不需要先执行父接口的()方法,只有在使用父接口的变量时,才会进行初始化;接口的实现类在初始化时也不会执行接口的()方法。
  • 此方法在多线程环境中会被正确的加锁、同步。

使用

完成了初始化阶段后,我们就可以使用对象了,在程序中可以随意进行访问,只要类还没有被卸载。

卸载

GC 能够对方法区内无用对象进行回收,启动类加载的类型永远是可触及的,回收的是由用户自定义加加载器加载的类,具体内容等到 GC 部分再说。

接口的加载过程

接口的加载和类的加载是类似的,只是接口要初始化时并不会连带父接口一块初始化,只有在真正用到父接口时才会执行初始化。

定义类加载器和初始类加载器

我们知道不同类加载器加载的类位于不同的命名空间,它们之间是相互隔离的,这里说的隔离仅仅指它们存储位置隔离,并不是说一个自定义的类 A 使用了 java.util.List 类就会报错。
自定义的类 A 一般会使用系统类加载器加载,而 java.util.List 则会由启动类加载器加载,当加载类 A 时如果遇到了 java.util.List,会首先尝试通过系统类加载器加载,在它发现自己无法加载后,通过双亲委派模型交给父加载器加载。
初始类加载器和定义类加载器
如上图所示:

  • A 是由系统类加载器加载的,因此系统类加载器是其定义类加载器兼初始类加载器;
  • 系统类加载器加载过 java.util.List,因此是其初始类加载器;
  • 启动类加载器实际加载了 java.util.List,因此是其定义类加载器。

QA

  1. 为什么下面的执行结果为 0?
    说明类加载器在加载一个类时,父类的成员变量就算被覆盖,其存储空间依然还存在。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    class A {
    int a;
    }
    public class JavaTest extends A {
    int a;
    @Test
    public void test() {
    a = 1;
    System.out.println(super.a);
    }
    }
  2. 为什么下面的报错?
    类的初始化阶段有一个细节:类初始化块不能访问定义在其之后的变量
    1
    2
    3
    4
    5
    6
    7
    public class JavaTest {
    static {
    i = 0;
    System.out.println(i); // 报错
    }
    static int i = 1;
    }
  3. 为什么输出两个’A’?
    当我们 new A()时,首先为 A 分配内存空间,此时 A 已经存在了,只是还未初始化,然后调用 A 的构造函数,A 的构造函数又隐式调用了父类的构造函数。
    在父类构造函数中使用 this 调用 draw(),this 实际上指向了 a 对象,平常调用方法时 this 也是隐含的。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    class B {
    int a;
    B() {
    this.draw();
    }
    void draw() {
    System.out.println("B");
    }
    }
    class A extends B {
    int a;
    A() {
    draw();
    }
    @Override
    void draw() {
    System.out.println("A");
    }
    public static void main(String[] args) {
    A a = new A();
    }
    }
  4. 为什么最后输出的 count2 为 0?
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    class SingleTon {
    private static SingleTon singleTon = new SingleTon();
    public static int count1;
    public static int count2 = 0;
    private SingleTon() {
    count1++;
    count2++;
    }
    public static SingleTon getInstance() {
    return singleTon;
    }
    }
    public class Test {
    public static void main(String[] args) {
    SingleTon singleTon = SingleTon.getInstance();
    System.out.println("count1=" + singleTon.count1);
    System.out.println("count2=" + singleTon.count2);
    }
    }
    这里有问题的应该是 static 变量的初始化和构造方法被调用的顺序,实际上构造方法是先被调用的。
  5. SingleTon singleTon = SingleTon.getInstance();调用了类的 SingleTon 调用了类的静态方法,触发类的初始化(主动引用)
  6. 类加载的时候在准备过程中为类的静态变量分配内存并初始化默认值 singleton=null count1=0,count2=0(准备)
  7. 类初始化,为类的静态变量赋值和执行静态代码快。singleton 赋值为 new SingleTon()调用类的构造方法(初始化)
  8. 调用类的构造方法后 count=1;count2=1
  9. 继续为 count1 与 count2 赋值,此时 count1 没有赋值操作,所有 count1 为 1,但是 count2 执行赋值操作就变为 0
  10. 读下面的类加载器应用代码,为什么输出 false?
    myLoader 加载的类和虚拟机的默认类加载器(Bootstrap ClassLoader)将加载的类保存在不同的命名空间中,它们相当于不同的类。
    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
    public class JavaTest {
    @Test
    public void test() throws ClassNotFoundException, IllegalAccessException, InstantiationException {
    ClassLoader myLoader = new ClassLoader() {
    @Override
    public Class<?> loadClass(String name) throws ClassNotFoundException {
    String fileName = name.substring(
    name.lastIndexOf(".") + 1) + ".class";
    InputStream is = getClass().getResourceAsStream(fileName);
    if(is == null) { return super.loadClass(name); }
    try {
    byte[] b = new byte[is.available()]; // 进行验证
    is.read(b);
    return defineClass(name, b, 0, b.length);
    } catch (IOException e) {
    throw new ClassNotFoundException(name);
    }
    }
    };
    Object obj = myLoader.loadClass("com.tallate.JavaTest")
    .newInstance();
    System.out.println(obj.getClass());
    System.out.println(obj instanceof com.tallate.JavaTest); // false?
    }
    }
  11. JVM 怎么知道哪些类应该委托给父加载器加载?
    每个类装载器都有一个 URLClassPath 对象用于保存类路径,在加载时会先在这个路径下查找该类,找不到再返回 null。
  12. 不同命名空间的类为什么能互相使用?
    双亲委派模型中,一个类装载器总是会先委托父类去进行装载,所有这些被委托的类装载器都被称为初始类装载器,而实际装载的被称为定义类装载器,所有初始装载器间的类型是共享的
  13. 可以不可以自己写个 String 类?
    不能,因为根据类加载的双亲委派机制,会去加载父类,父类发现冲突了 String 就不再加载了。
  14. Tomcat 的应用隔离原理是什么?
    Tomcat 实现了两种隔离技术:用于线程隔离的线程池和用于代码隔离的 WebAppClassLoader。
    前者不必赘述,对于后者,大家比较感兴趣的是 Tomcat 中对双亲委派模型的违背,因为它不是先委托父加载器去加载目标类,因为 Tomcat 一个进程可以运行多个 web 服务器,两个 web 项目中可能会出现两个声明完全一致的类,它们必须所处的命名空间必须隔离开,不然可能会发生一个项目启动后调到另一个项目中的代码的情况,这样就乱套了。

参考

类文件结构

  1. 【JVM】JVM 系列之 Class 文件(三)

对象分配

  1. What do Java objects look like in memory during run-time?
  2. What does a Java array look like in memory?
0%