likes
comments
collection
share

给开源项目提了个PR

作者站长头像
站长
· 阅读数 14

拖了很久的文章终于动笔了,两个月前提的PR现在才开始写总结文章,lazydog一只....

1、背景

首先介绍下 Sa-Token ,这是一个轻量级 Java 权限认证框架,主要解决:登录认证、权限认证、单点登录、OAuth2.0、分布式Session会话、微服务网关鉴权 等一系列权限相关问题。

给开源项目提了个PR

优化源自其中的一个随机数获取逻辑实现,具体 PR 如图所示,该PR已被合并到主线代码中,在10月28日发布的 v1.32.0 中已经完成替换。

给开源项目提了个PR

主要工作就是使用 ThreadLocalRadom 获取随机数去替换原来的 Random 获取,PR中也有给出具体的测试样例和数据来佐证更换之后的性能提升,但今天这篇文章还是想更全面地讲一下几种随机数获取逻辑的使用和性能差异。

需要注意的是,在 1.32.0 版本中是使用了单例的 ThreadLocalRandom 获取随机数,实际上这会导致多个线程获取的随机数重复,因为seed是共享的,正确的做法应该是每次获取随机数都调用current方法去重新获取,这在 1.33.0 版本中得到修复。

2、随机数获取方式

1)多例Random

在 Java 中我们获取随机数自然而然会想到使用 Random 对象的 nextXX 方法去获取,这也是最简单的一种,例如可以这样:

public class SaUtils1 {
    public static String getRandomString(int length) {
        String str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
        Random random = new Random();
        StringBuffer sb = new StringBuffer();
        for (int i = 0; i < length; i++) {
            int number = random.nextInt(62);
            sb.append(str.charAt(number));
        }
        return sb.toString();
    }
}

这种方式获取确实很简单,但还是存在一个可优化的点,我们可以发现这是一个静态方法获取随机数,每次进入到这个方法都会创建一个新的 Random 对象。虽然说方法结束后内存会被回收,但在访问量大的情况下,反复创建对象的开销也不能忽视,因此有了第二种方式获取随机数。

2)单例Random

单例获取实际上就是将 Random 对象升级为类成员,这样可以避免重复创建对象,例如:

public class SaUtils2 {
    private static final Random random = new Random();
    public static String getRandomString(int length) {
        String str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
        StringBuffer sb = new StringBuffer();
        for (int i = 0; i < length; i++) {
            int number = random.nextInt(62);
            sb.append(str.charAt(number));
        }
        return sb.toString();
    }
}

显而易见地,在升级为类成员之后 Random 对象只需创建一次,在创建开销上明显是占优的,但具体的性能分析还要等待真正的测试。

3)ThreadLocalRandom

ThreadLocalRandom 是 JDK7 提供并发产生随机数的工具类,能够解决多线程下的争用问题,在使用上和Random略有不同:

public class SaUtils3 {
    public static String getRandomString(int length) {
        String str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
        StringBuffer sb = new StringBuffer();
        for (int i = 0; i < length; i++) {
            int number = ThreadLocalRandom.current().nextInt(62);
            sb.append(str.charAt(number));
        }
        return sb.toString();
    }
}

可以看到这里首先是用 ThreadLocalRandom 的静态方法 current 来获取到一个实例,接着再去调用 nextXX 方法实现随机数获取。

3、线程争用分析

1)Random 的线程争用问题

实际上我们使用单例 Random 对象在单线程下是没问题的,但在多线程下虽不会有线程安全问题但会发生线程争用,进而影响生成效率,那么线程争用主要体现在哪呢?

以 nextInt 为例,贴代码分析下:

给开源项目提了个PR

可以看到里面调用了 next 方法,进而在 next 方法中是取旧的 seed 去做位运算,进而得到新的 seed 并计算出随机数,这里我们只需要知道这个过程就行了,位运算的具体操作不是本文重点。

这里我们也可以发现这个 seed 是 AtomicLong 类型的,意味着其可以保证原子操作,也就避免了线程安全问题的出现。同时,意味着在高并发场景下会有较多的 CAS 失败操作,不断自旋重试,进而造成 CPU 处理效率降低且吞吐量下降。

2)ThreadLocalRandom 的解决方案

接下来我们看看 ThreadLocalRandom 在源码层面有什么区别。

首先进入 current 方法,可以发现这里是调用了 UNSAFE 来保证单例,如果还没有创建过就进入到初始化方法,我们跟进去看看。

给开源项目提了个PR

这里需要说明的是,PROBE 实际上是一个状态变量,如果为0说明还没初始化,如果为1说明已经初始化完成,而 SEED 很明显就是种子变量了,这两个变量都是通过UNSAFE中的方法给设置到线程中去,那么我们去 Thread 中找一下。

给开源项目提了个PR

可以发现这两个变量在 Thread 类中是有体现的,也就是说实际上种子是存储在 Thread 中的,与线程强相关的。

给开源项目提了个PR

从这里我们可以发现在单例下种子实际上是通过存储在 Thread 中来实现线程隔离的。

接下来看看 nextInt 方法,可以发现这里除了生成随机数,还对 seed 进行更新,同样是使用 UNSAFE 去更新种子。

给开源项目提了个PR

其他更细节的操作就不分析了,到这里我们可以得出结论:ThreadLocalRandom 减少线程争用的操作就是将 seed 与线程绑定起来,每个线程有自己的 seed 这样即使在多线程环境下,seed 的更新也不会造成频繁 CAS 导致吞吐量降低。也因为seed是和线程绑定的,那么就不适用于使用单例的ThreadLocalRandom对象了,这样会导致多个线程生成的随机数重复。

4、性能分析

在分析了源码层面的区别之后,还应该从实际的使用出发,测试下性能的表现。

1)单线程测试

public class RandomTest {
    static int times = 100000;
    public static void main(String[] args) throws InterruptedException {
        singleThread();
    }
    public static void singleThread() {
        long start = System.currentTimeMillis();
        for (int i = 0; i < times; i++) {
            SaUtils.getRandomString(64);
        }
        long end = System.currentTimeMillis();
        System.out.println("single thread-cost time : " + (end - start) + "ms");

        long start1 = System.currentTimeMillis();
        for (int i = 0; i < times; i++) {
            SaUtils2.getRandomString(64);
        }
        long end1 = System.currentTimeMillis();
        System.out.println("single thread-cost time : " + (end1 - start1) + "ms");

        long start2 = System.currentTimeMillis();
        for (int i = 0; i < times; i++) {
            SaUtils3.getRandomString(64);
        }
        long end2 = System.currentTimeMillis();
        System.out.println("single thread-cost time : " + (end2 - start2) + "ms");

    }
}

这里简单地做一个单线程的测试,其中涉及到的生成随机数的类都在上面第二点中有列出,这里不再重复。

这里做了10w次的获取操作,打印出耗时具体如下,可能稍有误差,不过大致可以体现出差异。

给开源项目提了个PR

可以发现多例 Random 确实有一些性能损耗,而 ThreadLocalRandom 的耗时是最短的。

2)多线程测试

public class RandomTest {
    static int times = 100000;
    static int threadNum = 10;
    public static void main(String[] args) throws InterruptedException {
        poolThread();
    }
    public static void poolThread() throws InterruptedException {
        ExecutorService pool = Executors.newFixedThreadPool(threadNum);
        CountDownLatch cdl = new CountDownLatch(times);
        long start1 = System.currentTimeMillis();
        for (int i = 0; i < times; i++) {
            pool.execute(() -> {
                SaUtils.getRandomString(64);
                cdl.countDown();
            });
        }
        cdl.await();
        long end1 = System.currentTimeMillis();
        System.out.println("cost time : " + (end1 - start1) + "ms");
        pool.shutdown();

        pool = Executors.newFixedThreadPool(threadNum);
        CountDownLatch cdl2 = new CountDownLatch(times);
        long start2 = System.currentTimeMillis();
        for (int i = 0; i < times; i++) {
            pool.execute(() -> {
                SaUtils2.getRandomString(64);
                cdl2.countDown();
            });
        }
        cdl2.await();
        long end2 = System.currentTimeMillis();
        System.out.println("cost time : " + (end2 - start2) + "ms");
        pool.shutdown();

        pool = Executors.newFixedThreadPool(threadNum);
        CountDownLatch cdl3 = new CountDownLatch(times);
        long start3 = System.currentTimeMillis();
        for (int i = 0; i < times; i++) {
            pool.execute(() -> {
                SaUtils3.getRandomString(64);
                cdl3.countDown();
            });
        }
        cdl3.await();
        long end3 = System.currentTimeMillis();
        System.out.println("cost time : " + (end3 - start3) + "ms");
        pool.shutdown();

    }
}

这里测试了10个线程同时获取的情况,同样是获取10w次,具体的数据如下:

给开源项目提了个PR

从结果可以看出,在多线程环境下,由于存在线程争用,单例的 Random 表现最不理想,耗时几乎是多例 Random 的四倍,这也印证了我们前面的分析。

相比之下, ThreadLocalRandom 表现依旧是最佳的,耗时上大概是多例 Random 的 40%,由于机器差异可能数据有所不同,不过大致上是这个量级。

5、总结

基于上面的理论分析和性能分析,我们可以发现不管是单线程还是多线程环境下,ThreadLocalRandom 的表现都是比较占优的,特别是在高并发的情况下,使用这种随机数获取方式可以带来一定的性能提升。

回到PR本身,Sa-Token 是一个认证鉴权框架,其 token 的生成默认基于随机数生成,同时 token 的获取会贯穿整个认证鉴权过程,那么随机数的获取效率在这里就显得至关重要了,也是基于框架的特点,才提出了这个修改建议。

需要注意的是,在 1.32.0 版本中是使用了单例的 ThreadLocalRandom 获取随机数,实际上这会导致多个线程获取的随机数重复,因为seed是共享的,正确的做法应该是每次获取随机数都调用current方法去重新获取,这在 1.33.0 版本中得到修复。