RocketMQ 学习心得以及值得借鉴的地方

283 阅读6分钟

一:值得借鉴点

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();
            }
        });
}