java.util.Random和ThreadLocalRandom

引言

ThreadLocalRandom类是从JDK7开始在并发包下新增的随机数生成器,它解决了Random类在多线程下多个线程竞争内部唯一的原子性种子变量而导致大量线程自旋重试的不足。ThreadLocalRandom为后面要探讨的高并发累加器Striped64及其实现类提供了基础,所以先要理解它。本文首先分析Random类的源码以及它在多线程下使用的局限性,然后在通过分析ThreadLocalRandom源码及其解决办法。

 

java.util.Random分析

构造方法

在JDK7之前包括现在java.util.Random应该是使用比较广泛的随机数生成工具类,所以我们还是有必要了解其实现原理。首先来看看其构造方法和及其相关的方法:

 1 public Random() {//默认的无参构造方法  
 2     this(seedUniquifier() ^ System.nanoTime());  
 3 }  
 4 private final AtomicLong seed;//内部维护的用于产生随机数的种子  
 5 private static long seedUniquifier() {  
 6     // L'Ecuyer, "Tables of Linear Congruential Generators of  
 7     // Different Sizes and Good Lattice Structure", 1999  
 8     for (;;) {  
 9         long current = seedUniquifier.get();  
10         long next = current * 181783497276652981L;  
11         if (seedUniquifier.compareAndSet(current, next))  
12             return next;  
13     }  
14 }  
15 //原子变量  
16 private static final AtomicLong seedUniquifier = new AtomicLong(8682522807148012L);  
17 //有参的构造方法  
18 public Random(long seed) {  
19     if (getClass() == Random.class)  
20         this.seed = new AtomicLong(initialScramble(seed));  
21     else {  
22         //子类可以重写setSeed()方法  
23         this.seed = new AtomicLong();  
24         setSeed(seed);  
25     }  
26 }  
27 private static long initialScramble(long seed) {  
28     return (seed ^ multiplier) & mask;  
29 }  
30 synchronized public void setSeed(long seed) {  
31     this.seed.set(initialScramble(seed));  
32     haveNextNextGaussian = false;  
33 }  

Random有两个构造方法, 有参的构造方法传入的long型参数和最终将要产生的随机数没有直接联系,它只是用来计算(initialScramble方法)出一个种子seed,或者我喜欢叫它随机因子,这个种子是用于将来产生随机数的起源数字,产生随机数的过程就是:1).先利用这个种子经过固定的计算公式得出一个新的种子数,并通过CAS成功更改这个种子(下一次产生随机数之前将会拿到这个被更新过的种子继续更新),2)通过这个被更改过的种子数再通过相应的公式最终产生随机数。由此可见参数相同的Random类将会产生相同的随机数,因为Random内部相关的计算过程都是固定的。不同的只是这个起始种子,它决定了将会产生的随机数,所以称之为“伪随机数”。

 

    回到上面的源码,有参的构造方法传入了一个用于计算出种子的参数,那么无参的构造方法呢?由上面的源码可以看出,它最终还是调用的有参的构造方法,只是这个参数由一些计算得出,首先执行seedUniquifier(),其返回结果再和当前时间的纳秒值进行异或。seedUniquifier方法内部使用了AtomicLong+CAS的方式确保了多线程情况下不会产生相同的种子数,所以Random其实是线程安全的。当然在高并发下构造多个Random实例可能导致大量线程多次CAS重试。

    另外,从有参的构造方法体可以看出,Random的子类其实可以重写设置种子数初始值的方法setSeed().

下面的例子不论在何时何地以何种方式运行,得到的结果都是相同的(17,88),这很好理解,因为当初始种子相同时,由于Random内部的计算过程都是固定不变的,所以将会产生的随机数也是确定的。只有使用无参的构造方法才会避免这种情况。

1 Random random = new Random(50);  
2 System.out.println(random.nextInt(100));//总是17  
3 System.out.println(random.nextInt(100));//总是88  
4 Random random1 = new Random(50);  
5 System.out.println(random1.nextInt(100));//也总是17  
6 System.out.println(random1.nextInt(100));//也总是88 

核心方法 

接下来,虽然Random内部产生随机数的方法很多,不管是产生int,long,double等类型的随机数,其实都是利用了下面这个核心方法,当然它也是体现了Random局限性问题的根源:

1 protected int next(int bits) {  
2     long oldseed, nextseed;  
3     AtomicLong seed = this.seed;  
4     do {  
5         oldseed = seed.get();  
6         nextseed = (oldseed * multiplier + addend) & mask;  
7     } while (!seed.compareAndSet(oldseed, nextseed));  
8     return (int)(nextseed >>> (48 - bits));  
9 }  

该方法几乎是Random所有产生随机数的核心方法,参数bits表示将要产生的随机数的二进制数,例如int型是32,boolean是1, 该方法就是利用AtomicLong+CAS更新种子数,更新种子数的公式是先(oldseed * multiplier + addend) & mask,然后再进行移位算法>>>,据说这被称为所谓的“线性同余算法”,我们不需要理解它,只要知道这其实就是一个固定的计算公式,入参是旧种子数, 输出是新的种子数。在多线程下使用同一个Random实例生成随机数的时候,多个线程同时产生随机数之前计算新的种子的时候多个线程会竞争对同一个原子变量seed的更新操作,由于原子变量的更新是CAS操作,同时只有一个线程会成功,所以会造成大量线程进行自旋重试,这是会降低并发性能的,这就是Random的局限性,所以ThreadLocalRandom应运而生。Random的这种AtomicLong+CAS机制虽然在高并发下效率低下,但是保障了高并发下不同线程产生不同的随机数,这也是线程安全的一种保障。

 

随机数的边界

Random提供的众多产生随机的方法 中,nextDouble(),nextFloat()是产生【0,1)之间的随机数,nextLong(),nextInt()产生的随机数分别是long(2的负64次方~2的64次方)和int(2的负32次方~2的32次方)型变量的边界,另外还有几个特殊方法:

  1. void nextBytes(byte[] bytes),生成内容随机的byte值,并放入bytes数组,注意如果传入的bytes数组如果有值,会被随机生成的byte覆盖。
  2. int nextInt(int bound),生成【0,bound)之间的随机数,该方法的实现虽然简短,但是据说是具有算法背景的高级工程师花了大量时间设计、实现的,千万不要企图使用Math.abs(rnd.nextInt()) % n这种方式去替代,你将会得到事与愿违的结果。
  3. double nextGaussian(),以上所有方法产生的随机数都是成均匀分布的,该方法取自此随机数生成器序列的、呈高斯(“正态”)分布的double值,其平均值是0.0标准差是1.0。

关于Math.random

 Math.random()返回一个随机的double值,其本质其实是调用Random的nextDouble方法,源码如下:

1 public static double random() {  
2     return RandomNumberGeneratorHolder.randomNumberGenerator.nextDouble();  
3 }  
4   
5 private static final class RandomNumberGeneratorHolder {  
6     static final Random randomNumberGenerator = new Random();  
7 }  

其内部的Random的实例是一个static,final修饰的,所以每一次都是利用的同一个Random实例获取的随机数。 

最后,Random实现了Serializable接口,是可序列化的。并且自定义writeObject和readObject方法对象序列化进行了优化。

 

JDK8 新增的函数式编程方法

下列方法是JDK8新增的方法,用于流式编程:

1 IntStream ints(long streamSize)//产生streamSize个介于 2的负32次方~2的32次方之间的int型随机数  
2 IntStream ints() //产生无穷多个介于 2的负32次方~2的32次方之间的int型随机数  
3 IntStream ints(long streamSize, int randomNumberOrigin,int randomNumberBound)//产生streamSize个介于【randomNumberOrigin,randomNumberBound)之间的int型随机数  
4 public IntStream ints(int randomNumberOrigin, int randomNumberBound)//产生无穷多个介于【randomNumberOrigin,randomNumberBound)之间的int型随机数  
5   
6 .......还有类似的long、double类型的系列方法  

 

上面只列举了int型的相关方法,Random还提供了long,double型的相关方法,其中double型的相关方法在没指定randomNumberBound时,产生的是[0,1)之间的随机数,  long型还是2的负64次方~2的64次方之间的随机数。

 

那么如何使用它们呢?请看下面的例子:

1 //产生10个[0,5)之间的随机数  
2 new Random().ints(0,5).limit(10).forEach(System.out::println);  
3   
4 //list只有5个2的-32次方~2的32次方之间的int随机数  
5 List<Integer> list = random.ints(5).limit(10).boxed().collect(Collectors.toList()); 

上面的例子中,因为Random相关的流式函数都是产生无穷多个随机数字流,所以只能使用惰性求值函数limit 进行截断,第一个例子产生10个【0,5)之间的随机数,当然有重复值是肯定的。第二例子中首先,boxed是所谓的自动装箱函数(即int封装成Integer),虽然limit截断10个,但是Random只产生了5个随机数,所以list中只有5个随机数。

 

另外,Java8并没有提供创建高斯(正态分布)伪随机数的流,但是利用现有的方法,也能够轻易的实现:

1 Stream.generate(random::nextGaussian).map(e -> e).limit(10).forEach(System.out::println);  

generate方法是通过给定的 Supplier(这个接口可以看成一个对象的工厂,每次调用返回一个给定类型的对象)产生一个无限长度的流,这里直接使用了Random的nextGaussian方法,用于产生无穷多个高斯随机数,接下来的map方法是一种类型转换方法,将一个流中的值转换成一个新的流。

 

关于函数式编程:

https://www.cnblogs.com/snowInPluto/p/5981400.html

https://www.cnblogs.com/aoeiuv/p/5911692.html

 

Random总结

首先,Random是线程安全的,它的内部维护了一个用于产生随机数的AtomicLong型种子变量seed,每一次产生随机数之前通过CAS的机制对种子变量进行更新,然后拿着更新过的种子计算出随机数。造成Random在高并发下使用同一个Random实例产生随机数的性能问题的根本,就在于多个线程需要以CAS的方式竞争该Random实例内部的那个共享的种子变量seed。明白了这一点,对于解决办法也就不难得出了,那就是每一个线程独立的维护一个用于自己产生随机数的种子变量(类似ThreadLocal的原理),这就不会存在竞争了,没错,下面要将的ThreadLocalRandom就是这样做的。

 

ThreadLocalRandom分析

Java7的实现

首先,对于ThreadLocalRandom的实现,JDK7和JDK8的实现方式是完全不一样的,不过它们的思想都是一样的,都借助了ThreadLocal的原理,不同的是JDK7直接使用了ThreadLocal来实现,而JDK8只是借助这种思想,并没有直接借助ThreadLocal来实现,下面是JDK7的部分实现源码:

 1 public class ThreadLocalRandom extends Random {  
 2   
 3     private long rnd;  
 4   
 5     private long pad0, pad1, pad2, pad3, pad4, pad5, pad6, pad7;  
 6       
 7     ThreadLocalRandom() {  
 8         super();  
 9         initialized = true;  
10     }  
11       
12     public static ThreadLocalRandom current() {  
13         return localRandom.get();  
14     }  
15   
16     private static final ThreadLocal<ThreadLocalRandom> localRandom =  
17         new ThreadLocal<ThreadLocalRandom>() {  
18             protected ThreadLocalRandom initialValue() {  
19                 return new ThreadLocalRandom();  
20             }  
21     };  
22       
23     public void setSeed(long seed) {  
24     if (initialized)  
25         throw new UnsupportedOperationException();  
26         rnd = (seed ^ multiplier) & mask;  
27     }  
28     protected int next(int bits) {  
29         rnd = (rnd * multiplier + addend) & mask;  
30         return (int) (rnd >>> (48-bits));  
31     }  
32       
33     .....  
34 } 

以上只列举了JDK7中对ThreadLocalRandom实现的部分关键源码,从以上的源码可以看出以下几点,

 

  1. ThreadLocalRandom继承了Random;
  2. ThreadLocalRandom重写了setSeed和next(int bits)方法;
  3. 在内部维护了一个static的ThreadLocal<ThreadLocalRandom>类型的变量localRandom用来保存各个线程自己的ThreadLocalRandom实例;
  4. 增加了8个缓存行填充字段解决“伪共享”的问题,使每个线程的ThreadLocalRandom实例位于不同的缓存行,避免了竞争。

在JDK7的实现下,实际上是每个线程对应一个自己的ThreadLocalRandom实例,每一个实例独立的维护了各自的种子rnd,它通过Random的无参构造方法回调被重写的setSeed方法来初始化各自的种子变量rnd初始值。而被重写的next(int bits)方法则同Random的结构类似,是真正用于产生随机数其他方法的核心方法。

     Java7中ThreadLocalRandom对各个线程的种子的初始化是基于父类Random的的逻辑和被重写的setSeed方法逻辑确定的,而父类Random的逻辑seedUniquifier()方法是通过AtomicLong +CAS机制实现的,所以这也保证了多个线程将获取到不同的初始种子,但前提是不要在多线程之间共享同一个ThreadLocalRandom实例。ThreadLocalRandom也是线程安全的。

 

Java8的实现

到了JDK8,ThreadLocalRandom的实现方法发生了变化,但是中心思想没有变,它不再使用ThreadLocalRandom实例本身维护线程各自的种子变量,而是将种子变量转移到Thread类中,如下所示,在JDK8的Thread类中新增了三个更ThreadLocalRandom有关的三个变量:

 1 // The following three initially uninitialized fields are exclusively  
 2 // managed by class java.util.concurrent.ThreadLocalRandom. These  
 3 // fields are used to build the high-performance PRNGs in the  
 4 // concurrent code, and we can not risk accidental false sharing.  
 5 // Hence, the fields are isolated with @Contended.  
 6       
 7 /** The current seed for a ThreadLocalRandom */  
 8 @sun.misc.Contended("tlr")  
 9 long threadLocalRandomSeed;  
10   
11 /** Probe hash value; nonzero if threadLocalRandomSeed initialized */  
12 @sun.misc.Contended("tlr")  
13 int threadLocalRandomProbe;  
14   
15 /** Secondary seed isolated from public ThreadLocalRandom sequence */  
16 @sun.misc.Contended("tlr")  
17 int threadLocalRandomSecondarySeed;  

至于JDK的实现者为何要将种子变量放到Thread类中(并且利用了Contended注解解决了缓存行的伪共享问题),通过stackoverflow找到的答案是出于对时间(访问速度更快)和空间(占用内存更小)的更优考虑,就不再深究,下面就Java8中的实现进行分析:

 1 public class ThreadLocalRandom extends Random {  
 2   
 3     private static final AtomicInteger probeGenerator = new AtomicInteger();  
 4     private static final AtomicLong seeder = new AtomicLong(initialSeed());  
 5   
 6     boolean initialized;  
 7   
 8     /** Constructor used only for static singleton */  
 9     private ThreadLocalRandom() {  
10         initialized = true; // false during super() call  
11     }  
12       
13     static final ThreadLocalRandom instance = new ThreadLocalRandom();  
14       
15     static final void localInit() {  
16         int p = probeGenerator.addAndGet(PROBE_INCREMENT);  
17         int probe = (p == 0) ? 1 : p; // skip 0  
18         long seed = mix64(seeder.getAndAdd(SEEDER_INCREMENT));  
19         Thread t = Thread.currentThread();  
20         UNSAFE.putLong(t, SEED, seed);  
21         UNSAFE.putInt(t, PROBE, probe);  
22     }  
23       
24     public static ThreadLocalRandom current() {  
25         if (UNSAFE.getInt(Thread.currentThread(), PROBE) == 0)  
26             localInit();  
27         return instance;  
28     }  
29       
30     public void setSeed(long seed) {  
31         // only allow call from super() constructor  
32         if (initialized)  
33             throw new UnsupportedOperationException();  
34     }  
35       
36     final long nextSeed() {  
37         Thread t; long r; // read and update per-thread seed  
38         UNSAFE.putLong(t = Thread.currentThread(), SEED,  
39                        r = UNSAFE.getLong(t, SEED) + GAMMA);  
40         return r;  
41     }  
42       
43     protected int next(int bits) {  
44         return (int)(mix64(nextSeed()) >>> (64 - bits));  
45     }  
46       
47     ....  
48 }  
以上是JDK8的部分关键实现源码,从JDK8的实现我们可以得出以下几点:
  1. ThreadLocalRandom同样继承了Random类;
  2. ThreadLocalRandom同样重写了setSeed和next(int bits)方法,另外对产生随机数的方法nextInt、nextLong等方法也都进行了重新,因为他们的核心代码由nextSeed方法实现。
  3. ThreadLocalRandom的实例instance是static修饰的,所以对于多个线程,实例其实只有一个,而不像JDK7那样有多个。
在JDK8的实现中,多线程中ThreadLocalRandom实例只有一个,获取该唯一实例的方法只能通过ThreadLocalRandom.current()方法,在该方法中首先会判断当前线程本身的探针哈希变量threadLocalRandomProbe是否已经初始化,如果是第一次调用该方法,threadLocalRandomProbe和相关的那两个变量都是0,这是出于对不需要用到ThreadLocalRandom的线程就不需要初始化相关的变量的一种延迟初始化优化。所以当用到ThreadLocalRandom的时候,需要执行localInit()方法对当前线程的种子变量(threadLocalRandomSeed)和探针哈希值(threadLocalRandomProbe)进行初始化,在localInit()方法中,利用了两个原子变量probeGenerator和seeder对当前线程的种子变量和探针哈希值进行了初始化,这里将probeGenerator和seeder声明为原子变量的目的是为了在多线程情况下,赋予它们各自不同的种子初始值,这样就不会导致每个线程产生的随机数序列都是一样的,而且probeGenerator和seeder只会在初始化在初始化调用线程的种子和探针变量时候用到,每个线程只会使用一次。
     另外,由于ThreadLocalRandom继承了Random,所以在构造ThreadLocalRandom实例的时候,先会执行Random的无参构造方法,该无参构造方法再回调ThreadLocalRandom重写的setSeed方法,但是通过上面对该方法的重写内容可以看出,它仅仅是判断了initialized是否为true就抛出异常,其它什么也没做,所以从这里可以看出2点,1)ThreadLocalRandom并没有像JDK7那样基于父类的原子种子变量来为当前线程产生初始种子,2)ThreadLocalRandom并没有对外提供设置初始化种子的机制,该方法只会被父类初始化的时候调用,而这个时候initialized为false。
    还有,在初始化种子变量的初始值对应的原子变量seeder时,它调用了一个initialSeed()方法,它的源码我没有贴出来,该代码内首先判断键为“java.util.secureRandomSeed”的系统属性值是否为true来判断要不要使用安全性高的种子,如果为true则使用SecureRandom.getSeed(8)获取初始化种子,这里说的安全是指获取的种子是不可被预测的。如果不需要安全性高的种子则使用当前时间来计算初始化种子,这个种子是可以被预测到的。也就是说除了安全性更高的随机数产生器secureRandomSeed产生的随机数,其实Random、ThreadLocalRandom产生的都被称为“伪随机数”,因为他们是可预测的。
     通过以上的关键代码,ThreadLocalRandom就将各个线程对应的各自的初始种子放到了各个线程Thread实例本身的threadLocalRandomSeed变量中,并且ThreadLocalRandom还重写了产生随机数的相关方法,它们最终都会通过上面的nextSeed()来获取并更新各自的种子变量,随后再通过这个新的种子变量产生随机数。由此还可得出由于具体的种子是存放到线程里面的,所以ThreadLocalRandom的实例里面只是与线程无关的通用算法,所以是线程安全的。
     对于ThreadLocalRandom的其他方法就不再熬述了,其实它提供的功能和Random类似,唯一有增强的地方就是Random只对函数式编程提供的指定起止边界的随机数生成方法在ThreadLocalRandom中已经为普通的随机数生成方法也提供了,例如nextLong(long origin, long bound)、nextInt(int origin, int bound)、nextDouble(double origin, double bound),当然ThreadLocalRandom也有针对Java8的函数式编程的相关方法,而那些以internalNext开头的受保护的方法也是为函数式编程方法服务的。
     最后,关于JDK8在线程Thread类中新增的除了threadLocalRandomSeed我们知道是用于ThreadLocalRandom参数随机数用之外,另外两个threadLocalRandomProbe(探针hash)和threadLocalRandomSecondarySeed(二级种子)变量有什么作用呢,threadLocalRandomProbe在Striped64中用于分散计算数组索引下标,在ConcurrentSkipListMap、ForkJoinPool、ConcurrentHashMap等类中也有相关的使用,留到将来对于它们的分析时在研究了。
 

ThreadLocalRandom的使用方式

使用ThreadLocalRandom的方式很简单,每次直接使用ThreadLocalRandom.current().nextInt()这种方式即可,特别需要注意的就是一定不要在多线程之间共享同一个ThreadLocalRandom实例,例如下面这种错误的用法:

 1 public class RandomTest implements Runnable {  
 2   
 3     public static void main(String[] args) {  
 4         ExecutorService taskPool = Executors.newCachedThreadPool();  
 5         for (int n = 1; n < 10; n++) {  
 6             taskPool.submit(new RandomTest());  
 7         }  
 8         taskPool.shutdown();  
 9     }  
10   
11     private static Random random = ThreadLocalRandom.current();//不要在多线程之间共享同一个实例  
12   
13     @Override  
14     public void run() {  
15         System.out.println(random.nextInt());//错误的使用方式  
16     }  
17 } 

以上的使用方式是错误的,这种错误的使用方式在JDK7和JDK8下都将可能导致多个线程产生相同的随机数,为什么呢?从上面对源码的分析不难看出:

 

  • 如果是JDK7,相当于各个线程都是共用存储与主线程的ThreadLocalMap中的同一个ThreadLocalRandom实例,而用于产生随机数的种子是存储在该ThreadLocalRandom实例中的,虽然每次产生随机数的时候都会更新种子rnd,但是JDK7的ThreadLocalRandom的种子不是原子变量,而是简单的long类型,且更新种子的方法没有加锁,因此将可能导致多个线程拿到相同的种子继而产生相同的随机数。
  • 如果是JDK8,相当于各个线程共享的该ThreadLocalRandom实例的种子都基于主线程中的threadLocalRandomSeed变量,即各个线程使用了相同的初始种子,所以这10个线程当然就产生了相同的随机数序列,如果说JDK7是由于并发问题有可能导致某些线程产生相同的随机值,那么JDK8下将百分之百肯定所有线程产生的随机序列都将是相同的。

还有一点,那就是不论是JDK7还是JDK8,它们内部维护的种子变量都只是一个普通的int或者long型变量,因为已经不存在竞争了,所以也就没必要声明为原子变量了,当然也就不需要CAS的方式去改变它了,所以当多个线程使用相同的ThreadLocalRandom实例产生随机数的时候,产生相同的随机数也就说的通了。

 

所以正确的用法是,不要将ThreadLocalRandom.current()返回的实例在多个线程之间共享,而应该每一次需要产生随机数的时候都获取实例,同时立即调用产生随机数的方法,例如:

1 @Override  
2 public void run() {  
3     System.out.println(ThreadLocalRandom.current().nextInt());//每一次产生随机数时都要执行ThreadLocalRandom.current()  
4 }  

因为多线程情况下,ThreadLocalRandom.current()方法会帮我们找到各自线程内部维护的种子,从而产生各自的随机数,所以在每一次想要产生随机数的时候都要先执行ThreadLocalRandom.current()方法。

 

ThreadLocalRandom总结

不论是Random还是ThreadLocalRandom,它们产生随机数的总体方式都是一致的(不同的主要是对种子的产生算法与存储方式以及根据种子产生随机数的算法),那就是先通过一个通用的算法根据旧种子计算出新的种子替代旧种子,然后再根据新种子利用另一个通用算法产生随机数,可见产生随机数的算法是不变的,关键在于种子的不同,种子相同的时候在相同的运行环境下(JDK版本)产生的随机数肯定就是相同的,整个过程可以抽象为一个数学表达式:r = f(seed),seed是种子,r是产生的随机数,f就是那个通用算法,当然我这里是站在一个更高的维度进行的抽象,其实针对新种子的产生和最终随机数的产生是两个不同的通用算法:seed新=f1(seed旧),r = f2(seed新),f1,f2就代表那两个不同的通用算法。

 

 本节首先讲解了Random的实现原理以及介绍了Random在多线程下存在竞争种子原子变量更新操作失败后自旋等待的缺点,从而引出ThreadLocalRandom类,ThreadLocalRandom使用ThreadLocal的思想,让每个线程内持有一个本地的种子变量(JDK7的种子放在ThreadLocalRandom实例中,JDK8的种子存放在Thread实例中),JDK8的种子变量只有在使用随机数时候才会被初始化,ThreadLocalRandom在多线程下计算新种子时候是根据自己线程内维护的种子变量进行更新,然后产生随机数,从而避免了竞争。

  

另外,除了Random和ThreadLocalRandom这种产生伪随机数的类,JDK还提供了产生安全的不可预测的随机数的类SecureRandom,这里由于篇幅限制就不再对其展开分析了,到用到的时候再看咯。

 

posted @ 2021-05-11 17:14  莫待樱开春来踏雪觅芳踪  阅读(635)  评论(0)    收藏  举报