Fork me on GitHub
夸克的博客


  • 首页

  • 关于

  • 标签

  • 分类

  • 归档

  • 搜索

dubbo2.7中时间轮的应用

发表于 2020-05-21 | 分类于 dubbo | 热度: ℃
字数统计: 4,169 | 阅读时长 ≈ 18

前言

dubbo内部有比较多定时任务的管理功能,JDK也提供了Timer和DelayedQueue等工具类,可以实现简单的定时任务管理,其底层实现就是使用的堆这种数据结构,存取的时间复杂度是O(nlogN),无法支持大量的定时任务。dubbo内部采用了时间轮的方式来管理定时任务。应用场景比如:dubbo的心跳机制、dubbo客户端超时检测等。

时间轮是一种高效的、批量管理的定时任务的调度模型。时间轮一般会实现一个环形结构,类似于时钟,分为很多槽,每个槽代表一个时间间隔,每个槽使用双向链表来存储定时任务;指针周期性地跳动,跳动到一个槽位,执行对应的定时任务。

image-20220701053550790

注意下单层时间轮的容量和精度是有限的,如果时间跨度特比大,精度要求很高,或者海量定时任务需要调度的场景,通常会使用多级时间轮以及持久化存储的方案。
比如多级时间轮以及持久化存储与时间轮结合,每级时间轮的时钟周期不一样,比如年级别时间轮、月级别时间轮、日级别时间轮、毫秒级别时间轮,定时任务在创建时先持久化,在时钟指针接近时预读到内存,并且需要定期清理磁盘上的过期任务。

Dubbo中,时间轮的实现方式是主要在dubbo-common的org.apache.dubbo.common.timer包中。dubbo和netty的实现基本一致,netty时间轮一个应用场景简单提下,Redisson实现分布式锁提供了watchdog锁续期的功能,为了避免每加锁一次起一个线程去扫描是否需要续期以及执行续期逻辑带来的压力,采用了netty的时间轮来注册续期任务,只用一个线程和合适的时间周期完成了续期逻辑。

核心接口

Timer接口:定义了定时器的基本行为。核心方法是newTimeout方法,提交一个定时任务(TimerTask)返回关联的Timeout对象。

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
/**
* 定义了定时器的基本行为
* 核心方法是newTimeout方法:提交一个定时任务(TimerTask)并且返回关联的Timeout对象,类似于线程池中提交任务
* Schedules {@link TimerTask}s for one-time future execution in a background
* thread.
*/
public interface Timer {

/**
* Schedules the specified {@link TimerTask} for one-time execution after
* the specified delay.
* 向时间轮中提交一个定时任务
*
* @return a handle which is associated with the specified task
* @throws IllegalStateException if this timer has been {@linkplain #stop() stopped} already
* @throws RejectedExecutionException if the pending timeouts are too many and creating new timeout
* can cause instability in the system.
*/
Timeout newTimeout(TimerTask task, long delay, TimeUnit unit);

/**
* Releases all resources acquired by this {@link Timer} and cancels all
* tasks which were scheduled but not executed yet.
*
* @return the handles associated with the tasks which were canceled by
* this method
*/
Set<Timeout> stop();

/**
* the timer is stop
*
* @return true for stop
*/
boolean isStop();
}

HashedWheelTimer实现类:Timer接口的实现类。通过时间轮算法实现了一个定时器。执行过程为:

  • 根据当前时间轮指针选定对应的槽。
  • 遍历槽上的定时任务(HashedWheelTimeout),对每个定时任务进行计算,是当前时钟周期则去除,如果不是则将任务中的剩余时钟周期-1,代表距离执行又接近了一圈。

TimerTask接口:所有定时任务都要继承TimerTask接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 所有定时任务都需要继承的接口
* A task which is executed after the delay specified with
* {@link Timer#newTimeout(TimerTask, long, TimeUnit)} (TimerTask, long, TimeUnit)}.
*/
public interface TimerTask {

/**
* Executed after the delay specified with
* {@link Timer#newTimeout(TimerTask, long, TimeUnit)}.
*
* @param timeout a handle which is associated with this task
*/
void run(Timeout timeout) throws Exception;
}

Timeout接口:TimerTask中run()方法的参数,可以查看定时任务的状态,还可以操作取消定时任务

image-20220701053735936

HashedWheelTimeout:Timeout接口的唯一实现,是HashedWheelTimer的内部类。扮演两个角色:

  • 第一个,时间轮中双向链表的节点,即定时任务TimerTask在HashedWheelTimer中的容器
  • 第二个,定时任务TimerTask提交到HashedWheelTimer之后的句柄,用于在时间轮外查看和控制定时任务。

HashedWheelTimeout的核心字段

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
/**
* 实现了Timeout接口 内部类 HashedWheelTimeout是Timeout的唯一实现。 两个作用
* 1.时间轮中双向链表的节点,定时任务TimerTask在HashedWheelTimer中的容器
* 2.TimerTask提交到HashedWheelTimer之后返回的句柄(Handle),用于在时间轮外部查看和控制定时任务
*/
private static final class HashedWheelTimeout implements Timeout {

private static final int ST_INIT = 0;
private static final int ST_CANCELLED = 1;
private static final int ST_EXPIRED = 2;
// 状态控制
private static final AtomicIntegerFieldUpdater<HashedWheelTimeout> STATE_UPDATER =
AtomicIntegerFieldUpdater.newUpdater(HashedWheelTimeout.class, "state");

private final HashedWheelTimer timer;
// 实际被调度的任务
private final TimerTask task;

// 定时任务被执行的时间 单位纳秒
// 计算公式:currentTime(创建 HashedWheelTimeout 的时间) + delay(任务延迟时间) - startTime(HashedWheelTimer 的启动时间)
private final long deadline;

@SuppressWarnings({"unused", "FieldMayBeFinal", "RedundantFieldInitialization"})
// 状态有三种 INIT(0)、CANCELLED(1)、EXPIRED(2)
private volatile int state = ST_INIT; // 状态字段

/**
* RemainingRounds will be calculated and set by Worker.transferTimeoutsToBuckets() before the
* HashedWheelTimeout will be added to the correct HashedWheelBucket.
* 当前任务剩余的时钟周期数。时间轮表示的时间长度有限 在任务到期时间与当前时刻的时间差,超过时间轮单圈能表示的时长
* 就出现套圈的情况,这时需要该字段表示剩余的时钟周期
*/
long remainingRounds;

/**
* 当前定时任务在链表中的前驱和后继节点
* 单线程操作不需要加锁控制
*/
HashedWheelTimeout next;
HashedWheelTimeout prev;

/**
* The bucket to which the timeout was added
*/
HashedWheelBucket bucket;

HashedWheelTimeout的核心方法

  • isCancelled()、isExpired()、state()方法:主要用来检查HashedWheelTimeout的状态。
  • cancel()方法:将当前HashedWheelTimeout状态设置为CANCELLED,将当前HashedWheelTimeout添加到canceledTimeouts队列等待销毁。
  • expire()方法:当任务到期时,会调用该方法将当前HashedWheelTimeout设置为Expired状态,然后调用其中的TimerTask的run()方法执行定时任务。
  • remove()方法:将当前的HashedWheelTimeout从时间轮中删除。

HashedWheelTimer 时间轮

上面有提到HashedWheelTimer实现类是时间轮的具体实现,工作原理是根据当前时间轮的指针选定对应的槽(HashedWheelBucket),从双向链表的头节点开始迭代,对每个HashedWheelTimeout进行计算,属于当前时钟周期则取出运行,不属于则将其时钟周期-1,等待下一圈的判断。

核心字段

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
// 实现了Timer接口 通过时间轮算法实现了一个定时器
// 根据当前时间轮指针选定对应的槽(HashedWheelBucket),
// 从双向链表头开始遍历,对每个定时任务(HashedWheelTimeout)进行计算,属于当前时钟周期取出运行,否则将 剩余时钟周期数 减1
public class HashedWheelTimer implements Timer {

/**
* may be in spi?
*/
public static final String NAME = "hased";

private static final Logger logger = LoggerFactory.getLogger(HashedWheelTimer.class);

private static final AtomicInteger INSTANCE_COUNTER = new AtomicInteger();
private static final AtomicBoolean WARNED_TOO_MANY_INSTANCES = new AtomicBoolean();
private static final int INSTANCE_COUNT_LIMIT = 64;
// 状态变更器
private static final AtomicIntegerFieldUpdater<HashedWheelTimer> WORKER_STATE_UPDATER =
AtomicIntegerFieldUpdater.newUpdater(HashedWheelTimer.class, "workerState");

// 真正执行的定时任务逻辑
private final Worker worker = new Worker();
private final Thread workerThread;

private static final int WORKER_STATE_INIT = 0;
private static final int WORKER_STATE_STARTED = 1;
private static final int WORKER_STATE_SHUTDOWN = 2;

/**
* 时间轮当前状态
* 0 - init, 1 - started, 2 - shut down
*/
@SuppressWarnings({"unused", "FieldMayBeFinal"})
private volatile int workerState;

// 时间指针每次加1所代表的实际时间 单位为纳秒 即槽与槽之间的时间间隔
private final long tickDuration;

// 时间轮中的槽 即时间轮的环形队列 一般为大于且最靠近n的2的幂次方
private final HashedWheelBucket[] wheel;

// 掩码 mask=wheel.length-1 执行ticks & mask能定位到对应的时钟槽
private final int mask;

// 确认时间轮中的startTime的闭锁
private final CountDownLatch startTimeInitialized = new CountDownLatch(1);

// 两个队列是对于添加的定时任务和取消的定时任务的缓冲。
// 缓冲外部提交时间轮的定时任务
private final Queue<HashedWheelTimeout> timeouts = new LinkedBlockingQueue<>();
// 暂存取消的定时任务 会被销毁掉
private final Queue<HashedWheelTimeout> cancelledTimeouts = new LinkedBlockingQueue<>();

// 当前时间轮剩余的定时任务总数
private final AtomicLong pendingTimeouts = new AtomicLong(0);
// 阈值
private final long maxPendingTimeouts;

// 时间轮启动时间 提交到时间轮的定时任务deadline字段值以该时间为起点进行计算
private volatile long startTime;
}

newTimeout方法提交定时任务

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
// 时间轮 去加入一个新的任务
@Override
public Timeout newTimeout(TimerTask task, long delay, TimeUnit unit) {
if (task == null) {
throw new NullPointerException("task");
}
if (unit == null) {
throw new NullPointerException("unit");
}

//剩余定时任务数+1
long pendingTimeoutsCount = pendingTimeouts.incrementAndGet();

if (maxPendingTimeouts > 0 && pendingTimeoutsCount > maxPendingTimeouts) {
// 阈值判断
pendingTimeouts.decrementAndGet();
throw new RejectedExecutionException("Number of pending timeouts ("
+ pendingTimeoutsCount + ") is greater than or equal to maximum allowed pending "
+ "timeouts (" + maxPendingTimeouts + ")");
}

// 确定时间轮的startTime start()方法会初始化时间轮,确定startTime
// 启动workerThread 开始执行worker任务
start();

// 计算deadline
// Add the timeout to the timeout queue which will be processed on the next tick.
// During processing all the queued HashedWheelTimeouts will be added to the correct HashedWheelBucket.
long deadline = System.nanoTime() + unit.toNanos(delay) - startTime;

// Guard against overflow.
if (delay > 0 && deadline < 0) {
deadline = Long.MAX_VALUE;
}
// 封装为HashedWheelTimeout 加入到队列中
HashedWheelTimeout timeout = new HashedWheelTimeout(this, task, deadline);
timeouts.add(timeout);
return timeout;
}

newTimeout主要做了几件事:

  • 维护了时间轮中剩余定时任务数量字段
  • start()方法
    • 计算了时间轮的startTime方法,且修改时间轮的状态
    • worker线程调用start()方法,开启执行扫描时间轮的线程。
  • 根据startTime来计算定时任务要调度的deadline,当前时间+延时时间 - 启动时间。
  • 封装task为HashedWheelTimeout添加到timeouts执行队列中。

worker线程扫描时间轮并执行任务

在worker线程的run()方法中:

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
private final class Worker implements Runnable {
private final Set<Timeout> unprocessedTimeouts = new HashSet<Timeout>();

// tick周期
private long tick;

@Override
public void run() {
// Initialize the startTime.
startTime = System.nanoTime();
if (startTime == 0) {
// We use 0 as an indicator for the uninitialized value here, so make sure it's not 0 when initialized.
startTime = 1;
}

// Notify the other threads waiting for the initialization at start().唤醒外边的线程
startTimeInitialized.countDown();

do {
// 等待下一个执行周期 即槽之间的时间间隔 比如每个槽之间是1s执行一次
final long deadline = waitForNextTick();
if (deadline > 0) {
// 确认索引
int idx = (int) (tick & mask);
// 清理已取消的定时任务
processCancelledTasks();
// 对应的槽
HashedWheelBucket bucket =
wheel[idx];
// 转移缓存在timeouts队列中的已提交定时任务到时间轮对应的槽中
transferTimeoutsToBuckets();
// 处理当前槽中的定时任务
bucket.expireTimeouts(deadline);
tick++;
}
} while (WORKER_STATE_UPDATER.get(HashedWheelTimer.this) == WORKER_STATE_STARTED); // 模拟时间轮转动

// Fill the unprocessedTimeouts so we can return them from stop() method.
for (HashedWheelBucket bucket : wheel) {
bucket.clearTimeouts(unprocessedTimeouts);
}
for (; ; ) {
HashedWheelTimeout timeout = timeouts.poll();
if (timeout == null) {
break;
}
if (!timeout.isCancelled()) {
// 状态变更之后 未被加入到槽中的未取消任务加入到unprocessedTimeouts队列
unprocessedTimeouts.add(timeout);
}
}
processCancelledTasks();
}

// bucket.expireTimeouts 执行定时任务
/**
* Expire all {@link HashedWheelTimeout}s for the given {@code deadline}.
* 遍历双向链表中的全部 HashedWheelTimeout 节点。 在处理到期的定时任务时,会通过 remove() 方法取出,
* 并调用其 expire() 方法执行;对于已取消的任务,通过 remove() 方法取出后直接丢弃;对于未到期的任务,
* 会将 remainingRounds 字段(剩余时钟周期数)减一。
*/
void expireTimeouts(long deadline) {
HashedWheelTimeout timeout = head;

// process all timeouts
while (timeout != null) {
HashedWheelTimeout next = timeout.next;
if (timeout.remainingRounds <= 0) {
next = remove(timeout);
if (timeout.deadline <= deadline) {
// 调用expire 内部会执行定时任务的run方法
timeout.expire();
} else {
// The timeout was placed into a wrong slot. This should never happen.
throw new IllegalStateException(String.format(
"timeout.deadline (%d) > deadline (%d)", timeout.deadline, deadline));
}
} else if (timeout.isCancelled()) {
// 取消直接remove
next = remove(timeout);
} else {
// 未到期任务的remainingRounds - 1
timeout.remainingRounds--;
}
timeout = next;
}
}

时间轮一次转动的流程:

  • 时间轮转动,时间周期开始
  • 清理用户主动取消的任务,会记录在cancelledTimeouts队列中,在每次指针转动的时候,时间轮也会清理该队列。
  • 将缓存在timeouts队列中的定时任务转移到时间轮对应的槽中。
  • 根据当前指针定位槽,遍历双向链表,来执行对应的任务,方法实现在HashedWheelBucket.expireTimeouts方法中:
    • 循环遍历双向链表,当定时任务的remainingRounds小于等于0,则说明是当前时钟周期内的任务,判断是否达到了deadline(满足时间的触发,兜底判断,一般在时钟周期内都会满足),如果满足调用expire()方法执行任务,内部会调用TimerTask的run方法,即真正的定时任务的逻辑。
    • 如果用户取消,则直接remove()从链表上摘除。
    • 继续遍历下一个Timeout节点。
  • 时间轮一直是运行状态,则重复上述轮询的操作;如果时间轮为停止状态,则遍历每个槽位,来清除注册的定时任务。最后再清理cancelledTimeouts队列中用户取消的任务。

Dubbo中时间轮的应用

客户端的超时检查

客户端发起调用时会创建一个DefaultFuture,用于发起远程调用且阻塞同步等待结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// DefaultFuture的newFuture方法
public static DefaultFuture newFuture(Channel channel, Request request, int timeout, ExecutorService executor) {
final DefaultFuture future = new DefaultFuture(channel, request, timeout);
future.setExecutor(executor);
// ThreadlessExecutor needs to hold the waiting future in case of circuit return.
if (executor instanceof ThreadlessExecutor) {
((ThreadlessExecutor) executor).setWaitingFuture(future);
}
// timeout check
// 创建客户端的超时检查任务 时间轮去定时检查是否超时
timeoutCheck(future);
return future;
}

// timeoutCheck方法
private static void timeoutCheck(DefaultFuture future) {
// 超时时间的检查
TimeoutCheckTask task = new TimeoutCheckTask(future.getId());
future.timeoutCheckTask = TIME_OUT_TIMER.newTimeout(task, future.getTimeout(), TimeUnit.MILLISECONDS);
}

可以看到timeoutCheck方法中创建了一个TimeoutCheckTask(实现了TimerTask接口),然后利用时间轮调用newTimeout加入了一个定时任务。

客户端检查超时公用的时间轮:

1
2
3
4
public static final Timer TIME_OUT_TIMER = new HashedWheelTimer(
new NamedThreadFactory("dubbo-future-timeout", true),
30,
TimeUnit.MILLISECONDS);

TimeoutCheckTask的逻辑:

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
// 超时检查的任务
private static class TimeoutCheckTask implements TimerTask {

private final Long requestID;

TimeoutCheckTask(Long requestID) {
this.requestID = requestID;
}

// 超时检查的逻辑 在达到对应的超时时间触发
@Override
public void run(Timeout timeout) {
// 根据requestId从Future缓存中获取future
DefaultFuture future = DefaultFuture.getFuture(requestID);
//
if (future == null || future.isDone()) {
// 完成正常返回
return;
}

// 否则响应超时 isSent区分是客户端执行超时,还是服务端的超时。
if (future.getExecutor() != null) {
future.getExecutor().execute(() -> notifyTimeout(future));
} else {
notifyTimeout(future);
}
}

private void notifyTimeout(DefaultFuture future) {
// create exception response.
// 客户端在超时之后创建一个超时的返回
Response timeoutResponse = new Response(future.getId());
// set timeout status.
// 根据future的isSent确定状态是客户端响应超时 还是 服务端响应超时
timeoutResponse.setStatus(future.isSent() ? Response.SERVER_TIMEOUT : Response.CLIENT_TIMEOUT);
timeoutResponse.setErrorMessage(future.getTimeoutMessage(true));
// handle response.
// 响应超时
DefaultFuture.received(future.getChannel(), timeoutResponse, true);
}
}

与注册中心交互的失败重试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private void addFailedRegistered(URL url) {
// 如果注册失败 会添加到重试任务到时间轮 进行后面的异步重试
FailedRegisteredTask oldOne = failedRegistered.get(url);
if (oldOne != null) {
return;
}
FailedRegisteredTask newTask = new FailedRegisteredTask(url, this);
oldOne = failedRegistered.putIfAbsent(url, newTask);
if (oldOne == null) {
// never has a retry task. then start a new task for retry.
// Failback 容错。 如果注册失败 启动一个时间轮去异步重试注册节点 (时间轮的一个应用)
retryTimer.newTimeout(newTask, retryPeriod, TimeUnit.MILLISECONDS);
}
}

其中FailedRegisteredTask是TimerTask的实现,代表一个失败重新注册到注册中心的任务,内部就是调用了注册服务的逻辑(比如zk去创建临时节点);retryTimer是一个时间轮,30毫秒去转动指针扫描槽来执行任务。

1
2
3
4
5
6
7
8
9
10
11
// Timer for failure retry, regular check if there is a request for failure, and if there is, an unlimited retry
private final HashedWheelTimer retryTimer;

public FailbackRegistry(URL url) {
super(url);
this.retryPeriod = url.getParameter(REGISTRY_RETRY_PERIOD_KEY, DEFAULT_REGISTRY_RETRY_PERIOD);

// since the retry task will not be very much. 128 ticks is enough.
// 利用时间轮注册重试和注册中心连接的任务
retryTimer = new HashedWheelTimer(new NamedThreadFactory("DubboRegistryRetryTimer", true), retryPeriod, TimeUnit.MILLISECONDS, 128);
}

ParNew和CMS垃圾回收器

发表于 2020-05-21 | 分类于 Java基础 , JVM | 热度: ℃
字数统计: 3,301 | 阅读时长 ≈ 12

垃圾收集算法

分代收集理论

JVM采用分代收集的算法,根据对象存活周期的不同将内存分为几块。堆内存分为新生代和老年代,可以根据各个年代的特点来选择合适的算法。

比如在新生代中,每次收集都会大量对象被回收,所以剩余很少,可以采用标记-复制算法,只需要很少的复制成本就可以完成垃圾收集,所以新生代都是采用复制算法。优点是新生代对象大量都被回收,效率高,复制之后内存可以用指针碰撞移动指针分配新的内存,比freelist方式产生空间碎片好的多。复制算法的缺点就是空间利用率会降低,S区只能有一块区域存放存活对象,但是一般Survivor区不会设置太大;还有个缺点就是如果分代年龄阈值设置过大可能会经历多次复制才能晋升到老年代。

而在老年代中,对象存活几率比较大,而且没有额外的空间可以再去晋升或者担保,所以必须选择标记-清除、标记-整理等算法进行垃圾回收。 这种算法比复制算法要慢10倍以上。

Remember Set和cardtable卡表

在新生代做GCRoots可达性扫描过程中可能碰到跨代引用,比如老年代引用了新生代的对象,而如果直接去老年代扫描效率很低,引入Remember Set来记录非回收区到收集区的指针集合,避免把整个老年代加入到扫描的范围。 在很多垃圾收集器都有这样的问题,而Remember Set这样的结构就是解决跨代引用的问题。

cardtable卡表是RemembeSet的具体实现,卡表比如在新生代记录一个字节数组byte[],再将老年代划分多个区域(卡页),字节数组记录对应老年代每个区域是否有跨代引用和地址,如果存在跨代引用,则叫做脏页,这样GC时可以通过卡表只扫描对应脏页内的对象,增加了扫描效率。

image-20220701104506482

标记-复制算法

为了解决效率问题,复制算法出现。将内存划分为大小相同的两块,每次只能用其中的一块。(联想年轻代中的Survivor区)。当这一块内存使用完之后,将GC之后存活的对象复制到另一块,把使用过的空间一次性清理掉,效率高,每次可以直接对一半内存进行直接回收。但是空间利用率低。好在年轻代大多数对象生命周期很短,所以每次GC之后的存活对象很少,所以对Survivor两块区域不需要占用年轻代过大的比例。
image-20220701104515230

标记-清除算法

两个阶段:标记和清除。
标记即为利用GCRoots可达性分析标记存活的对象,统一回收清除未标记(可回收)的对象。
image-20220701104525460

优点:空间利用率高,整块内存都可以使用。
缺点:

  • 效率问题(如果需要标记的对象很多,效率会受影响,且这过程如果伴随STW,对应用程序有影响)
  • 空间问题(在对回收对象清除之后,会产生大量不连续的空间碎片,对下次要求连续内存的分配可能会产生影响)

标记-整理算法

标记过程和标记-清除算法一样,也是标记GCRoots进行可达性分析寻找存活对象,然后在第二部不是直接清除回收垃圾对象,而是先让存活对象向一端移动,整理成连续的内存,然后清理掉剩余的内存。

这个算法标记过程还是可能存在效率问题。但是避免了标记-清除算法中的空间碎片问题,留下的空闲内存都是连续的空间。

image-20220701104535754

垃圾收集器

垃圾收集器是垃圾回收算法的具体实现。主流的垃圾收集器如下:
image-20220701104544488

Serial收集器

Serial(串行)收集器是单线程收集器,在GC时会单线程GC且会STW直到收集结束。

Serial提供了新生代垃圾回收器,采用复制算法,如果使用需要配置参数-XX:+UseSerialGC。同时也提供了老年代的回收器,采用标记-整理算法,需要配置参数:-XX:+UseSerialOldGC。

image-20220701104554689

Serial虽然是单线程的,但是优点是简单且高效(对比其他收集器的单线程版本),比如在单个CPU环境下可能是比较好的选择(不会频繁的上下文切换)。同时Serial Old也会作为CMS垃圾收集器在concurrent mode failure场景下退化的fullgc老年代的垃圾收集器;也可以搭配Parallel Scavenge收集器使用。

ParNew收集器

ParNew是新生代的垃圾回收器,采用复制算法。可以用参数-XX:+UseParNewGC来控制使用ParNew作为新生代的垃圾收集器。如果设置了CMS作为老年代的垃圾收集器,那么默认新生代使用ParNew作为新生代进行搭配。

ParNew就是Serial的多线程版本,有多个线程回收新生代的垃圾对象。同时也是除了Serial之外可以和CMS进行分代配合的收集器,所以很多都选择ParNew+CMS这样的垃圾回收器的组合。

ParNew在进行回收时多个线程并行回收,但是这里还是会STW用户线程的,和Serial一样。可以使用+XX:ParallelGCThreads参数来设置GC线程的个数。其工作过程如下:
image-20220701104614936

垃圾回收器中的并发、并行

在讨论垃圾收集器的上下文语境中,并行和并发如下解释:

  • 并行(parallel):指多个线程并行的去GC,如ParNew新生代垃圾回收器,但是用户线程还是会被暂停STW。
  • 并发(concurrent):指GC线程和用户线程并发执行,不会暂停用户线程的执行,都去分不同的时间片。

Parallel Scavenge/Old收集器

Parallel Scavenge是新生代垃圾回收器,可以使用-XX:+UseParallelGC参数指定使用。其也是采用复制算法、多线程进行parallel的gc。

工作过程和ParNew的多线程进行GC,STW用户线程一致,但在设计上有点区别。

而Parallel Old则是多线程GC的老年代垃圾回收器,采用的是标记-整理垃圾回收算法,同时也是更注重吞吐量,而不是尽力缩短GC的停顿时间。

GC的停顿时间的缩短是牺牲吞吐量和新生代空间来换取的:比如新生代调整小一些,收集肯定更快发生,这样新生代的GC会更频繁。比如原来每10s回收一次新生代,每次停顿100ms,现在就变成了每5s一次新生代回收,每次停顿70ms,吞吐量降低了,但是每次停顿时间变少了。

Parallel Scavenge和ParNew的区别

  • Parallel Scavenge更追求吞吐量(即CPU运行用户线程时间 / (CPU运行用户线程时间 + CPUGC的时间)),对后台计算和CPU敏感型的任务更友好。而ParNew等其他收集器更注重GC的停顿时间,即减少STW时间,对和用户交互的程序更友好。
  • Paralle Scavenge只能和Serial Old或者Parallel Old老年代的回收器搭配使用,不能和CMS搭配使用。而ParNew是可以和CMS搭配使用的。
  • Parallel Scavenge可以自适应调整新生代的Eden、Surrvivor区域的比例,参数是-XX:+UseAdaptiveSizePolicy。

CMS垃圾回收器

CMS(Concurrent Mark Sweep)是老年代的垃圾回收器,其目标是获取最短停顿时间。也是HotSpot实现的真正意义上并发(Concurrent注意不是并行,意味着不会停顿用户线程)收集器,实现了在一定阶段让用户线程和GC线程一起工作。

CMS采用标记-清除算法,但是内部也提供了在清除之后整理防止出现内存碎片的功能和参数。

CMS的步骤过程

CMS每个阶段的工作过程如下:
image-20220701104628660

CMS一定不会STW用户线程吗?

CMS的并发(不STW用户线程)也只是发生在某些阶段的,而不是整个CMS的过程不会STW。比如初始标记和再次重新标记的过程都是会STW的,只不过这两个阶段会占用整个CMS GC的很小一部分时间,最多的还是在并发标记和并发清理阶段。

同时CMS在并发标记或者并发清理阶段,因为不会STW用户线程,可能又触发了old区的垃圾回收,即一次fullgc,那么会出现“Concurrent mode failure”,即会退化为单线程Serial Old垃圾回收器,会单线程的GC,且也会STW。

初始标记(会STW)

初始标记会STW,其实标记一下和GC Roots直接关联到的对象,速度很快。

并发标记(GC线程和用户线程并发、耗时、标记会有误差)

初始化标记之后,会对GCRoots直接关联的对象进行Traceing,即通过可达性分析去寻找引用链上的对象进行标记,这个过程是并发的,即GC线程和用户线程可以同时执行,不会STW。

当然这个阶段很耗费时间,因为是要寻找对象引用的链。

还有这个阶段因为不会STW用户线程,所以标记的对象可能会多标(比如用户线程已经释放了对象关联的GCroots,可能是栈帧中的局部变量),那么这些就会在这个阶段浮动垃圾;也可能少标(用户线程在可达性分析的完成之后,对象又关联上了GCroots的引用)。这部分依赖底层三色标记的算法和后面重新标记的过程来修正或者直接当做浮动垃圾(多标的场景)

预清理阶段

理解CMS回收器的preclean阶段:https://blog.csdn.net/enemyisgodlike/article/details/106960687。
主要是为了后面重新标记而提前清理cardTable(记录跨代引用),和调整最终重新标记的时机。

重新标记(会STW)

重新标记就是为了修正并发标记阶段因为用户线程继续运行导致标记产生变动的对象,这个阶段的时间一般会比初始标记的时间长,但远比并发标记阶段时间短。重新标记阶段会STW(肯定的不然又多标记或者漏标),依赖底层三色标记算法的增量更新算法(JVM中的赋值写屏障)。

并发清理(GC清理和用户线程并发执行)

开启用户线程,和GC线程对未标记(垃圾对象)进行清理,如果设置了整理压缩内存的参数再去整理内存,这个过程对于三色标记法中标记黑色的对象不进行处理。

并发重置

重置对象的一些信息,方便下次GC时重新标记。

CMS总结

优点: 分成多个阶段,其中STW的时间占用很少,最大限度的减少了STW停顿时间,多线程和GC线程进行并发标记和并发清除,加快了效率。

缺点:

  • 对CPU资源敏感。设置GC线程不当或者CPU资源紧张时,多个GC线程切换可能会抢占用户线程资源,使得应用总吞吐量变低,负载变高。
  • CMS在整个过程中会产生浮动垃圾,浮动垃圾即并发标记或者并发清理过程中用户线程可能产生的新的垃圾对象。这部分可以等待下一次gc再进行清理。(因为存在重新标记的过程,这部分浮动垃圾会比较少)
  • CMS因为产生了浮动垃圾,且参数-XX:CMSInitiatingOccupancyFraction参数(老年代占用多少启动CMS GC)默认为92,假设在CMS过程中因为用户线程并发,预留的内存空间不足以容纳程序需要,则会出现Concurrent mode failure,即再进行一次full gc,此时CMS也会退化为Serial Old,即单线程回收,整个过程都STW,效率变得很低。
  • CMS本身采用“标记-清除”算法,这个算法本身可能导致大量的空间碎片,因为用户线程并发,在清理之后可能无法容纳新晋升的大对象的连续内存导致FullGC。JVM提供了清除之后压缩的两个参数:-XX:+UseCMSCompactAtFullCollection(在CMS之后开启压缩整理合并碎片)、-XX:CMSFullGCsBeforeCompaction(执行多少次CMS FullGC之后才进行压缩整理,默认为0,代表每次CMS之后都会整理碎片空间,因为碎片压缩整理也会STW,所以提供了这个值)。

CMS相关核心参数

image-20220701104646352

JVM对象创建与内存分配

发表于 2020-05-20 | 分类于 Java基础 , JVM | 热度: ℃
字数统计: 3,450 | 阅读时长 ≈ 12

JVM对象创建的流程

image-20220701104241903

1. 类加载的检查

new指令可以是new对象、对象克隆、对象序列化等。

虚拟机会检查常量池中是否能定位到类的符号引用(字面量),以及这个类是否被类加载器加载、校验、准备、解析、初始化等工作。如果没有,则进行类加载的工作。

2. 分配内存

从堆中划分一块区域为对象分配内存。
划分有两种方式:

  • 指针碰撞(默认使用):内存是规整的,用过的内存放一边,空闲的再另一边,中间有个指针作为分界点,那么分配内存就是移动指针向空闲那边分配内存。
  • 空闲列表(Free List):内存不是规整的,空闲和已使用的交错,这时维护一个空闲列表来存空闲内存块,划分一块区域给对象实例。

这其中的并发问题解决:

  • CAS失败重试,对分配的空间做同步处理
  • 本地线程分配缓存(TLAB):每个线程划分自己的一块缓冲区,-XX:+UseTLAB来控制大小,这样避免并发问题。

3. 初始化

对分配到的内存初始化零值,这时成员变量字段会初始化为默认值,此时其实已经可以被访问到。

4. 设置对象头

初始化零值之后,JVM对对象设置对象头。

JVM一个对象有三部分组成:
对象头、实例数据、对齐补齐的区域(要求是8字节的倍数)
image-20220701104253896

对象头是存放比如这个对象是哪个类的实例(KClass对象的类型指针)、MarkWord(一些记录信息比如hashcode、GC分代年龄、偏向锁id、锁标志、线程持有锁、偏向锁时间戳等)、数组长度(如果是数组对象)

5. 执行方法

对成员变量进行赋值,且执行类的构造方法。对应着助记符invokespecial,比如下面代码对应的助记符:

1
2
3
4
5
6
7
8
9
10
11
public static void main(String[] args) {
Math math = new Math();
}


public static void main(java.lang.String[]);
Code:
0: new #2 // class jvm/内存区域/Math
3: dup
4: invokespecial #3 // Method "<init>":()V 执行init方法
5: return

java对象的指针压缩

在64位操作系统中支持JVM的指针压缩。jvm配置参数:-XX: +UseCompressedOops。其中compressed–压缩、oop(ordinary object pointer)–c++中的对象指针。

为什么要进行指针压缩

  1. 在64位操作系统使用32位指针,内存使用会多1.5倍左右。比如32位的操作系统支持寻址内存最大是4g,而一个对象假设在64位操作系统中只需要33位来(即8g,夸张的假设),这里可以用压缩算法来存储对象,即指针压缩之后用4个字节就能表达对象,在真正使用时解压使用。
  2. 指针压缩的好处是减少对象大小,能承载更多的对象,减少GC的压力,且复制对象数据也更节省效率。即用4个字节即32位地址就能支持更大的内存。
  3. 当然对堆的大小有要求,小于4g是不需要指针压缩。jvm用去除高32位地址,即使用低虚拟地址空间;大于32g时压缩指针会失效,强制使用64位来进行寻址,所以也不建议堆内存特别大。

对象内存分配的细节

对象内存分配的流程:
image-20220701104309765

逃逸分析、标量替换和对象栈上分配

大多数对象都是在堆上进行分配,对象没有被GCROOTS引用时依靠GC进行回收内存。但也不全是在堆上分配。JVM通过逃逸分析可以确定对象不会在外部访问,即不会发生逃逸即可在栈上分配,这样对象随着栈帧出栈而销毁,不需要进行GC。

逃逸分析的JVM参数:-XX:+DoEscapeAnalysis开启逃逸分析。

标量替换:通过逃逸分析确定对象不会被外部访问,会进一步尝试将对象进行标量(基本数据类型)的替换,将整个对象里的成员拆分为标量在栈帧或者寄存器上分配,这样不需要一大块内存来存放内存在栈上。 对应的JVM参数是:-XX:+EliminateAllocations。

demo:

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
/**
* 逃逸分析和标量替换带来的栈上分配的优化
* 调用1亿次 大约需要1GB的内存 15m肯定会发生GC
*
* 堆大小20m 开启逃逸分析和标量替换 栈上分配对象 所以不会大量GC
* -Xms20M -Xmx20M -Xmn10M -XX:+PrintGC -XX:+DoEscapeAnalysis -XX:+EliminateAllocations
*
* 下面这两种因为没有进行栈上分配 所以会大量GC
*
* 关闭逃逸分析
* -Xms20M -Xmx20M -Xmn10M -XX:+PrintGC -XX:-DoEscapeAnalysis -XX:+EliminateAllocations
* 关闭标量替换
* -Xms20M -Xmx20M -Xmn10M -XX:+PrintGC -XX:-DoEscapeAnalysis -XX:-EliminateAllocations
*/
public class AllocateInStackDemo {

public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0;i < 1000000000;i++) {
alloc();
}
long end = System.currentTimeMillis();
System.out.println("运行完毕" + (end - start));
}

private static void alloc() {
User user = new User();
// user只有标量变量 可以直接替换
user.setId(1);
user.setName("aaa");
}

}

在Eden区分配

  • MinorGC/YoungGC:年轻代(新生代)发生的垃圾收集动作,MinorGC非常频繁,回收速度也很快,应用中大多数对象都是在年轻代通过MinorGC被回收的。
  • MajorGC/FullGC:会回收年轻代、老年代、方法区(元空间)的垃圾。FullGC的速度比较慢,一般比MinorGC慢上10倍左右。

一般什么场景下会发生fullgc?

  • 老年代内存不足
  • 方法区(元空间)内存不足
  • System.gc()调用时
  • 老年代担保分配场景中,担保失败的场景(promotion failture),年轻代对象总和或者每次MinorGC之后晋升老年代的平均对象大小 > 老年代剩余空间,则会进行一次fullgc来回收老年代的对象,来担保能晋升到老年代。
  • CMS垃圾回收器使用在并发标记或者并发清理阶段,产生的浮动垃圾导致没有足够的空间分配新的对象,出现Concurrent mode failure,会触发再一次FullGC清理堆空间,CMS也退化为Serial Old单线程垃圾收集器。

在堆内存的年轻代的Eden区分配对象才是大多数对象内存分配的途径。当Eden区没有足够空间分配对象,JVM会发起一次MinorGC。

对象新创建之后会分配在Eden区(没有超过大对象直接老年代分配的阈值),eden不满足大小会触发一次minorGC,剩余存活的对象会复制到Survivor区域(S0或者S1),下一次eden区满了之后会再次触发minorGC,会回收eden和survivor所有垃圾对象,把剩余存活的对象再一次复制到另一块空的Survivor区域。

年轻代中Eden和S0、S1区默认8:1:1。这里因为新生代的对象都是很快消亡的(可能就是一次请求),所以Eden区尽量的大,而Survivor区是用来存储GC之后存活的复制对象的,够用即可。JVM参数-XX:+UseAdaptiveSizePolicy(默认开启)来自适应Eden和Survivor的比例。

大对象直接进入老年代

JVM参数 -XX:PretenureSizeThreshold设置了直接进入老年代分配内存的对象大小, 控制了大对象(需要大量连续内存空间,比如字符串、数组)避免在年轻代中的Eden和S区中的复制算法降低效率。这个参数在Serial和ParNew两个垃圾收集器下有效。

比如设置:

1
-XX:PretenureSizeThreshold=1000000(单位是字节) -XX:+UseSerialGC

大于设置值大小的对象会直接分配至老年代的内存。

长期存活的对象进入老年代

每个对象在对象头的MarkWord中记录了对象的GC分代年龄,对象在Eden分配完经历第一次MinorGC之后还存活,且能被Survivor容纳,将会被移动到Survivor区域(S0区),并设置对象的GC分代年龄为1。在Survivor区每经过一次MinorGC年龄都会+1。JVM参数:-XX:MaxTenuringThreshold来控制晋升到老年代的阈值。

对象动态年龄判断

在MinorGC之后,会触发一个动态年龄判断机制。即MinorGC之后Survivor区有一批对象(剩余存活的),年龄1+年龄2+年龄3+…+年龄n的多个年龄对象总和超过了Survivor区的50%,此时会把年龄n以上的对象直接放入到老年代,这个规则其实就是希望长期存活的大对象提前进入到老年代,而不是在年轻代多次复制之后达到阈值再进入老年代。

老年代空间分配担保机制

年轻代每次MinorGC之前都会计算下老年代的剩余空间。如果这个可用空间小于年轻代所有对象(包括未清理的垃圾对象)就会依赖一个JVM参数:-XX:-HandlePromotionFailure(jdk1.8默认设置了)的参数是否设置了,如果设置了,就会判断老年代当前可用内存大小,是否大于之前每一次MinorGC之后进入老年代的对象的平均大小,如果上一步结果是小于或者参数没有设置,则触发一次fullgc,让老年代和年轻代一起进行一次GC,如果回收完还是没有足够的空间存放新的对象则会发生OOM。

当然如果没有触发老年代空间分配担保机制,MinorGC之后剩余空间需要挪到老年代的对象还是大于可用空间,也会触发full gc,full gc之后如果还不够就会触发OOM。

整个过程如下:
image-20220701104323541

对象内存回收

引用计数法

每个对象维护一个引用计数器,每当有一个地方引用其对象,计数器加1。计数器为0时,对象可以被回收。这个方法效率高,但是存在循环引用的场景,很难去解决。所以主流JVM没有采用这个算法来确定无用的对象。

可达性分析算法

以GCROOTS为起点,从这些对象节点向下搜索引用的对象,能连通找到的对象都是非垃圾对象,其余未被标记的都是垃圾可回收对象。

image-20220701104351339

能作为GCRoots的根节点:

  • 线程栈的本地变量(局部变量)引用的对象。
  • 本地方法(native方法)引用的对象。
  • 元空间中类的静态变量。
  • 元空间中常量引用的对象。

java中的引用

常见的强引用就是内存中存在一块内存区域存放对象的地址值,则这个对象被内存地址引用。而其他引用出现的背景是当需要描述一些这些引用:内存足够的时候存活,内存不足够的时候可以被GC,许多缓存的场景可以用这些引用。

强引用

这个没啥说的就是 object = new Object,只要强引用在,GC不会手机回收被引用的对象。

软引用

SoftReference是描述有用但是不是必须的对象。在GC之后还没有足够可用的内存,会回收软引用的对象,如果还没有足够的空间会抛出内存溢出异常。

使用场景可以想想浏览器前进后退保留的页面内容,如果GC内存不足清除,再重新加载即可,不需要一直保留缓存。

弱引用

WeakReference是弱引用,描述非必须对象,弱引用关联的对象只生存到下一次GC之前,无论是否内存足够,都会在GC时回收只有弱引用的对象。应用可以看看ThreadLocal中实现的ThreadLocalMap。

虚引用

最弱的一种引用关系,虚引用不影响对象存活,也无法通过引用获取到对象实例。在GC时会收到一个通知,PhantomReference一个应用场景是jdk里DirectByteBuffer被回收之后,Cleaner会把分配的堆外内存释放了;netty里用来探测内存泄漏。当堆内的对象被GC时收到通知可以对堆外内存进行手动调用Unsafe进行回收。

finalize()方法

对象宣告死亡需要两次标记的过程:
(1)GCRoots可达性分析连通不到之后,即没被标记不可清除对象
(2)没有覆盖finalize()方法或者已经执行过finalize()方法

这种才会真正GC。

在覆盖finalize()方法中如果把当前this引用关联上别的静态变量或者成员变量,则会逃过之后的GC,相当于给了一次逃亡的机会。这个方法不建议去覆盖。

元空间的回收

元空间主要是对常量池和无用类的回收。

确定废弃常量池中的字面量也是看有没有对象引用常量池中的字面量,可以对常量进行废弃回归。

而无用类的确定十分苛刻:

  • 该类对应的实例都被回收,堆中没有该类对应的对象实例。
  • 加载该类的ClassLoader被回收。
  • 对应的Class对象都没被使用,没有反射调用该类的方法。

满足这三个条件才会进行元空间中无用类的回收。且对于频繁动态代理、反射、动态JSP的系统,因为运行中动态生成类或者替换类加载器,要关注元空间的类卸载情况,避免元空间溢出。

dubbofilter的路径问题

发表于 2020-05-20 | 分类于 dubbo | 热度: ℃
字数统计: 429 | 阅读时长 ≈ 2

自定义dubbofilter

在使用dubbo框架的时候可以使用filter去实现一些拦截功能和调整拦截顺序,在每次调用的过程中,Filter的拦截都会被执行。当然除了Dubbo默认的filter,用户也可以自定义dubbo filter来实现对应的功能。这里记录一个遇到的spi文件路径问题。

问题现象

在测试自定义一个dubbo filter之后,发现并没有生效。

对应的filter代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Activate(group = Constants.PROVIDER, order = Integer.MIN_VALUE)
public class HelloFilter implements Filter {

/**
* @param invoker
* @param invocation
* @return
* @throws RpcException
*/
@Override
public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {
System.out.println("filter调用了");
return invoker.invoke(invocation);
}
}

对应的spi文件内容:

1
dubboLoggerFilter=com.xxx.demo.xxx.filter.HelloFilter

经过排查之后发现代码写的没啥问题,但dubbo并没有加载这个自定义filter的spi。隐约感觉是路径问题。

问题解决

因为是对应一个已生效的filter去设置的,所以当时看到已有项目中的文件是这样的:

image-20200520204641035

所以在建立spi文件的文件目录时直接new了一个目录名字叫做META-INF.dubbo。但其实看官方blog中的介绍是要在maven资源文件下建立如下的结果的spi文件:

image-20200520204834979

这里恍然大悟才发现自己的目录路径建立错误了。

这里其实是idea展示的一个坑,比如我在现在改对的基础上去建立META-INF.dubbo目录,其实是和正确目录展示是一样的:

image-20200520205113150

但是在你提交git文件的时候,其实是能明显看到对应的差别的:

image-20200520205152514

这里记录下踩到的这个坑。

dubboSPI的实现

发表于 2020-05-20 | 分类于 dubbo | 热度: ℃
字数统计: 3,846 | 阅读时长 ≈ 17

dubbo的spi概述

采用spi是为了更好的达到OCP原则(对扩展开放,对修改封闭)。dubbo采用微内核+插件的架构。内核部分功能稳定,面向功能的可拓展性实现都是由插件来完成的,内核只是管理插件和应用插件实现。这样更灵活。

dubbo就是采用spi来加载插件的。

SPI原理

jdk中的spi

使用

需要在resource目录下的META-INFO/services下新建对应SPI接口名称为名字的文件,然后将实现类的全限类名作为文件内容。

其文件内容:

然后利用ServiceLoader接口去加载和使用对应的spi接口即可。

1
2
3
4
5
6
7
8
9
10
11
12
public class 测试jdk_spi {

@Test
public void testJdkSPI() {
ServiceLoader<IShot> serviceLoader = ServiceLoader.load(IShot.class);
Iterator<IShot> iterator = serviceLoader.iterator();
while (iterator.hasNext()) {
IShot next = iterator.next();
System.out.println(next.shot());
}
}
}

输出:

1
2
i am a dog
i am a cat

原理

ServiceLoader接口在调用load时,会创建一个ServiceLoader对应的实例,其中维护了一个providers变量,是一个LinkedHashMap,其会将spi文件中的每个接口实现的名称作为key,具体实例化的实现作为value存储,并且会生成一个LazyIterator作为迭代器的实现。

在调用ServiceLoader迭代器的hasNext和next方法时,会调用到上边的Lazy迭代器,其就是去读取配置文件中的内容,保存到providers中。

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
private S nextService() {
if (!hasNextService())
throw new NoSuchElementException();
String cn = nextName;
nextName = null;
Class<?> c = null;
try {
c = Class.forName(cn, false, loader);
} catch (ClassNotFoundException x) {
fail(service,
"Provider " + cn + " not found");
}
if (!service.isAssignableFrom(c)) {
fail(service,
"Provider " + cn + " not a subtype");
}
try {
S p = service.cast(c.newInstance());
providers.put(cn, p);
return p;
} catch (Throwable x) {
fail(service,
"Provider " + cn + " could not be instantiated",
x);
}
throw new Error(); // This cannot happen
}

dubbo中的SPI

dubbo没有直接使用java的SPI来实现自己的插件加载,而是在SPI基础上进行改造。因为java的SPI需要加载文件中所有扩展点的实现,

java SPI是需要加载文件中所有的实现类,会造成资源浪费,且不能动态加载某个实现类
。

而dubbo的spi文件首先拓展了三个目录下:

  • META-INF/services/ 目录:该目录下的 SPI 配置文件用来兼容 JDK SPI 。
  • META-INF/dubbo/ 目录:该目录用于存放用户自定义 SPI 配置文件。
  • META-INF/dubbo/internal/ 目录:该目录用于存放 Dubbo 内部使用的 SPI 配置文件。

其次dubbo的SPI文件的配置改为了KV形式,实现了只加载对应key的值的扩展点具体实现。

配置文件举例:

1
dubbo=org.apache.dubbo.rpc.protocol.dubbo.DubboProtocol

这时因为扩展点的key:extensionName为dubbo,所以找到DubboProtocol这个Protocol接口的实现。

1. @Spi注解

Dubbo中接口用@SPI注解注释时,标注为扩展点接口,其value值的作用是在加载Protocol接口实现时,如果没有明确指定扩展名,则取value值作为扩展名去加载spi文件中对应的实现类。

ExtensionLoader如何处理@SPI注解

ExtensionLoader是SPI实现的核心工具类,对于每个扩展点接口都会有一个ExtensionLoader实例。同时还有一些静态字段作为缓存加载过的扩展点实现。

静态字段:

  • strategies(LoadingStrategy[]类型):LoadingStrategy接口有三个实现,对应是三个SPI配置文件的加载路径。其也都实现了优先级接口,优先级为:

    1
    DubboInternalLoadingStrategy > DubboLoadingStrategy > ServicesLoadingStrateg
  • EXTENSION_LOADERS (ConcurrentHashMap<Class<?>, ExtensionLoader<?>>类型):表示type类型对应的extensionLoader实例映射缓存。

  • EXTENSION_INSTANCES:表示扩展实现类和其实例对象的映射

实例字段:

  • type:当前的ExtensionLoader实例负责加载的扩展接口
  • objectFactory:所属的对象工厂,注入所依赖的其他扩展点接口时所用
  • cachedDefaultName(String类型):默认扩展名。即@SPI接口的value值
  • cachedNames (ConcurrentHashMap<Class<?>, String>类型):缓存了该ExtensionLoader实例加载的扩展实现类与扩展名之间的映射关系。
  • cachedClasses (Holder<ConcurrentHashMap<String, Class<?>>>类型):缓存了扩展点名称和扩展点实现类之间的映射关系
  • cachedInstances (ConcurrentMap<String, Holder>类型):缓存了该ExtensionLoader实例加载的扩展名与扩展实现对象之间的映射关系。
  • cachedAdaptiveInstance:缓存了adaptive扩展点实例
  • cachedAdaptiveClass:缓存该extensionLoader加载过程中直接标注@Adaptive注解的扩展实现类
  • cachedWrapperClasses:缓存该extensionLoader加载过程中的包装类wrapper实现

ExtensionLoader.getExtensionLoader() 方法会创建对应的ExtensionLoader对象:

1
2
3
4
5
6
7
8
9
10
public static <T> ExtensionLoader<T> getExtensionLoader(Class<T> type) {
// 从缓存中找
ExtensionLoader<T> loader = (ExtensionLoader<T>) EXTENSION_LOADERS.get(type);
if (loader == null) {
// 如果缓存为null 则初始化一个 再放入缓存
EXTENSION_LOADERS.putIfAbsent(type, new ExtensionLoader<T>(type));
loader = (ExtensionLoader<T>) EXTENSION_LOADERS.get(type);
}
return loader;
}

这里的new ExtensionLoader就是初始化type和objectFactory两个字段。

初始化完ExtensionLoader实例之后,可以根据getExtension方法用name加载扩展点:

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
public T getExtension(String name) { 
// getOrCreateHolder()方法中封装了查找cachedInstances缓存的逻辑
Holder<Object> holder = getOrCreateHolder(name);
Object instance = holder.get();
if (instance == null) { // double-check防止并发问题
synchronized (holder) {
instance = holder.get();
if (instance == null) {
// 根据扩展名从SPI配置文件中查找对应的扩展实现类
instance = createExtension(name);
holder.set(instance);
}
}
}
return (T) instance;
}

// getOrCreateHolder方法:
private Holder<Object> getOrCreateHolder(String name) {
Holder<Object> holder = cachedInstances.get(name);
if (holder == null) {
cachedInstances.putIfAbsent(name, new Holder<>());
holder = cachedInstances.get(name);
}
return holder;
}

看到是调用了createExtension(name) 来实例化扩展点实现类:

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
/**
* 获取 cachedClasses 缓存,根据扩展名从 cachedClasses 缓存中获取扩展实现类。
* 如果 cachedClasses 未初始化,则会扫描前面介绍的三个 SPI 目录获取查找相应的 SPI 配置文件,
* 然后加载其中的扩展实现类,最后将扩展名和扩展实现类的映射关系记录到 cachedClasses 缓存中。
* 这部分逻辑在 loadExtensionClasses() 和 loadDirectory() 方法中。
*
* 根据扩展实现类从 EXTENSION_INSTANCES 缓存中查找相应的实例。如果查找失败,会通过反射创建扩展实现对象。
*
* 自动装配扩展实现对象中的属性(即调用其 setter)。这里涉及 ExtensionFactory 以及自动装配的相关内容,
*
* 自动包装扩展实现对象。这里涉及 Wrapper 类以及自动包装特性的相关内容
*
* 如果扩展实现类实现了 Lifecycle 接口,在 initExtension() 方法中会调用 initialize() 方法进行初始化。
* @param name
* @return
*/
@SuppressWarnings("unchecked")
private T createExtension(String name) {
// 获取扩展名对应的扩展实现类
Class<?> clazz = getExtensionClasses().get(name);
if (clazz == null) {
throw findException(name);
}
try {
// 从扩展实现类和其实例对象中获取
T instance = (T) EXTENSION_INSTANCES.get(clazz);
if (instance == null) {
// newInstance放入缓存中
EXTENSION_INSTANCES.putIfAbsent(clazz, clazz.newInstance());
instance = (T) EXTENSION_INSTANCES.get(clazz);
}
// 处理依赖的其他扩展点实现 调用了setter方法
injectExtension(instance);

// 实现wrapper包装
Set<Class<?>> wrapperClasses = cachedWrapperClasses;
if (CollectionUtils.isNotEmpty(wrapperClasses)) {
for (Class<?> wrapperClass : wrapperClasses) {
// 遍历全部wrapper类包装到当前的扩展点实现
instance = injectExtension((T) wrapperClass.getConstructor(type).newInstance(instance));
}
}
// 初始化instanced对象 如果扩展点实现了LifeCycle接口的话
initExtension(instance);
return instance;
} catch (Throwable t) {
throw new IllegalStateException("Extension instance (name: " + name + ", class: " +
type + ") couldn't be instantiated: " + t.getMessage(), t);
}
}

2. @Adaptive注解和适配器

@Adaptive表示自适应的扩展点实现。

  • 当@Adaptive注解在类上注解时,表示此类为扩展实现,在SPI中使用并不多,只使用在了ExtensionFactory接口上。

可以看到ExtensionFactory接口的自适应实现AdaptiveExtensionFactory即为ExtensionLoader.getAdaptiveExtension()方法的返回值,即注解在类上即为自适应实现,且会缓存在ExtensionLoader实例中的cachedAdaptiveClass变量中。

AdaptiveExtensionFactory内部逻辑也比较简单,即根据注入的其他两个ExtensionFactory具体实现去加载对应的扩展点实现。一个是Dubbo SPI自适应扩展点实现加载,一个是Spring上下文获取bean。

  • 当@Adaptive注解在方法上时,Dubbo会动态代理生成Adaptive实现类(比如Transporter$Adaptive),此动态代理类也会实现扩展点接口。

代理类中的逻辑也是根据@Adaptive注解中值作为从url获取扩展名称的key,然后再根据ExtensionLoader获取扩展实现类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Transporter$Adaptive implements Transporter { 
public org.apache.dubbo.remoting.Client connect(URL arg0, ChannelHandler arg1) throws RemotingException {
// 必须传递URL参数
if (arg0 == null) throw new IllegalArgumentException("url == null");
URL url = arg0;
// 确定扩展名,优先从URL中的client参数获取,其次是transporter参数
// 这两个参数名称由@Adaptive注解指定,最后是@SPI注解中的默认值
String extName = url.getParameter("client",
url.getParameter("transporter", "netty"));
if (extName == null)
throw new IllegalStateException("...");
// 通过ExtensionLoader加载Transporter接口的指定扩展实现
Transporter extension = (Transporter) ExtensionLoader
.getExtensionLoader(Transporter.class)
.getExtension(extName);
return extension.connect(arg0, arg1);
}
... // 省略bind()方法
}

以上这两种为自适应的适配器实现。获取适配器的代码为getAdaptiveExtension()方法:

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
public T getAdaptiveExtension() {
// 从缓存中取
Object instance = cachedAdaptiveInstance.get();
if (instance == null) {
if (createAdaptiveInstanceError != null) {
throw new IllegalStateException("Failed to create adaptive instance: " +
createAdaptiveInstanceError.toString(),
createAdaptiveInstanceError);
}

synchronized (cachedAdaptiveInstance) { // double check
instance = cachedAdaptiveInstance.get();
if (instance == null) {
try {
// 创建自适应的扩展点实现
instance = createAdaptiveExtension();
// 缓存
cachedAdaptiveInstance.set(instance);
} catch (Throwable t) {
createAdaptiveInstanceError = t;
throw new IllegalStateException("Failed to create adaptive instance: " + t.toString(), t);
}
}
}
}

// createAdaptiveExtension方法中调用的getAdaptiveExtensionClass方法
private Class<?> getAdaptiveExtensionClass() {
// 触发loadClass 内部如果有直接加了@Adaptive注解的扩展点实现,则会维护到cacheAdaptiveClass变量中
getExtensionClasses();
if (cachedAdaptiveClass != null) {
return cachedAdaptiveClass;
}
// 动态代理类选择出的扩展点实现也维护在cacheAdaptiveClass变量中
return cachedAdaptiveClass = createAdaptiveExtensionClass();
}

  • 3. 自动包装特性

    扩展点的实现中可能有很多通用逻辑,dubbo SPI中的自动包装特性将多个扩展实现类的公共逻辑抽象到Wrapper类中,Wrapper类和普通扩展点实现一样,也实现了扩展接口,在获取真正的扩展对象时,在外面包一层Wrapper对象,是装饰器模式的实现。

判断是否为Wrapper的实现:

1
2
3
4
5
6
7
8
9
10
private boolean isWrapperClass(Class<?> clazz) {
try {
// 检查是否是wrapper包装方式:是否有当前扩展点接口为参数的构造函数
// wrap类是为了解决多个扩展点实现的公共逻辑
clazz.getConstructor(type);
return true;
} catch (NoSuchMethodException e) {
return false;
}
}

在加载spi文件时,会去缓存当前扩展点的wrapper实现类到一个集合变量中:cachedWrapperClasses,然后在之后遍历wrapper实现包装:

1
2
3
4
5
6
7
Set<Class<?>> wrapperClasses = cachedWrapperClasses;
if (CollectionUtils.isNotEmpty(wrapperClasses)) {
for (Class<?> wrapperClass : wrapperClasses) {
instance = injectExtension((T) wrapperClass
.getConstructor(type).newInstance(instance));
}
}

4. 自动装配特性

自动装配在injectExtension()方法中。其会扫描所有setter方法,并根据setter方法的名称以及参数的类型,加载相应的扩展实现,然后反射调用setter方法填充属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
private T injectExtension(T instance) { 
if (objectFactory == null) { // 检测objectFactory字段
return instance;
}

for (Method method : instance.getClass().getMethods()) {
... // 如果不是setter方法,忽略该方法(略)
if (method.getAnnotation(DisableInject.class) != null) {
continue; // 如果方法上明确标注了@DisableInject注解,忽略该方法
}
// 根据setter方法的参数,确定扩展接口
Class<?> pt = method.getParameterTypes()[0];
... // 如果参数为简单类型,忽略该setter方法(略)
// 根据setter方法的名称确定属性名称
String property = getSetterProperty(method);
// 加载并实例化扩展实现类
Object object = objectFactory.getExtension(pt, property);
if (object != null) {
method.invoke(instance, object); // 调用setter方法进行装配
}
}
return instance;
}

5. @Activate注解和自动激活特性

以Filter接口为例,扩展点的实现非常多,不同场景下需要不同Filter一起执行,根据配置决定哪些场景下哪些Filter自动激活且加入到拦截链中就是@Activate注解的作用。

  • group:是Provider端还是Consumer端的
  • value:修饰的实现类只在URL参数指定key时才会激活
  • order:排序

对@Activate注解的扫描

在loadClass对自动激活的注解进行扫描:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
private void loadClass(){ 
if (clazz.isAnnotationPresent(Adaptive.class)) {
// 处理@Adaptive注解
cacheAdaptiveClass(clazz, overridden);
} else if (isWrapperClass(clazz)) { // 处理Wrapper类
cacheWrapperClass(clazz);
} else { // 处理真正的扩展实现类
clazz.getConstructor(); // 扩展实现类必须有无参构造函数
...// 兜底:SPI配置文件中未指定扩展名称,则用类的简单名称作为扩展名(略)
String[] names = NAME_SEPARATOR.split(name);
if (ArrayUtils.isNotEmpty(names)) {
// 将包含@Activate注解的实现类缓存到cachedActivates集合中
cacheActivateClass(clazz, names[0]);
for (String n : names) {
// 在cachedNames集合中缓存实现类->扩展名的映射
cacheName(clazz, n);
// 在cachedClasses集合中缓存扩展名->实现类的映射
saveInExtensionClass(extensionClasses, clazz, n,
overridden);
}
}
}
}

getActivateExtension方法

在此方法中:

  • 如果传入配置没有-default配置,会触发写入自动激活的缓存。
  • 然后遍历需要自动激活的扩展点接口,如果符合group(provider端或consumer端),并且没有出现在names配置的和被去除的,则加载激活扩展点实现,放入到activateExtensions且sort方法排序。
  • 遍历传入的filter配置(这里和配置文件的顺序保持一致),会处理与default扩展点实现(上一步加载的自动激活扩展点实现)的顺序和配置保持一致。
    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
    public List<T> getActivateExtension(URL url, String[] values, String group) {
    List<T> activateExtensions = new ArrayList<>();
    // names是dubbo配置传入的顺序
    List<String> names = values == null ? new ArrayList<>(0) : asList(values);
    if (!names.contains(REMOVE_VALUE_PREFIX + DEFAULT_KEY)) { // 无-default
    // 触发 cachedActivate缓存字段的加载
    getExtensionClasses();
    for (Map.Entry<String, Object> entry : cachedActivates.entrySet()) {
    String name = entry.getKey(); // 扩展名
    Object activate = entry.getValue(); // @Activate注解

    String[] activateGroup, activateValue;

    if (activate instanceof Activate) {
    activateGroup = ((Activate) activate).group();
    activateValue = ((Activate) activate).value();
    } else if (activate instanceof com.alibaba.dubbo.common.extension.Activate) {
    activateGroup = ((com.alibaba.dubbo.common.extension.Activate) activate).group();
    activateValue = ((com.alibaba.dubbo.common.extension.Activate) activate).value();
    } else {
    continue;
    }
    if (isMatchGroup(group, activateGroup) // 匹配group
    && !names.contains(name) // 没有出现在names中 走默认激活的
    && !names.contains(REMOVE_VALUE_PREFIX + name) // 未在配置中去除的
    && isActive(activateValue, url)) { // 检查url中是否出现指定的key
    // 加载扩展实现的实例对象 这些是不在传入的 names 里的且被去除掉的
    activateExtensions.add(getExtension(name));
    }
    }
    // 对不在filter配置中加载的激活扩展点进行排序
    activateExtensions.sort(ActivateComparator.COMPARATOR);
    }
    List<T> loadedExtensions = new ArrayList<>();
    for (int i = 0; i < names.size(); i++) {
    String name = names.get(i);
    if (!name.startsWith(REMOVE_VALUE_PREFIX) // -开头的不加载 -开头的直接跳过 因为在上边已经过滤了
    && !names.contains(REMOVE_VALUE_PREFIX + name)) {
    if (DEFAULT_KEY.equals(name)) {
    // 这里在default之前的都会放在上边加载过的默认自动激活扩展点之前
    if (!loadedExtensions.isEmpty()) {
    // 按照顺序 把自定义的扩展添加 到 默认扩展集合之前
    activateExtensions.addAll(0, loadedExtensions);
    loadedExtensions.clear();
    }
    } else {
    // 根据扩展名去加载对应的扩展点实现类
    loadedExtensions.add(getExtension(name));
    }
    }
    }
    if (!loadedExtensions.isEmpty()) {
    // 在default之后的会加载到default之后
    activateExtensions.addAll(loadedExtensions);
    }
    return activateExtensions;
    }

比如有如下几个Filter

传入filter配置是为Provider端的:”demoFilter3、-demoFilter2、default、demoFilter1”。
那么最终Filter链的结果是: [demoFilter3, demoFilter6, demoFilter4, demoFilter1]。

JVM内存模型

发表于 2020-04-20 | 分类于 Java基础 , JVM | 热度: ℃
字数统计: 1,173 | 阅读时长 ≈ 4

JVM的整体结构和内存模型

image-20220701103855422

关于方法区和元空间

  • jdk1.8之前存放方法元信息、Class信息、常量池等的为方法区,在堆内存储。而在1.8之后废弃了永久代,替换为元空间,常量池和静态变量并入堆内存储,MetaSpace来存储类、方法元信息,同时使用了本地内存来存储。
  • 相当于1.8版本之后,方法区一部分归到堆内存中,一部分移动到堆外内存,受机器本地内存大小限制。不过设置了Metaspace参数也会在内存不足时OOM。

JVM内存参数设置(简要说明)

运行时数据区的一些基本参数

  • -Xms:堆的最小值参数
  • -Xmx:堆的最大值参数
  • -Xmn:新生代的内存大小
  • -XX: MaxDirectMemorySize:直接内存的最大值。即NIO使用的堆外内存的最大值。 这里注意heap dump不会记录堆外内存的移除,但也可以造成OOM,现象是dump文件很小,即堆内存其实使用很少。
  • -XX:MetaspaceSize:设置元空间触发fullgc的初始阈值(元空间没有固定初始值大小)。默认不设置为21M。到达该值就会触发full gc进行类型卸载,同时收集器会对该值进行调整,如果释放了大量空间,则调小;如果释放了很少的空间,则在不超过-XX: MaxMetaspaceSize的情况下适当提高此值。 在jdk1.8之前的永久代参数-XX: PermsSize参数标识为初始化的容量,1.8之后调整为元空间后不一样
  • -XX: MaxMetaspaceSize:元空间最大值,默认是-1,不限制的话,受限于直接内存大小。
  • -Xss:线程栈的大小(默认为1M)

常量池

Class常量池和运行时常量池

Class文件中包含常量池,也包含字段、方法、接口、类版本等描述信息。常量池用于存放编译期间的字面量和符号引用。

image-20220701103908248

当常量池字面量和符号引用被加载到内存中,符号才会有内存的地址,常量池就是运行时常量池。
而动态链接的过程就是把运行时常量池的符号引用变为直接内存地址引用。

常量池在1.8之后从永久代(元空间)拿出,移动到了堆内存。

字符串常量池

创建字符串对象时,做了一些优化:

  • 如果字符串常量在常量池中存在,返回此常量的地址引用;
  • 如果不存在,则实例化改字符串放入常量池中,是字面量的引用直接返回,是new String()这种则会在堆内新创建一个对象。

字符串操作

  • 字面量直接赋值
    1
    String a = "aaa";

这样会用equals()方法判断字符串常量池是否存在,如果存在返回引用,不存在创建一个放入字符串常量池之后返回引用。

  • new String()复制
    1
    String a = new String("aaa");

这里也会判断字符串常量池和堆中是否有这个对象,没有则都创建,然后返回堆中的对象引用。也就是可能会创建两个对象。

  • intern方法
    1
    2
    String s1 = "aaa";
    String s2 = s1.intern();

一个native方法,调用intern()时如果字符串常量池中存在常量,则返回池中的引用地址,否则将池中的地址直接指向堆中字符串对象(例子是s1)的地址。

常见面试题

image-20220701103933318
image-20220701103954181
image-20220701104015926

关于String是不可变的

除非下面的a、b、c三个变量被final修饰,即变为常量,那么执行String s1 = a + b + c时会编译为常量进行优化,也就是和String s = “a” + “b” + “c” 字面量直接相加一样,都是字符串常量池中常量对象的引用。
image-20220701104030743

为什么是不可变的?

  • 安全。最常用的String内部char[]数组是final的,规避了直接改变对象内容,封装了内部数据。(享元模式)
  • 线程安全,final在要求构造函数中初始化char[]数组
  • hashcode初始化时确定,String很适合作为hash表的key。
彩蛋:比较对象时为什么要重写hashcode()方法

equals()方法相等,则对象一定相等,为什么还要hashcode方法呢?

  • 因为hashcode效率高,一般约束都是equals()方法相等,则hashcode()一定相等;而hashcode()不相等,一定不是一个对象。
  • HashSet这种数据结构是去重的,比较时候为了效率如果hashcode不相等,那么即使equals方法相等也会认为不是一个对象。

JVM类加载机制详解

发表于 2020-04-20 | 分类于 Java基础 , JVM | 热度: ℃
字数统计: 2,963 | 阅读时长 ≈ 12

类加载的过程

通过Java命令执行代码的大体流程:

image-20220701103636576

其中loadClass的过程如下:
加载 -> 验证 -> 准备 -> 解析 -> 初始化 -> 使用 -> 卸载

  • 加载:在硬盘上查找并通过IO读入字节码文件,使用类时才会记载,调用类的main()方法、new类的对象等等。在加载阶段会在内存生成一个代表这个类的java.lang.Class对象,作为方法区这个类各种数据的访问入口。
  • 验证:校验字节码文件的正确性
  • 准备:给类的静态变量初始化内存并复制默认值
  • 解析:将符号引用替换为直接引用,将静态方法(符号引用比如main()方法)替换为指向数据所存内存的指针或者句柄等(直接引用),这种叫静态链接过程(类加载期间完成)。动态链接是在程序运行期间完成的将符号引用替换为直接引用。
  • 初始化:对类的静态变量初始化为指定的值,且加载static代码块。

image-20220701103649868

类被加载到方法区之后主要包含 运行时常量池、类型信息、字段信息、方法信息、类加载器的引用、对应class实例的引用等信息。其中Class对象是开发人员访问方法区中类定义的入口和切入点。

类的初始化

对于类的初始化只有在主动引用的场景下才会进行初始化操作。即类加载了(取决jvm的实现)但类不一定去初始化。主动引用有五种场景:

  • new实例、读取设置类的static字段、调用类的静态方法时
  • 对类进行反射调用时
  • 初始化一个类时发现父类没有初始化,触发父类的初始化
  • main方法调用时要对类进行初始化
  • jdk1.7之后对动态语言支持,MethodHandler实例解析结果句柄对应的类没有初始化时要初始化。

这里注意:

  • 对应static final修饰的常量被调用不会触发主动引用。但是运行时常量是会触发类初始化的,比如下面这样的运行时常量:

    1
    2
    // 运行时常量是特殊的 不会加入到对应类的常量池中 会主动调用常量所在的类 导致类初始化 对应的助记符也是getstatic助记符
    static final String rom = UUID.randomUUID().toString();
  • 引用类型数组new出来不会造成引用类的初始化,这里比较特殊,会JVM自己生成一个数组对象,不算主动引用。

类初始化换个角度来说就是对类所有变量赋值和执行static代码块的过程,即执行类构造器(不是构造函数)()方法的过程。

()方法是由编译器自动收集类所有类变量的赋值动作和静态static块合并产生的,其在执行子类的clinit()方法之前保证父类的clinit()方法已经执行。虚拟机保证对一个类的clinit()方法在多线程 环境下正确的加锁、同步、只有一个线程会执行clinit()方法(所以单例模式中有静态变量初始化这一个例子)。

类加载器和双亲委派机制。

jdk自带的类加载器:

  • 引导类加载器:负责加载支撑JVM运行的位于jre的lib目录下的核心类库,比如rt.jar、charsets.jar等。
  • 扩展类加载器:负责加载支撑JVM允许的位于jre的lib目录下的ext扩展目录中的jar包类
  • 应用程序类加载器:负责加载ClassPath路径下的类包,主要是开发人员写的类。

关于类加载器的一个demo:

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
public class TestClassLoader {
public static void main(String[] args) {
System.out.println(String.class.getClassLoader()); // 输出null,因为引导类加载器是由C++实现的 不是直接在Launcher中的内部类
System.out.println(DESKeyFactory.class.getClassLoader()); // ext扩展类加载器
System.out.println(TestClassLoader.class.getClassLoader()); // app类加载器 加载开发者写在classpath的class


// 看下维护的parent父加载器
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader(); // systemClassLoader默认是app类加载器
ClassLoader extClassLoader = systemClassLoader.getParent();
ClassLoader bootstarpClassLoader = extClassLoader.getParent();// 在逻辑上是引导类加载器 但是引导类加载器是由c++实现的。 这里ext的parent是null
System.out.println("systemClassLoader:" + systemClassLoader);
System.out.println("extClassLoader:" + extClassLoader);
System.out.println("bootstarpClassLoader:" + bootstarpClassLoader);

// 线程上下文保存的类加载器 有些SPI场景需要通过这个来打破双亲委派原则
ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader(); //线程上下文类加载器默认是appClassLoader
System.out.println(contextClassLoader);

// 加载路径 APP类加载器对应 classpath的路径 这里注意也会有rt.jar和ext的包路径 但是因为双亲委派会优先给引导类加载器去加载核心jar包 自己写的类在target下最终会在appClassLoader中加载
System.out.println(System.getProperty("java.class.path"));

}
}


// 输出
null
sun.misc.Launcher$ExtClassLoader@2c7b84de
sun.misc.Launcher$AppClassLoader@18b4aac2
systemClassLoader:sun.misc.Launcher$AppClassLoader@18b4aac2
extClassLoader:sun.misc.Launcher$ExtClassLoader@2c7b84de
bootstarpClassLoader:null
sun.misc.Launcher$AppClassLoader@18b4aac2
省略appClassLoader路径的输出

类加载器初始化过程

AppClassLoader和ExtClassLoader是JVM启动类sun.misc.Launcher的内部类,其都继承了URLClassLoader。在JVM启动时,虚拟机会创建一个Launcher类的实例,且保证全局单例,而在Launcher的构造函数中初始化了ExtClassLoader和AppClassLoader:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
 public Launcher() {
Launcher.ExtClassLoader var1;
try {
// 内部DCL单例创建ExtClassLoader
var1 = Launcher.ExtClassLoader.getExtClassLoader();
} catch (IOException var10) {
throw new InternalError("Could not create extension class loader", var10);
}

try {
// 内部DCL创建AppClassLoader var1(extClassLoader)作为参数传入赋值给parent变量
this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);
} catch (IOException var9) {
throw new InternalError("Could not create application class loader", var9);
}

// 线程上下文加载器
Thread.currentThread().setContextClassLoader(this.loader);
// 省略代码
}

可以看到先初始化了一个extClassLoader,然后将ext作为参数传入了AppClassLoader的构造过程中,其中APP类加载器中的parent父类加载器会赋值为ext类加载器,方便后续的双亲委派。

JVM默认设置线程上下文的类加载器为AppClassLoader。ClassLoader.getSystemClassLoader()返回的也是APP类加载器。

双亲委派机制

由上面的代码可以看到jdk自带的类加载器在逻辑上是有继承父子关系的,而类加载器的加载是存在双亲委派的机制。

双亲委派机制:加载类时会先委托给父类加载器去加载,找不到再委托给上层父类加载器去加载,如果所有父类加载器在自己的加载路径下找不到目标类,则此时才在子类加载器加载目标类。

image-20220701103705295

来看下代码是怎么实现双亲委派机制的:

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
// AppClassLoader.loadClass方法
public Class<?> loadClass(String var1, boolean var2) throws ClassNotFoundException {
int var3 = var1.lastIndexOf(46);
if (var3 != -1) {
SecurityManager var4 = System.getSecurityManager();
if (var4 != null) {
var4.checkPackageAccess(var1.substring(0, var3));
}
}

if (this.ucp.knownToNotExist(var1)) {
Class var5 = this.findLoadedClass(var1);
if (var5 != null) {
if (var2) {
this.resolveClass(var5);
}

return var5;
} else {
throw new ClassNotFoundException(var1);
}
} else {
// 直接委托给super.loadClass方法
return super.loadClass(var1, var2);
}
}

// ClassLoader中的loadClass方法
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
// 检查当前类加载器是否已经加载了该类 如果存在直接返回
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
// 如果此类加载器还有parent类加载器 则委托给父类loadClass
c = parent.loadClass(name, false);
} else {
// parent为null 让引导类加载器去加载 这里相当于extClassLoader的parent是引导类加载器
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}

if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
// 都会调用URLClassLoader的findClass方法在加载器的类路径里查找并加载该类
c = findClass(name);

// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
// 返回类
return c;
}
}

从代码里可以看到,双亲委派就是委托给parent父类加载器变量来load的,如果parent为null则委托给引导类加载器。如果父类加载器加载失败,会回到子类加载器在findClass方法中根据name加载对应的类。如果自定义类加载器想符合双亲委派机制,可以直接重写findClass这个方法,且默认自定义类加载器的父加载器是AppClassLoader,这样自定义类加载器会遵循双亲委派机制来加载类。

双亲委派的优点

  • 沙箱安全机制:自己写的java.lang.String类不会被加载,因为优先委托给引导类加载器来加载,只会加载rt.jar中的核心api,这样可以防止核心API被篡改。
  • 避免类的重复加载:父类加载器已经加载过的类,因为双亲委派不会被加载第二次。保证加载类的唯一性。

双亲委派机制的打破

双亲委派机制本身也存在限制和缺点。双亲委派使得类有了层级划分(跟随类加载器),越基础的类越上层的类加载器去加载,但是如果基础的类要调用用户写的类时(不在加载基础类加载器包路径下),这个模型就不灵活。或者比如在Tomcat这种容器中,想实现不同war包之间的隔离和部分共享,双亲委派机制也不能满足。所以需要打破双亲委派机制。

打破方式:

  • 利用线程上下文类加载器来加载对应类。比如SPI的应用,JNDI或者JDBC。对应JNDI来说,基础的资源查找和管理都在rt.jar中由启动类加载器加载,而其需要加载的spi文件有的在classpath里,这时就依赖线程上下文类加载器来加载,打破了双亲委派机制。
  • 自己实现自定义的类加载器(默认的parent为应用程序类加载器),重写loadClass方法,不委托对应的parent去加载。

Tomcat打破双亲委派的机制

Tomcat作为web容器要解决的问题:

  1. web容器可能要部署不同的war包,要依赖三方包不同的版本,不能要求同一个类只加载了一次,要实现war包之间的隔离。
  2. 可以共享的类在不同的war包也不应该加载10次,有web程序可共享的类。
  3. server自己和web程序依赖的类应该是隔离的,为了安全。
  4. web容器要支持jsp的修改,支持jsp的热更新。

显然,默认的双亲委派机制是支持不聊tomcat这个需求的。第一点和第三点其实都是要多个相同全限定类名的类加载多次实现隔离,这显然不能去双亲委派。而jsp的要求热更新,jsp对应的class文件在加载之后变更,可以卸载jsp的类加载器,再重新去生成一个类加载器去加载对应的jsp文件实现热更新。

在tomcat中有三组目录(/common/、/server/、/shared/*),分别对应所有共享、server容器自己所用、web程序共享,当然每个web程序都有自己的/WEB-INF/目录来存放自己的web应用程序的资源。tomact的类加载器依赖了这些目录的功能,如下:
image-20220701103717270

类加载系列之理解类加载

发表于 2020-04-12 | 分类于 Java基础 , JVM | 热度: ℃
字数统计: 764 | 阅读时长 ≈ 3

类的阶段

在Java代码中,类型的加载、连接和初始化都是在程序运行期完成的。类有以下几个阶段:

  • 类加载:即查找并加载类的二进制数据
  • 类连接:
    • 验证:确保被加载类的正确性
    • 准备:为类的静态变量分配内存,并将其初始化为默认值
    • 解析:类的符号引用转换为直接引用
  • 类初始化:为类的静态变量赋予正确的初始值,运行static快中的代码。
  • 类使用
  • 类卸载
阅读全文 »

缓存设计的常见原则

发表于 2020-04-02 | 分类于 系统设计 | 热度: ℃
字数统计: 224 | 阅读时长 ≈ 1

缓存设计中的一般性原则

  • 热点数据一律进缓存;
  • 缓存场景优先采取本地缓存+分布式缓存的综合方案;
  • 优先读取本地缓存,以本地缓存为主,远端分布式缓存为辅;
  • 所有缓存设置过期时间,本地缓存过期时间控制在秒级;
  • 本地缓存务必同时设置容量驱逐和时间驱逐两种方式,减轻对内存的压力和防止内存泄漏等问题;
  • 缓存KEY具有业务可读性,杜绝不同场景出现相同KEY;
  • 缓存列表数据时,仅缓存第一页,缓存数量不超过20;
  • 杜绝并发更新缓存,防止缓存击穿;
  • 空数据进缓存,防止缓存穿透;
  • 读数据时,先读缓存,再读数据库;
  • 写数据时,先写数据库,再写缓存。

ArrayList拾遗

发表于 2020-03-29 | 分类于 Java基础 | 热度: ℃
字数统计: 2,583 | 阅读时长 ≈ 11

1.ArrayList简介

  • ArrayList底层是object数组,容量能在添加元素的过程中动态扩容。并且在可预知添加大量元素时,调用ensureCapactiy方法提前扩容,减少递增式的扩容次数。

  • 实现了RandomAccess接口,表示可以快速随机访问。根据下标访问。

  • 实现了Cloneable接口,覆盖了函数克隆,不过也是潜拷贝

  • 实现了Serializable接口,支持序列化进行传输

    阅读全文 »
1…456…12
夸克

夸克

愿赌服输

114 日志
32 分类
121 标签
GitHub E-Mail csdn
© 2022 夸克 | Site words total count: 168.9k
|
主题 — NexT.Muse v5.1.4
博客全站共168.9k字

载入天数...载入时分秒...