《Java 编程的逻辑》笔记——第7章 常用基础类(三)

声明:

本博客是本人在学习《Java 编程的逻辑》后整理的笔记,旨在方便复习和回顾,并非用作商业用途。

本博客已标明出处,如有侵权请告知,马上删除。

7.5 剖析日期和时间

本节,我们讨论 Java 中日期和时间处理相关的 API。

日期和时间是一个比较复杂的概念,Java 8 之前的设计有一些不足,业界有一个广泛使用的第三方类库 Joda-Time,Java 8 受 Joda-Time 影响,重新设计了日期和时间 API,新增了一个包 java.time。

虽然 Java 8 之前的 API 有一些不足,但依然是被大量使用的,本节只介绍 Java 8 之前的 API。

下面,我们先来看一些基本概念,然后再介绍 Java 的日期和时间 API。

7.5.1 基本概念

7.5.1.1 时区

我们都知道,同一时刻,世界上各个地区的时间可能是不一样的,具体时间与时区有关,一共有 24 个时区,英国格林尼治是 0 时区,北京是东八区,也就是说格林尼治凌晨 1 点,北京是早上 9 点。0 时区的时间也称为 GMT+0 时间,GMT 是格林尼治标准时间,北京的时间就是 GMT+8:00。

7.5.1.2 时刻和纪元时

所有计算机系统内部都用一个整数表示时刻,这个整数是距离格林尼治标准时间 1970 年 1 月 1 日 0 时 0 分 0 秒的毫秒数。为什么要用这个时间呢?更多的是历史原因,本文就不介绍了。

格林尼治标准时间 1970 年 1 月 1 日 0 时 0 分 0 秒也被称为 Epoch Time (纪元时)。

这个整数表示的是一个时刻,与时区无关,世界上各个地方都是同一个时刻,但各个地区对这个时刻的解读,如年月日时分秒,可能是不一样的。

如何表示 1970 年以前的时间呢?使用负数。

7.5.1.3 年历

我们都知道,中国有公历和农历之分,公历和农历都是年历,不同的年历,一年有多少月,每月有多少天,甚至一天有多少小时,这些可能都是不一样的。

比如,公历有闰年,闰年 2 月是 29 天,而其他年份则是 28 天,其他月份,有的是 30 天,有的是 31 天。农历有闰月,比如闰 7 月,一年就会有两个 7 月,一共 13 个月。

公历是世界上广泛采用的年历,除了公历,还有其他一些年历,比如日本也有自己的年历。Java API 的设计思想是支持国际化的,支持多种年历,但实际中没有直接支持中国的农历,本文主要讨论公历

简单总结下,时刻是一个绝对时间,对时刻的解读,如年月日周时分秒等,则是相对的,与年历和时区相关

7.5.2 日期和时间 API

Java API 中关于日期和时间,有三个主要的类:

  • Date:表示时刻,即绝对时间,与年月日无关。
  • Calendar:表示年历,Calendar 是一个抽象类,其中表示公历的子类是 GregorianCalendar
  • DateFormat:表示格式化,能够将日期和时间与字符串进行相互转换,DateFormat 也是一个抽象类,其中最常用的子类是 SimpleDateFormat。

还有两个相关的类:

  • TimeZone: 表示时区
  • Locale: 表示国家和语言

下面,我们来看这些类。

7.5.2.1 Date

Date 是 Java API 中最早引入的关于日期的类,一开始,Date 也承载了关于年历的角色,但由于不能支持国际化,其中的很多方法都已经过时了,被标记为了 @Deprecated,不再建议使用。

Date 表示时刻,内部主要是一个 long 类型的值,如下所示:

private transient long fastTime;

fastTime 表示距离纪元时的毫秒数,此处,关于 transient 关键字,我们暂时忽略。

Date 有两个构造方法:

public Date(long date) {
    
    
    fastTime = date;
}

public Date() {
    
    
    this(System.currentTimeMillis());
}

第一个构造方法,就是根据传入的毫秒数进行初始化,第二个构造方法是默认构造方法,它根据 System.currentTimeMillis() 的返回值进行初始化。

System.currentTimeMillis() 是一个常用的方法,它返回当前时刻距离纪元时的毫秒数

Date 中的大部分方法都已经过时了,其中没有过时的主要方法有:

public long getTime() // 返回毫秒数
public boolean equals(Object obj) // 判断与其他Date是否相同
// 与其他Date进行比较,如果当前Date的毫秒数小于参数中的,返回-1,相同返回0,否则返回1。
public int compareTo(Date anotherDate)
public boolean before(Date when) // 断是否在给定日期之前
public boolean after(Date when) // 断是否在给定日期之后
public int hashCode() // 哈希值算法与Long类似

7.5.2.2 TimeZone

TimeZone 表示时区,它是一个抽象类,有静态方法用于获取其实例

获取当前的默认时区,代码为:

TimeZone tz = TimeZone.getDefault();
System.out.println(tz.getID());

获取默认时区,并输出其 ID,在我的电脑上,输出为:

Asia/Shanghai

默认时区是在哪里设置的呢,可以更改吗?Java 中有一个系统属性,user.timezone,保存的就是默认时区,系统属性可以通过 System.getProperty 获得,如下所示:

System.out.println(System.getProperty("user.timezone"));

在我的电脑上,输出为:

Asia/Shanghai

系统属性可以在 Java 启动的时候传入参数进行更改,如

java -Duser.timezone=Asia/Shanghai xxxx

TimeZone 也有静态方法,可以获得任意给定时区的实例,比如:

获取美国东部时区

TimeZone tz = TimeZone.getTimeZone("US/Eastern");

ID 除了可以是名称外,还可以是 GMT 形式表示的时区,如:

TimeZone tz = TimeZone.getTimeZone("GMT+08:00");

7.5.2.3 国家和语言 Locale

Locale 表示国家和语言,它有两个主要参数,一个是国家,另一个是语言,每个参数都有一个代码,不过国家并不是必须的

比如说,中国的大陆代码是 CN,台湾地区的代码是 TW,美国的代码是 US,中文语言的代码是 zh,英文是 en。

Locale 类中定义了一些静态变量,表示常见的 Locale,比如:

  • Locale.US:表示美国英语
  • Locale.ENGLISH:表示所有英语
  • Locale.TAIWAN:表示台湾中文
  • Locale.CHINESE:表示所有中文
  • Locale.SIMPLIFIED_CHINESE:表示大陆中文

与 TimeZone 类似,Locale 也有静态方法获取默认值,如:

Locale locale = Locale.getDefault();
System.out.println(locale.toString());

在我的电脑上,输出为:

zh_CN

7.5.2.4 Calendar

Calendar 类是日期和时间操作中的主要类,它表示与 TimeZone 和 Locale 相关的日历信息,可以进行各种相关的运算

内部组成

我们先来看下它的内部组成。

与 Date 类似,Calendar 内部也有一个表示时刻的毫秒数,定义为:

protected long time;

除此之外,Calendar 内部还有一个数组,表示日历中各个字段的值,定义为:

protected int fields[];

这个数组的长度为 17,保存一个日期中各个字段的值,都有哪些字段呢?Calendar 类中定义了一些静态变量,表示这些字段,主要有:

  • Calendar.YEAR:表示年
  • Calendar.MONTH:表示月,一月份是 0,Calendar 同样定义了表示各个月份的静态变量,如 Calendar.JULY 表示 7 月。
  • Calendar.DAY_OF_MONTH:表示日,每月的第一天是 1。
  • Calendar.HOUR_OF_DAY:表示小时,从 0 到 23。
  • Calendar.MINUTE:表示分钟,0 到 59。
  • Calendar.SECOND:表示秒,0 到 59。
  • Calendar.MILLISECOND:表示毫秒,0 到 999。
  • Calendar.DAY_OF_WEEK:表示星期几,周日是 1,周一是 2,周六是 7,Calenar 同样定义了表示各个星期的静态变量,如 Calendar.SUNDAY 表示周日。

获取Calendar实例

Calendar 是抽象类,不能直接创建对象,它提供了四个静态方法,可以获取 Calendar 实例,分别为:

public static Calendar getInstance()
public static Calendar getInstance(Locale aLocale)
public static Calendar getInstance(TimeZone zone)
public static Calendar getInstance(TimeZone zone, Locale aLocale)

最终调用的方法都是需要 TimeZone 和 Locale 的,如果没有,则会使用上面介绍的默认值。getInstance 方法会根据 TimeZone 和 Locale 创建对应的 Calendar 子类对象,在中文系统中,子类一般是表示公历的 GregorianCalendar。

getInstance 方法封装了 Calendar 对象创建的细节,TimeZone 和 Locale 不同,具体的子类可能不同,但都是 Calendar,这种隐藏对象创建细节的方式,是计算机程序中一种常见的设计模式,它有一个名字,叫工厂方法,getInstance 就是一个工厂方法,它生产对象

获取日历信息

与 new Date() 类似,新创建的 Calendar 对象表示的也是当前时间,与 Date 不同的是,Calendar 对象可以方便的获取年月日等日历信息。

来看代码,输出当前时间的各种信息:

Calendar calendar = Calendar.getInstance();
System.out.println("year: "+calendar.get(Calendar.YEAR));
System.out.println("month: "+calendar.get(Calendar.MONTH));
System.out.println("day: "+calendar.get(Calendar.DAY_OF_MONTH));
System.out.println("hour: "+calendar.get(Calendar.HOUR_OF_DAY));
System.out.println("minute: "+calendar.get(Calendar.MINUTE));
System.out.println("second: "+calendar.get(Calendar.SECOND));
System.out.println("millisecond: " +calendar.get(Calendar.MILLISECOND));
System.out.println("day_of_week: " + calendar.get(Calendar.DAY_OF_WEEK));

具体输出与执行时的时间和默认的 TimeZone 以及 Locale 有关,在写作时,我的电脑上的输出为:

year: 2016
month: 7
day: 14
hour: 13
minute: 55
second: 51
millisecond: 564
day_of_week: 2

内部,Calendar 会将表示时刻的毫秒数,按照 TimeZone 和 Locale 对应的年历,计算各个日历字段的值,存放在 fields 数组中,Calendar.get 方法获取的就是 fields 数组中对应字段的值。

设置和修改时间

Calendar 支持根据 Date 或毫秒数设置时间:

public final void setTime(Date date)
public void setTimeInMillis(long millis)

也支持根据年月日等日历字段设置时间:

public final void set(int year, int month, int date)
public final void set(int year, int month, int date, int hourOfDay, int minute)
public final void set(int year, int month, int date, int hourOfDay, int minute, int second)
public void set(int field, int value)

除了直接设置,Calendar 支持根据字段增加和减少时间:

public void add(int field, int amount)

amount 为正数表示增加,负数表示减少。

比如说,如果想设置 Calendar 为第二天的下午 2 点 15,代码可以为:

Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.DAY_OF_MONTH, 1);
calendar.set(Calendar.HOUR_OF_DAY, 14);
calendar.set(Calendar.MINUTE, 15);
calendar.set(Calendar.SECOND, 0);
calendar.set(Calendar.MILLISECOND, 0);

Calendar 的这些方法中一个比较方便和强大的地方在于,它能够自动调整相关的字段。

比如说,我们知道二月份最多有 29 天,如果当前时间为 1 月 30 号,对 Calendar.MONTH 字段加 1,即增加一月,Calendar 不是简单的只对月字段加 1,那样日期是 2 月 30 号,是无效的,Calendar 会自动调整为 2 月最后一天,即 2 月 28 或 29。

再比如,设置的值可以超出其字段最大范围,Calendar 会自动更新其他字段,如:

Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.HOUR_OF_DAY, 48);
calendar.add(Calendar.MINUTE, -120);

相当于增加了 46 小时。

内部,根据字段设置或修改时间时,Calendar 会更新 fields 数组对应字段的值,但一般不会立即更新其他相关字段或内部的毫秒数的值,不过在获取时间或字段值的时候,Calendar 会重新计算并更新相关字段。

简单总结下,Calenar 做了一项非常繁琐的工作,根据 TimeZone 和 Locale,在绝对时间毫秒数和日历字段之间自动进行转换,且对不同日历字段的修改进行自动同步更新

除了 add,Calendar 还有一个类似的方法:

public void roll(int field, int amount)

与 add 的区别是,这个方法不影响时间范围更大的字段值。比如说:

Calendar calendar = Calendar.getInstance();
calendar.set(Calendar.HOUR_OF_DAY, 13);
calendar.set(Calendar.MINUTE, 59);
calendar.add(Calendar.MINUTE, 3);

calendar 首先设置为 13:59,然后分钟字段加 3,执行后的 calendar 时间为 14:02。如果 add 改为 roll,即:

calendar.roll(Calendar.MINUTE, 3);

则执行后的 calendar 时间会变为 13:02,在分钟字段上执行 roll 不会改变小时的值。

转换为 Date 或毫秒数

Calendar 可以方便的转换为 Date 或毫秒数,方法是:

public final Date getTime()
public long getTimeInMillis() 

Calendar 的比较

与 Date 类似,Calendar 之间也可以进行比较,也实现了 Comparable 接口,相关方法有:

public boolean equals(Object obj)
public int compareTo(Calendar anotherCalendar)
public boolean after(Object when)
public boolean before(Object when)

7.5.2.5 DateFormat

DateFormat 类主要在 Date 和字符串表示之间进行相互转换,它有两个主要的方法:

public final String format(Date date)
public Date parse(String source)

format 将 Date 转换为字符串,parse 将字符串转换为 Date

Date 的字符串表示与 TimeZone 和 Locale 都是相关的,除此之外,还与两个格式化风格有关,一个是日期的格式化风格,另一个是时间的格式化风格。

DateFormat 定义了四个静态变量,表示四种风格,SHORT、MEDIUM、LONG 和 FULL,还定义了一个静态变量 DEFAULT,表示默认风格,值为 MEDIUM,不同风格输出的信息详细程度不同。

与 Calendar 类似,DateFormat 也是抽象类,也用工厂模式创建对象,提供了多个静态方法创建 DateFormat 对象,有三类方法:

public final static DateFormat getDateTimeInstance()
public final static DateFormat getDateInstance()
public final static DateFormat getTimeInstance()

getDateTimeInstance 既处理日期也处理时间,getDateInstance 只处理日期,getTimeInstance 只处理时间,看下面代码:

Calendar calendar = Calendar.getInstance();
//2016-08-15 14:15:20
calendar.set(2016, 07, 15, 14, 15, 20);
System.out.println(DateFormat.getDateTimeInstance()
        .format(calendar.getTime()));
System.out.println(DateFormat.getDateInstance()
        .format(calendar.getTime()));
System.out.println(DateFormat.getTimeInstance()
        .format(calendar.getTime()));

输出为:

2016-8-15 14:15:20
2016-8-15
14:15:20

每类工厂方法都有两个重载的方法,接受日期和时间风格以及 Locale 作为参数:

DateFormat getDateTimeInstance(int dateStyle, int timeStyle)
DateFormat getDateTimeInstance(int dateStyle, int timeStyle, Locale aLocale)

比如,看下面代码:

Calendar calendar = Calendar.getInstance();
//2016-08-15 14:15:20
calendar.set(2016, 07, 15, 14, 15, 20);
System.out.println(DateFormat.getDateTimeInstance(
        DateFormat.LONG,DateFormat.SHORT,Locale.CHINESE)
        .format(calendar.getTime()));

输出为:

2016年8月15日 下午2:15

DateFormat 的工厂方法里,我们没看到 TimeZone 参数,不过,DateFormat 提供了一个 setter 方法,可以设置 TimeZone:

public void setTimeZone(TimeZone zone)

DateFormat 虽然比较方便,但如果我们要对字符串格式有更精确的控制,应该使用 SimpleDateFormat 这个类。

7.5.2.6 SimpleDateFormat

SimpleDateFormat 是 DateFormat 的子类,相比 DateFormat,它的一个主要不同是,它可以接受一个自定义的模式(pattern)作为参数,这个模式规定了 Date 的字符串形式

先看个例子:

Calendar calendar = Calendar.getInstance();
//2016-08-15 14:15:20
calendar.set(2016, 07, 15, 14, 15, 20);
SimpleDateFormat sdf = new SimpleDateFormat("yyyy年MM月dd日 E HH时mm分ss秒");
System.out.println(sdf.format(calendar.getTime()));

输出为:

2016年08月15日 星期一 14时15分20秒 

SimpleDateFormat 有个构造方法,可以接受一个 pattern 作为参数,这里 pattern 是:

yyyy年MM月dd日 E HH时mm分ss秒

pattern 中的英文字符 a-z 和 A-Z 表示特殊含义,其他字符原样输出,这里:

  • yyyy:表示四位的年
  • MM:表示月,两位数表示
  • dd:表示日,两位数表示
  • HH:表示24小时制的小时数,两位数表示
  • mm:表示分钟,两位数表示
  • ss:表示秒,两位数表示
  • E:表示星期几

这里需要特意提醒一下,hh 也表示小时数,但表示的是 12 小时制的小时数,而 a 表示的是上午还是下午,看代码:

Calendar calendar = Calendar.getInstance();
//2016-08-15 14:15:20
calendar.set(2016, 07, 15, 14, 15, 20);
SimpleDateFormat sdf = new SimpleDateFormat("yyyy/MM/dd hh:mm:ss a");
System.out.println(sdf.format(calendar.getTime()));

输出为:

2016/08/15 02:15:20 下午

更多的特殊含义可以参看 SimpleDateFormat 的 Java 文档。如果想原样输出英文字符,可以用单引号括起来。

除了将 Date 转换为字符串,SimpleDateFormat 也可以方便的将字符转化为 Date,看代码:

String str = "2016-08-15 14:15:20.456";
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
try {
    
    
    Date date = sdf.parse(str);
    SimpleDateFormat sdf2 = new SimpleDateFormat("yyyy年M月d h:m:s.S a");
    System.out.println(sdf2.format(date));
} catch (ParseException e) {
    
    
    e.printStackTrace();
}

输出为:

2016年8月15 2:15:20.456 下午

代码将字符串解析为了一个 Date 对象,然后使用另外一个格式进行了输出,这里 SSS 表示三位的毫秒数。

需要注意的是,parse 会抛出一个受检异常(checked exception),异常类型为 ParseException,调用者必须进行处理。

7.5.3 局限性

至此,关于 Java 1.8 之前的日期和时间相关 API 的主要内容,我们就介绍的差不多了,这里我们想强调一下这些 API 的一些局限性。

Date 中的过时方法

Date 中的方法参数与常识不符合,过时方法标记容易被人忽略,产生误用。比如说,看如下代码:

Date date = new Date(2016,8,15);
System.out.println(DateFormat.getDateInstance().format(date));

想当然的输出为 2016-08-15,但其实输出为:

3916-9-15

之所以产生这个输出,是因为,Date 构造方法中的 year 表示的是与 1900 年的差,month 是从 0 开始的。

Calendar操作比较啰嗦臃肿

Calendar API 的设计不是很成功,一些简单的操作都需要多次方法调用,写很多代码,比较啰嗦臃肿

另外,Calendar 难以进行比较复杂的日期操作,比如,计算两个日期之间有多少个月,根据生日计算年龄,计算下个月的第一个周一等。

下一节,我们会介绍 Joda-Time,相比 Calendar,Joda-Time 要简洁方便的多。

DateFormat的线程安全性

DateFormat/SimpleDateFormat 不是线程安全的。关于线程概念,后续文章我们会详解,这里简单说明一下,多个线程同时使用一个 DateFormat 实例的时候,会有问题,因为 DateFormat 内部使用了一个 Calendar 实例对象,多线程同时调用的时候,这个 Calendar 实例的状态可能就会紊乱。

解决这个问题大概有以下方案:

  • 每次使用 DateFormat 都新建一个对象
  • 使用线程同步
  • 使用 ThreadLocal
  • 使用 Joda-Time,Joda-Time 是线程安全的

7.6 随机

本节,我们来讨论随机,随机是计算机程序中一个非常常见的需求,比如说:

  • 各种游戏中有大量的随机,比如扑克游戏洗牌
  • 微信抢红包,抢的红包金额是随机的
  • 北京购车摇号,谁能摇到是随机的
  • 给用户生成随机密码

我们首先来介绍 Java 中对随机的支持,同时介绍其实现原理,然后我们针对一些实际场景,包括洗牌、抢红包、摇号、随机高强度密码、带权重的随机选择等,讨论如何应用随机。

先来看如何使用最基本的随机。

7.6.1 Math.random

Java 中,对随机最基本的支持是 Math 类中的静态方法 random,它生成一个 0 到 1 的随机数,类型为 double,包括 0 但不包括 1。比如,随机生成并输出 3 个数:

for(int i=0;i<3;i++){
    
    
    System.out.println(Math.random());
}

我的电脑上的一次运行,输出为:

0.4784896133823269
0.03012515628333423
0.7921024363953197

每次运行,输出都不一样。

Math.random() 是如何实现的呢?我们来看相关代码:

private static Random randomNumberGenerator;

private static synchronized Random initRNG() {
    
    
    Random rnd = randomNumberGenerator;
    return (rnd == null) ? (randomNumberGenerator = new Random()) : rnd;
}

public static double random() {
    
    
    Random rnd = randomNumberGenerator;
    if (rnd == null) rnd = initRNG();
    return rnd.nextDouble();
}

内部它使用了一个 Random 类型的静态变量 randomNumberGenerator,调用 random() 就是调用该变量的 nextDouble() 方法,这个 Random 变量只有在第一次使用的时候才创建

下面我们来看这个 Random 类,它位于包 java.util 下。

7.6.2 Random

7.6.2.1 基本用法

Random 类提供了更为丰富的随机方法,它的方法不是静态方法,使用 Random,先要创建一个 Random 实例,看个例子:

Random rnd = new Random();
System.out.println(rnd.nextInt());
System.out.println(rnd.nextInt(100));

我的电脑上的一次运行,输出为:

-1516612608
23

nextInt() 产生一个随机的 int,可能为正数,也可能为负数。nextInt(100) 产生一个随机 int,范围是 0 到 100,包括 0 不包括 100

除了 nextInt,还有一些别的方法。

随机生成一个 long

public long nextLong()

随机生成一个 boolean

public boolean nextBoolean()

产生随机字节

public void nextBytes(byte[] bytes)

随机产生的字节放入提供的 byte 数组 bytes,字节个数就是 bytes 的长度。

产生随机浮点数,从 0 到 1,包括 0 不包括 1

public float nextFloat()
public double nextDouble()

7.6.2.2 设置种子

除了默认构造方法,Random 类还有一个构造方法,可以接受一个 long 类型的种子参数:

public Random(long seed)

种子决定了随机产生的序列,种子相同,产生的随机数序列就是相同的。看个例子:

Random rnd = new Random(20160824);
for(int i=0;i<5;i++){
    
    
    System.out.print(rnd.nextInt(100)+" ");
}

种子为 20160824,产生 5 个 0 到 100 的随机数,输出为:

69 13 13 94 50 

这个程序无论执行多少遍,在哪执行,输出结果都是相同的

除了在构造方法中指定种子,Random 类还有一个 setter 实例方法:

synchronized public void setSeed(long seed)

其效果与在构造方法中指定种子是一样的。

为什么要指定种子呢?指定种子还是真正的随机吗?

指定种子是为了实现可重复的随机。比如用于模拟测试程序中,模拟要求随机,但测试要求可重复。在北京购车摇号程序中,种子也是指定的,后面我们还会介绍。

种子到底扮演了什么角色呢?随机到底是如何产生的呢?让我们看下随机的基本原理。

7.6.3 随机的基本原理

Random 产生的随机数不是真正的随机数,相反,它产生的随机数一般称之为伪随机数,真正的随机数比较难以产生,计算机程序中的随机数一般都是伪随机数。

伪随机数都是基于一个种子数的,然后每需要一个随机数,都是对当前种子进行一些数学运算,得到一个数,基于这个数得到需要的随机数和新的种子

数学运算是固定的,所以种子确定后,产生的随机数序列就是确定的,确定的数字序列当然不是真正的随机数,但种子不同,序列就不同,每个序列中数字的分布也都是比较随机和均匀的,所以称之为伪随机数

Random 的默认构造方法中没有传递种子,它会自动生成一个种子,这个种子数是一个真正的随机数,代码如下:

private static final AtomicLong seedUniquifier
    = new AtomicLong(8682522807148012L);
    
public Random() {
    
    
    this(seedUniquifier() ^ System.nanoTime());
}

private static long seedUniquifier() {
    
    
    for (;;) {
    
    
        long current = seedUniquifier.get();
        long next = current * 181783497276652981L;
        if (seedUniquifier.compareAndSet(current, next))
            return next;
    }
}

种子是 seedUniquifier() 与 System.nanoTime() 按位异或的结果,System.nanoTime() 返回一个更高精度(纳秒)的当前时间,seedUniquifier() 里面的代码涉及一些多线程相关的知识,我们后续章节再介绍,简单的说,就是返回当前 seedUniquifier(current) 与一个常数 181783497276652981L 相乘的结果(next),然后,将 seedUniquifier 设置为 next,使用循环和 compareAndSet 都是为了确保在多线程的环境下不会有两次调用返回相同的值,保证随机性。

有了种子数之后,其他数是怎么生成的呢?我们来看一些代码:

public int nextInt() {
    
    
    return next(32);
}

public long nextLong() {
    
    
    return ((long)(next(32)) << 32) + next(32);
}

public float nextFloat() {
    
    
    return next(24) / ((float)(1 << 24));
}
public boolean nextBoolean() {
    
    
    return next(1) != 0;
}

它们都调用了 next(int bits),生成指定位数的随机数,我们来看下它的代码:

private static final long multiplier = 0x5DEECE66DL;
private static final long addend = 0xBL;
private static final long mask = (1L << 48) - 1;
protected int next(int bits) {
    
    
    long oldseed, nextseed;
    AtomicLong seed = this.seed;
    do {
    
    
        oldseed = seed.get();
        nextseed = (oldseed * multiplier + addend) & mask;
    } while (!seed.compareAndSet(oldseed, nextseed));
    return (int)(nextseed >>> (48 - bits));
}

简单的说,就是使用了如下公式:

nextseed = (oldseed * multiplier + addend) & mask;

旧的种子(oldseed)乘以一个数(multiplier),加上一个数 addend,然后取低 48 位作为结果(mask相与)。

为什么采用这个方法?这个方法为什么可以产生随机数?这个方法的名称叫线性同余随机数生成器(linear congruential pseudorandom number generator),描述在《计算机程序设计艺术》一书中。随机的理论是一个比较复杂的话题,超出了本文的范畴,我们就不讨论了。

我们需要知道的基本原理是,随机数基于一个种子,种子固定,随机数序列就固定,默认构造方法中,种子是一个真正的随机数

理解了随机的基本概念和原理,我们来看一些应用场景,从产生随机密码开始。

7.6.4 随机密码

在给用户生成账号时,经常需要给用户生成一个默认随机密码,然后通过邮件或短信发给用户,作为初次登录使用。

我们假定密码是 6 位数字,代码很简单,如下所示:

public static String randomPassword(){
    
    
    char[] chars = new char[6];
    Random rnd = new Random();
    for(int i=0; i<6; i++){
    
    
        chars[i] = (char)('0'+rnd.nextInt(10));
    }
    return new String(chars);
}

代码很简单,就不解释了。如果要求是 8 位密码,字符可能有大写字母、小写字母、数字和特殊符号组成,代码可能为

private static final String SPECIAL_CHARS = "!@#$%^&*_=+-/";

private static char nextChar(Random rnd){
    
    
    switch(rnd.nextInt(4)){
    
    
    case 0:
        return (char)('a'+rnd.nextInt(26));
    case 1:
        return (char)('A'+rnd.nextInt(26));
    case 2:
        return (char)('0'+rnd.nextInt(10));
    default:
        return SPECIAL_CHARS.charAt(rnd.nextInt(SPECIAL_CHARS.length()));
    }
}

public static String randomPassword(){
    
    
    char[] chars = new char[8];
    Random rnd = new Random();
    for(int i=0; i<8; i++){
    
    
        chars[i] = nextChar(rnd);
    }
    return new String(chars);
}

这个代码,对每个字符,先随机选类型,然后在给定类型中随机选字符。在我的电脑上,一次的随机运行结果是:

8Ctp2S4H

这个结果不含特殊字符,很多环境对密码复杂度有要求,比如说,至少要含一个大写字母、一个小写字母、一个特殊符号、一个数字。以上的代码满足不了这个要求,怎么满足呢?一种可能的代码是:

private static int nextIndex(char[] chars, Random rnd){
    
    
     int index = rnd.nextInt(chars.length);
     while(chars[index]!=0){
    
    
        index = rnd.nextInt(chars.length);
     }
     return index;
}

private static char nextSpecialChar(Random rnd){
    
    
    return SPECIAL_CHARS.charAt(rnd.nextInt(SPECIAL_CHARS.length()));
}
private static char nextUpperlLetter(Random rnd){
    
    
    return (char)('A'+rnd.nextInt(26));
}
private static char nextLowerLetter(Random rnd){
    
    
    return (char)('a'+rnd.nextInt(26));
}
private static char nextNumLetter(Random rnd){
    
    
    return (char)('0'+rnd.nextInt(10));
}
public static String randomPassword(){
    
    
    char[] chars = new char[8];
    Random rnd = new Random();
    
    chars[nextIndex(chars, rnd)] = nextSpecialChar(rnd);
    chars[nextIndex(chars, rnd)] = nextUpperlLetter(rnd);
    chars[nextIndex(chars, rnd)] = nextLowerLetter(rnd);
    chars[nextIndex(chars, rnd)] = nextNumLetter(rnd);
    
    for(int i=0; i<8; i++){
    
    
        if(chars[i]==0){
    
    
            chars[i] = nextChar(rnd);    
        }
    }
    return new String(chars);
}

nextIndex 随机生成一个未赋值的位置,程序先随机生成四个不同类型的字符,放到随机位置上,然后给未赋值的其他位置随机生成字符。

7.6.5 洗牌

一种常见的随机场景是洗牌,就是将一个数组或序列随机重新排列,我们以一个整数数组为例来看,怎么随机重排呢?我们直接看代码:

private static void swap(int[] arr, int i, int j){
    
    
    int tmp = arr[i];
    arr[i] = arr[j];
    arr[j] = tmp;
}

public static void shuffle(int[] arr){
    
    
    Random rnd = new Random();
    for(int i=arr.length; i>1; i--) {
    
    
        swap(arr, i-1, rnd.nextInt(i));
    }
}

shuffle 这个方法就能将参数数组 arr 随机重排,来看使用它的代码:

int[] arr = new int[13];
for(int i=0; i<arr.length; i++){
    
    
    arr[i] = i;
}
shuffle(arr);
System.out.println(Arrays.toString(arr));

调用 shuffle 前,arr 是排好序的,调用后,一次调用的输出为:

[3, 8, 11, 10, 7, 9, 4, 1, 6, 12, 5, 0, 2]

已经随机重新排序了。

shuffle 的基本思路是什么呢?从后往前,逐个给每个数组位置重新赋值,值是从剩下的元素中随机挑选的。在如下关键语句中,

swap(arr, i-1, rnd.nextInt(i));

i-1 表示当前要赋值的位置,rnd.nextInt(i) 表示从剩下的元素中随机挑选。

7.6.6 带权重的随机选择

实际场景中,经常要从多个选项中随机选择一个,不过,不同选项经常有不同的权重。

比如说,给用户随机奖励,三种面额,1 元、5 元和 10 元,权重分别为 70,20 和 10。这个怎么实现呢?

实现的基本思路是,使用概率中的累计概率分布

以上面的例子来说,计算每个选项的累计概率值,首先计算总的权重,这里正好是 100,每个选项的概率是 70%,20% 和 10%,累计概率则分别是 70%,90% 和 100%。

在这里插入图片描述

有了累计概率,则随机选择的过程是,使用 nextDouble() 生成一个 0 到 1 的随机数,然后使用二分查找,看其落入那个区间,如果小于等于 70% 则选择第一个选项,70% 和 90% 之间选第二个,90% 以上选第三个,如图 7-2 所示。

下面来看代码,我们使用一个类 Pair 表示选项和权重,代码为:

class Pair {
    
    
    Object item;
    int weight;
    
    public Pair(Object item, int weight){
    
    
        this.item = item;
        this.weight = weight;
    }

    public Object getItem() {
    
    
        return item;
    }

    public int getWeight() {
    
    
        return weight;
    }
}

我们使用一个类 WeightRandom 表示带权重的选择,代码为:

public class WeightRandom {
    
    
    private Pair[] options;
    private double[] cumulativeProbabilities;
    private Random rnd;
    
    public WeightRandom(Pair[] options){
    
    
        this.options = options;
        this.rnd = new Random();
        prepare();
    }
    
    private void prepare(){
    
    
        int weights = 0;
        for(Pair pair : options){
    
    
            weights += pair.getWeight();
        }
        cumulativeProbabilities = new double[options.length];
        int sum = 0;
        for (int i = 0; i<options.length; i++) {
    
    
            sum += options[i].getWeight();
            cumulativeProbabilities[i] = sum / (double)weights;
        }
    }
    
    public Object nextItem(){
    
    
        double randomValue = rnd.nextDouble();

        int index = Arrays.binarySearch(cumulativeProbabilities, randomValue);
        if (index < 0) {
    
    
            index = -index-1;
        }
        return options[index].getItem();
    }
}

其中,prepare 方法计算每个选项的累计概率,保存在数组 cumulativeProbabilities 中,nextItem() 根据权重随机选择一个,具体就是,首先生成一个 0 到 1 的数,然后使用二分查找,以前介绍过,如果没找到,返回结果是 (-(插入点)-1),所以 -index-1 就是插入点,插入点的位置就对应选项的索引。

回到上面的例子,随机选择 10 次,代码为:

Pair[] options = new Pair[]{
    
    
        new Pair("1元",7),
        new Pair("2元", 2),
        new Pair("10元", 1)
};
WeightRandom rnd = new WeightRandom(options);
for(int i=0; i<10; i++){
    
    
    System.out.print(rnd.nextItem()+" ");
}

在一次运行中,输出正好符合预期,具体为:

1元 1元 1元 2元 1元 10元 1元 2元 1元 1元 

不过,需要说明的,由于随机,每次执行结果比例不一定正好相等。

7.6.7 抢红包算法

我们都知道,微信可以抢红包,红包有一个总金额和总数量,领的时候随机分配金额,金额是怎么随机分配的呢?微信具体是怎么做的,我们并不能确切的知道,根据一些公开资料,思路可能如下。

维护一个剩余总金额和总数量,分配时,如果数量等于 1,直接返回总金额,如果大于 1,则计算平均值,并设定随机最大值为平均值的两倍,然后取一个随机值,如果随机值小于 0.01,则为 0.01,这个随机值就是下一个的红包金额。

我们来看代码,为计算方便,金额我们用整数表示,以分为单位。

public class RandomRedPacket {
    
    

    private int leftMoney;
    private int leftNum;
    private Random rnd;
    
    public RandomRedPacket(int total, int num){
    
    
        this.leftMoney = total;
        this.leftNum = num;
        this.rnd = new Random();
    }
    
    public synchronized int nextMoney(){
    
    
        if(this.leftNum<=0){
    
    
            throw new IllegalStateException("抢光了");
        }
        if(this.leftNum==1){
    
    
            return this.leftMoney;
        }
        double max = this.leftMoney/this.leftNum*2d;
        int money = (int)(rnd.nextDouble()*max);
        money = Math.max(1, money);
        this.leftMoney -= money;
        this.leftNum --;
        
        return money;
    }
}

代码比较简单,就不解释了。我们来看一个使用的例子,总金额为 10 元,10 个红包,代码如下:

RandomRedPacket redPacket = new RandomRedPacket(1000, 10);
for(int i=0; i<10; i++){
    
    
    System.out.print(redPacket.nextMoney()+" ");
}

一次输出为:

136 48 90 151 36 178 92 18 122 129 

如果是这个算法,那先抢好,还是后抢好呢?先抢肯定抢不到特别大的,不过,后抢也不一定会,这要看前面抢的金额,剩下的多就有可能抢到大的,剩下的少就不可能有大的。

7.6.8 北京购车摇号算法

我们来看下影响很多人的北京购车摇号,它的算法是怎样的呢?根据公开资料,它的算法大概是这样的。

  1. 每期摇号前,将每个符合摇号资格的人,分配一个从 0 到总数的编号,这个编号是公开的,比如总人数为 2304567,则编号从 0 到 2304566。
  2. 摇号第一步是生成一个随机种子数,这个随机种子数在摇号当天通过一定流程生成,整个过程由公证员公证,就是生成一个真正的随机数。
  3. 种子数生成后,然后就是循环调用类似 Random.nextInt(int n) 方法,生成中签的编号。

编号是事先确定的,种子数是当场公证随机生成的,公开的,随机算法是公开透明的,任何人都可以根据公开的种子数和编号验证中签的编号。

7.6.9 一些说明

需要说明的是,Random 类是线程安全的,也就是说,多个线程可以同时使用一个 Random 实例对象,不过,如果并发性很高,会产生竞争,这时,可以考虑使用多线程库中的 ThreadLocalRandom 类

另外,Java 类库中还有一个随机类 SecureRandom,以产生安全性更高、随机性更强的随机数,用于安全加密等领域。

这两个类本文就不介绍了。

猜你喜欢

转载自blog.csdn.net/bm1998/article/details/107967969