一:值得借鉴点
1.0 NamesrvController 优雅的关闭
- 注册一个JVM钩子函数,在JVM进程关闭时停止 NamesrvController,释放线程池等资源
public static NamesrvController start(final NamesrvController controller) throws Exception {
// 控制器初始化
boolean initResult = controller.initialize();
// 注册一个JVM钩子函数
Runtime.getRuntime().addShutdownHook(new ShutdownHookThread(log, (Callable<Void>) () -> {
controller.shutdown();
return null;
}));
// 启动 NameServer
controller.start();
return controller;
}
这里可以看到一种释放程序资源的比较优雅的思路,就是向JVM注册一个钩子函数,在JVM进程关闭时回调这个钩子函数,然后就可以去释放进程中的资源,如线程池。
Runtime.getRuntime().addShutdownHook(Thread thread);
2.0 读写锁的运用场景
RocketMQ 中 KVConfigManager 用到了读写锁,相信很多配置中心也有这样的需要,服务中可以动态的读取配置信息KVConfigManager 是一个 KV 键值对配置管理器,它用一个内存 HashMap 结构来存储配置,读取配置时的性能很高。在 load() 加载配置时,可以看到就是读取 ${user.home}/namesrv/kvConfig.json 中的配置内容,然后放到本地内存表 configTable 中。
配置表 configTable 是 HashMap 类型的,那就存在多线程并发问题。可以看到 KVConfigManager 使用 ReentrantReadWriteLock 读写锁来保证并发安全。
在读取配置的的时候加读锁,读锁与读锁兼容,可以并发读取配置。
更新时先加写锁,再更新配置表,写锁与读锁互斥,这期间读将被阻塞。配置表更新完后就释放了写锁,然后再进行persist持久化,持久化主要是将配置表转成json字符串,然后写入磁盘 kvConfig.json 文件中。
可以看到持久化是加的读锁,因为写磁盘一般比写内存要耗时,如果这一步也加写锁,那么写锁阻塞的时间就会更长,阻塞读配置的时间也会更长。这里通过分段加锁的方式,在写内存时加写锁,在写磁盘时加读锁,减小了锁的粒度,提升锁的性能。
public class KVConfigManager {
private final NamesrvController namesrvController;
// 读写锁
private final ReadWriteLock lock = new ReentrantReadWriteLock();
// 存放KV配置
private final HashMap<String/* Namespace */, HashMap<String/* Key */, String/* Value */>> configTable = new HashMap<String, HashMap<String, String>>();
public KVConfigManager(NamesrvController namesrvController) {
this.namesrvController = namesrvController;
}
public void load() {
// 读取 ${user.home}/namesrv/kvConfig.json 配置文件中的内容
String content = MixAll.file2String(this.namesrvController.getNamesrvConfig().getKvConfigPath());
// 从KV配置文件解析到本地内存
if (content != null) {
KVConfigSerializeWrapper kvConfigSerializeWrapper = KVConfigSerializeWrapper.fromJson(content, KVConfigSerializeWrapper.class);
if (null != kvConfigSerializeWrapper) {
this.configTable.putAll(kvConfigSerializeWrapper.getConfigTable());
}
}
}
public String getKVConfig(final String namespace, final String key) {
// 加读锁
this.lock.readLock().lockInterruptibly();
try {
HashMap<String, String> kvTable = this.configTable.get(namespace);
if (null != kvTable) {
return kvTable.get(key);
}
} finally {
// 释放读锁
this.lock.readLock().unlock();
}
return null;
}
public void putKVConfig(final String namespace, final String key, final String value) {
// 更新前加写锁
this.lock.writeLock().lockInterruptibly();
try {
HashMap<String, String> kvTable = this.configTable.get(namespace);
// 命名空间不存在则创建
if (null == kvTable) {
kvTable = new HashMap<>();
this.configTable.put(namespace, kvTable);
log.info("putKVConfig create new Namespace {}", namespace);
}
kvTable.put(key, value);
} finally {
// 释放写锁
this.lock.writeLock().unlock();
}
// 持久化
this.persist();
}
public void persist() {
// 持久化时加读锁
this.lock.readLock().lockInterruptibly();
try {
KVConfigSerializeWrapper kvConfigSerializeWrapper = new KVConfigSerializeWrapper();
kvConfigSerializeWrapper.setConfigTable(this.configTable);
String content = kvConfigSerializeWrapper.toJson();
// 写到 kvConfig.json
if (null != content) {
MixAll.string2File(content, this.namesrvController.getNamesrvConfig().getKvConfigPath());
}
} finally {
// 释放读锁
this.lock.readLock().unlock();
}
}
}
这里可以用ConcurrentHashMap 来优化。
3.0 优雅地终止线程
- 常用的方法如下
Thread t = new Thread(() -> {
// 判断中断标识
while (!Thread.currentThread().isInterrupted()) {
// 执行任务...
try {
// 等待一段时间
Thread.sleep(1000);
} catch (InterruptedException e) {
// 重新设置中断标识
Thread.currentThread().interrupt();
}
}
});
t.start();
// 主线程中中断子线程
t.interrupt();
首先要知道 Thread 的 interrupt() 方法并不会中断正在执行的线程,它只是设置一个中断标志,我们可以通过 Thread.currentThread().isInterrupted() 来判断当前线程是否被中断,然后退出 run() 方法执行,最后线程停止运行。
但如果这个线程处于等待或休眠状态时(sleep、wait),再调用它的 interrupt() 方法,因为它没有占用 CPU 运行时间片,是不可能给自己设置中断标识的,这时就会产生一个 InterruptedException 异常,然后恢复运行。而 JVM 的异常处理会清除线程的中断状态,所以我们在 run() 方法中就无法判断线程是否中断了。不过我们可以在捕获到 InterruptedException 异常后再重新设置中断标识。例如上面的代码,这样最终也可以达到中断线程的目的。
首先要知道 Thread 的 interrupt() 方法并不会中断正在执行的线程,它只是设置一个中断标志,我们可以通过 Thread.currentThread().isInterrupted() 来判断当前线程是否被中断,然后退出 run() 方法执行,最后线程停止运行。
但如果这个线程处于等待或休眠状态时(sleep、wait),再调用它的 interrupt() 方法,因为它没有占用 CPU 运行时间片,是不可能给自己设置中断标识的,这时就会产生一个 InterruptedException 异常,然后恢复运行。而 JVM 的异常处理会清除线程的中断状态,所以我们在 run() 方法中就无法判断线程是否中断了。不过我们可以在捕获到 InterruptedException 异常后再重新设置中断标识。例如下面的代码,这样最终也可以达到中断线程的目的。
-
RocketMq中有一个类提供了优雅的关闭线程的方式
ServiceThread 基类主要就提供了可以优雅地终止线程的机制,并实现了等待机制。
package org.apache.rocketmq.common;
public abstract class ServiceThread implements Runnable {
private static final long JOIN_TIME = 90 * 1000;
private Thread thread;
// waitPoint 起到主线程通知子线程的作用
protected final CountDownLatch2 waitPoint = new CountDownLatch2(1);
// 是通知标识
protected volatile AtomicBoolean hasNotified = new AtomicBoolean(false);
// 停止标识
protected volatile boolean stopped = false;
// 是否守护线程
protected boolean isDaemon = false;
// 线程开始标识
private final AtomicBoolean started = new AtomicBoolean(false);
// 获取线程名称
public abstract String getServiceName();
// 开始执行任务
public void start() {
// 任务已经开始运行标识
if (!started.compareAndSet(false, true)) {
return;
}
// 停止标识设置为 false
stopped = false;
// 绑定线程,运行当前任务
this.thread = new Thread(this, getServiceName());
// 设置守护线程,守护线程具有最低的优先级,一般用于为系统中的其它对象和线程提供服务
this.thread.setDaemon(isDaemon);
// 启动线程开始运行
this.thread.start();
}
public void shutdown() {
this.shutdown(false);
}
public void shutdown(final boolean interrupt) {
// 任务必须已经开始
if (!started.compareAndSet(true, false)) {
return;
}
// 设置停止标识
this.stopped = true;
if (hasNotified.compareAndSet(false, true)) {
// 计数减1,通知等待的线程不要等待了
waitPoint.countDown();
}
try {
// 中断线程,设置中断标识
if (interrupt) {
this.thread.interrupt();
}
// 守护线程等待执行完毕
if (!this.thread.isDaemon()) {
this.thread.join(this.getJointime());
}
} catch (InterruptedException e) {
log.error("Interrupted", e);
}
}
public long getJointime() {
return JOIN_TIME;
}
// 等待一定时间后运行
protected void waitForRunning(long interval) {
if (hasNotified.compareAndSet(true, false)) {
// 通知等待结束
this.onWaitEnd();
return;
}
// 重置计数
waitPoint.reset();
try {
// 一直等待,直到计数减为 0,或者超时
waitPoint.await(interval, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
log.error("Interrupted", e);
} finally {
// 设置未通知
hasNotified.set(false);
// 通知等待结束
this.onWaitEnd();
}
}
// 等待结束后
protected void onWaitEnd() {
}
public boolean isStopped() {
return stopped;
}
public boolean isDaemon() {
return isDaemon;
}
public void setDaemon(boolean daemon) {
isDaemon = daemon;
}
}
开始运行:
- 在调用
start()开始执行任务时,首先设置 started 标识,标记任务已经开始。 - 接着设置了 stopped 标识,run() 方法里可以通过
isStopped()来判断是否继续执行。 - 然后绑定一个执行的 Thread,并启动这个线程开始运行。
等待运行:
- 子类可调用
waitForRunning()方法等待指定时间后再运行 - 如果 hasNotified 已经通知过,就不等待
- 否则重置 waitPoint 计数器(默认为 1)
- 然后调用 waitPoint.await 开始等待,它会等待直到超时或者计数器减为 0。
终止运行:
- 主线程可调用
shutdown()方法来终止 run() 方法的运行 - 其终止的方式就是设置 stopped 标识,这样 run() 方法就可以通过
isStopped()来跳出 while 循环 - 然后 waitPoint 计数器减 1(减为0),这样做的目的就是如果线程调用了 waitForRunning 方法正在等待中,这样可以通知它不要等待了。ServiceThread 巧妙的使用了 CountDownLatch 来实现了等待,以及终止时的通知唤醒机制。
- 最后调用 t.join() 方法等待 run() 方法执行完成。
RocketMQ使用ServiceThread 业务
FileWatchService 用于监听文件的变更,实现逻辑比较简单。
-
在创建 FileWatchService 时,就遍历要监听的文件,计算文件的hash值,存放到内存列表中
-
run() 方法中就是监听的核心逻辑,while 循环通过
isStopped()判断是否中断执行 -
默认每隔 500 秒检测一次文件 hash 值,然后与内存中的 hash 值做对比
-
如果文件 hash 值变更,则触发监听事件的执行
package org.apache.rocketmq.srvutil;
public class FileWatchService extends ServiceThread {
// 监听的文件路径
private final List<String> watchFiles;
// 文件当前hash值
private final List<String> fileCurrentHash;
// 监听器
private final Listener listener;
// 观测变化的间隔时间
private static final int WATCH_INTERVAL = 500;
// MD5 消息摘要
private final MessageDigest md = MessageDigest.getInstance("MD5");
public FileWatchService(final String[] watchFiles, final Listener listener) throws Exception {
this.listener = listener;
this.watchFiles = new ArrayList<>();
this.fileCurrentHash = new ArrayList<>();
// 遍历要监听的文件,计算每个文件的hash值并放到内存表中
for (int i = 0; i < watchFiles.length; i++) {
if (StringUtils.isNotEmpty(watchFiles[i]) && new File(watchFiles[i]).exists()) {
this.watchFiles.add(watchFiles[i]);
this.fileCurrentHash.add(hash(watchFiles[i]));
}
}
}
// 线程名称
@Override
public String getServiceName() {
return "FileWatchService";
}
@Override
public void run() {
// 通过 stopped 标识来暂停业务执行
while (!this.isStopped()) {
try {
// 等待 500 毫秒
this.waitForRunning(WATCH_INTERVAL);
// 遍历每个文件,判断文件hash值是否变更
for (int i = 0; i < watchFiles.size(); i++) {
String newHash = hash(watchFiles.get(i));
// 对比hash
if (!newHash.equals(fileCurrentHash.get(i))) {
// 更新文件hash值
fileCurrentHash.set(i, newHash);
// 触发文件变更事件
listener.onChanged(watchFiles.get(i));
}
}
} catch (Exception e) {
log.warn(this.getServiceName() + " service has exception. ", e);
}
}
}
// 计算文件的hash值
private String hash(String filePath) throws IOException {
Path path = Paths.get(filePath);
md.update(Files.readAllBytes(path));
byte[] hash = md.digest();
return UtilAll.bytes2string(hash);
}
// 文件变更监听器
public interface Listener {
void onChanged(String path);
}
}
FileWatchService 的初始化代码大致如下:
if (TlsSystemConfig.tlsMode != TlsMode.DISABLED) {
fileWatchService = new FileWatchService(
// 监听证书文件的变更
new String[]{
TlsSystemConfig.tlsServerCertPath,
TlsSystemConfig.tlsServerKeyPath,
TlsSystemConfig.tlsServerTrustCertPath
},
// 注册监听器
new FileWatchService.Listener() {
boolean certChanged, keyChanged = false;
@Override
public void onChanged(String path) {
((NettyRemotingServer) remotingServer).loadSslContext();
}
});
}