《Java 程式設計的邏輯》筆記——第7章 常用基礎類(三)

2020-08-12 22:14:08

宣告:

本部落格是本人在學習《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,以產生安全性更高、隨機性更強的亂數,用於安全加密等領域。

這兩個類本文就不介紹了。