11. 常用基础类
11.1. 包装类
Java 有 8 中基本类型,每种类型都有一种对应的包装类。具体的对应关系如下:
| 基本类型 | 包装类 | 基本类型 | 包装类 |
|---|---|---|---|
boolean | Boolean | long | Long |
byte | Byte | float | Float |
short | Short | double | Double |
int | Integer | char | Character |
11.1.1. 装箱与拆箱
Java 里面基本数据类型与其对应的包装类型是可以互相转换的,这称为装箱与拆箱。将基本类型转换成包装类型叫做装箱,反之叫做拆箱。
boolean b1 = false;
Boolean bObj = Boolean.valueOf(b1);
boolean b2 = bObj.booleanValue();
上面使用boolean类型举例子,实际上所有基本类型都是相似的。使用对应包装类型的静态方法valueOf进行装箱,拆箱只需要调用对象的xxxValue方法即可。
装箱与拆箱的操作比较繁琐,所以在 Java5 之后引入了自动装箱与拆箱技术。简单说就是可以直接相互赋值:
boolean b1 = false;
Boolean bObj = b1;
boolean b2 = bObj;
自动拆装箱是编译器提供的能力,实际上,背后还是做的上面那一套。
每种包装类都有对应的构造方法,例如Boolean bObj = new Boolean(true);。所以我们有两种方法从基本类型转到包装类型:一个是使用valueOf方法,一个是使用new。两者的区别主要是,new出来的对象每次都是新的,而valueOf方法则会维护一个缓存(Float 与 Double除外)提升效率。实际上,从 Java9 开始这些构造方法已经被标记过时了。
11.1.2. 共同点
所有包装类都实现了Object类的一些方法并实现了Comparable接口。
11.1.2.1. equals
equals方法用来判断当前对象与传进来的对象是否相同。Object的默认实现是比较两个对象的引用是否相同(这和==运算符的含义是一样的),这在许多时候是不合适的。对于包装类型而言,我们的比较依据应该是它们值的大小关系,所以所有包装类型都有自己的实现。
对于Long类型,其equals实现如下:
public boolean equals(Object obj) {
if(obj instanceof Long) {
return value == ((Long)obj).longValue();
}
return false;
}
对于Float类型,其实现如下:
public boolean equals(Object obj) {
return (obj instanceof Float)
&& (floatToIntBits(((Float)obj).value) == floatToIntBits(value));
}
从实现可以看出浮点数比较看的是静态函数floatToIntBits的返回值,这个函数指的是将浮点数的二进制看做一个int。
对于Double类型会调用doubleToLongBits方法,将Double的二进制看做一个long,然后进行比较。
11.1.2.2. hashCode
hashCode返回一个对象的哈希值。哈希值是一个int类型的数,对象的哈希值不能改变。同一个对象的哈希值必须一样,不同对象的哈希值一般应不同。
hashCode与equals方法有密切联系。对两个对象来说,若equals返回true则hashCode必须一样,equals返回false不做要求。hashCode默认实现是将对象地址转成整数,子类若是重写equals方法必须也重写hashCode方法。
public int hashCode() {
return (int)value;
}
Byte、Short、Integer、Character类直接使用其值作为哈希值。
public int hashCode() {
return value ? 1231 : 1237;
}
Boolean类型根据真假返回两个质数。
public int hashCode() {
return(int)(value ^ (value >>> 32));
}
Long类型将高 32 位与低 32 位做异或作为返回值。
public int hashCode() {
return floatToIntBits(value);
}
public int hashCode() {
long bits = doubleToLongBits(value);
return(int)(bits ^ (bits >>> 32));
}
对于浮点数,将其二进制看做整数然后执行一样的操作。Float将看做Integer,Double将看做Long。
11.1.2.3. compareTo
每个包装类都实现了Comparable接口,接口里面只有compareTo一个方法。这个方法将当前对象与参数对象作比较,返回一个整数。整数符号为正表示大于,符号为负表示小于,整数为 0 表示相等。
各个包装类的实现都是使用基本类型值进行比较,对于Boolean类型来说,false小于true。
11.1.3. 包装类与字符串
11.1.3.1. 字符串转包装类
各包装类都有valueOf静态方法将字符串转成对应的包装类,这个方法上面还讲过另一种重载形式——将基本类型转成对应的包装类型。
Boolean b = Boolean.valueOf("false");
11.1.3.2. 字符串转基本类型
各包装类还有parseXXX静态方法将字符串转成基本数据类型。
boolean b = Boolean.parseBoolean("false");
11.1.3.3. 基本类型转字符串
各包装类还有静态的toString方法,接受基本数据数据,将之转成字符串。
String s = Boolean.toString(true);
11.1.4. 常用常量
包装类中还定义了一些静态常量,对于Boolean类来说,定义了:
public static final Boolean TRUE = new Boolean(true);
public static final Boolean FALSE = new Boolean(false);
所有数值类型都定义了MAX_VALUE和MIN_VALUE表示该类型能表示的最大或最小范围,对于Integer来说:
public static final int MIN_VALUE = 0x80000000;
public static final int MAX_VALUE = 0x7fffffff;
Float和Double还定义了特殊数值,例如对Double而言:
public static final double POSITIVE_INFINITY = 1.0 / 0.0; // 正无穷
public static final double NEGATIVE_INFINITY = -1.0 / 0.0; // 负无穷
public static final double NaN = 0.0d / 0.0; // 非数值
11.1.5. Number
6 中数值类型的包装类都继承于Number类,这是一个抽象类。它定义了如下方法:
byte byteValue()
short shortValue()
int intValue()
long longValue()
float floatValue()
double doubleValue()
通过这些方法,包装类能返回任意类型的基本类型。但如果类型不匹配怎么办?
例如对于Integer i = 100,i.byteValue()返回对byte强转后的结果。
11.1.6. 不可变性
包装类都是不可变的,即对象一旦创建就无法修改。这主要通过下面的方式:
- 所有包装类都声明为
final,不可被继承。 - 内部基本类型值都是
final的,且都是私有的。 - 不提供
setter来修改基本类型值。
11.2. 剖析 Integer
Long和Integer相似,所以不多说。本节主要研究一些二进制操作。
11.2.1. 位翻转
11.2.1.1. 用法
位翻转指的是首尾互换,类似于求逆序。Integer有两个静态方法实现翻转:
public static int reverse(int i)
public static int reverseBytes(int i)
其中reverse表示按位翻转,reverseBytes表示按字节翻转。看个例子:
public static void main(String[] args) {
int a = 0x12345678;
System.out.println(Integer.toBinaryString(a));
int r = Integer.reverse(a);
System.out.println(Integer.toBinaryString(r));
int rb = Integer.reverseBytes(a);
System.out.println(Integer.toHexString(rb));
}
这个例子会输出:
10010001101000101011001111000
11110011010100010110001001000
78563412
对于按字节翻转,原来的数字是0x12345678,按字节翻转后是0x78563412。我们知道一个字节是 8 位,而十六进制数里面每个数字都是 4 位,所以一个字节表示十六进制里面的两位数。因此原来数字里面分为12、34、56、78这四个字节,那么翻转之后就是78、56、34、12。
对于按位翻转,看上面的结果似乎不对。但实际上,上面的输出结果并不是 32 位,它们前面还有前导 0 被省略掉了,补上之后的输出应该是:
00010010001101000101011001111000
00011110011010100010110001001000
这样看来,按位翻转确实是首尾交换没错了。
11.2.1.2. 按字节翻转实现
public static int reverseBytes(int i) {
return ((i >>> 24) ) |
((i >> 8) & 0xFF00) |
((i << 8) & 0xFF0000) |
((i << 24));
}
左边是按位翻转的源代码,这个代码其实还是比较好理解的,主体思想就是:将每个字节放到它该在的位置,其它位置置为 0,最后将这些数字或在一起即可。
11.2.1.3. 按位翻转实现
按照一样的思想,我们可以实现按位翻转。但问题是一个Integer有 32 位,按照上面的做法,我们需要将 32 个数字或在一起,很麻烦。下面看看源码的实现:
public static int reverse(int i) {
//HD, Figure 7-1
i = (i & 0x55555555) << 1 | (i >>> 1) & 0x55555555;
i = (i & 0x33333333) << 2 | (i >>> 2) & 0x33333333;
i = (i & 0x0f0f0f0f) << 4 | (i >>> 4) & 0x0f0f0f0f;
i = (i << 24) | ((i & 0xff00) << 8) |
((i >>> 8) & 0xff00) | (i >>> 24);
return i;
}
代码注释“HD”表示一本书《Hacker's Delight》,中文翻译为《算法心得:高效算法的奥秘》。算法的主要思路就是交换,首先相邻位之间做交换;接着两位为一组,相邻组两两交换;4 位一组,组间两两交换;8 位一组,直接按字节交换结束。
十进制数也能使用这个算法:12345678→21 43 65 87→4321 8765→87654321。
对于十进制而言使用这样的方法效率不高,但二进制使用这样的方法效率很高。我们首先两两交换每一位,代码如下:
i = (i & 0x55555555) << 1 | (i & 0xAAAAAAAA) >>> 1
5 的二进制是0101,A 的二进制是1010。因此i & 0x55555555是取 i 的奇数位(从右开始数起),i & 0xAAAAAAAA是取 i 的偶数位。然后奇数位左移 1 位,偶数位右移 1 位,最后两者或在一起就实现了两两交换的操作。
这个思路还可以有一点优化i = (i & 0x55555555) << 1 | (i >>> 1) & 0x55555555。这边只使用了一个常量0x55555555实现,先将 i 右移 1 位,那么偶数位自然变成奇数位。
同理,实现 2 位一组的交换的代码如下(3 的二进制是0011,C 的二进制是1100):
i = (i & 0x33333333) << 2 | (i & 0xCCCCCCCC) >>> 2 // 优化前
i = (i & 0x33333333) << 2 | (i >>> 2) & 0x33333333 // 优化后
实现 4 位一组交换的代码如下:
i = (i & 0x0f0f0f0f) << 4 | (i & 0xf0f0f0f0) >>> 4 // 优化前
i = (i & 0x0f0f0f0f) << 4 | (i >>> 4) & 0x0f0f0f0f // 优化后
11.2.2. 循环移位
Integer类有两个静态方法实现循环移位,分别表示循环左移和循环右移。
public static int rotateLeft(int i, int distance)
public static int rotateRight(int i, int distance)
循环移位与普通移位的差距在于位溢出的时候会放到另一端,而不是补 0。
public static void main(String[] args) {
int a = 0x12345678;
int b = Integer.rotateLeft(a, 8);
println(Integer.toHexString(b));
int c = Integer.rotateRight(a, 8);
println(Integer.toHexString(c));
}
左边程序的运行结果是:
34567812
78123456
这两个函数的实现如下:
public static int rotateLeft(int i, int distance) {
return (i << distance) | (i >>> -distance);
}
public static int rotateRight(int i, int distance) {
return (i >>> distance) | (i << -distance);
}
实现里面令人费解的是移动负数。实际上在进行移位的时候,不是直接使用后面的数字作为移动的位数,而是使用后面数字的低 5 位作为移动的位数。因为我们知道Integer一共是 32 位,移位的时候最多移 31 位,再多就没有意义了,而 5 位能表示的最大范围正好是 31,所以会选取后面数字的低 5 位作为移动位数。例如i >>> -8,-8 的二进制是...11000,所以实际上移位的时候会移动 24 位。
了解了这个,那么我们看看实现的逻辑。对于 5 位的整数k,-k是对k取反加 1,因此-k + k的二进制会是100000。最后我们发现,移动-k位实际上是移动32 - k位。基于此,源码的实现是很好理解的。
11.2.3. valueOf 方法的实现
之前我们就说过,创建包装类的时候推荐使用valueOf方法而不是直接new。现在我们看看valueOf的实现:
public static Integer valueOf(int i) {
assert IntegerCache.high >= 127;
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
可以看到使用valueOf里面有一个缓存,每次创建的时候优先拿缓存而不是创建。而包装类都是不可变的,缓存被拿去用不用担心被篡改,非常合理。我们再看看缓存内部是什么:
private static class IntegerCache {
static final int low = -128;
static final int high;
static final Integer cache[];
static {
//high value may be configured by property
int h = 127;
String integerCacheHighPropValue =
sun.misc.VM.getSavedProperty(
"java.lang.Integer.IntegerCache.high");
if(integerCacheHighPropValue != null) {
int i = parseInt(integerCacheHighPropValue);
i = Math.max(i, 127);
//Maximum array size is Integer.MAX_VALUE
h = Math.min(i, Integer.MAX_VALUE - (-low) -1);
}
high = h;
cache = new Integer[(high - low) + 1];
int j = low;
for(int k = 0; k < cache.length; k++)
cache[k] = new Integer(j++);
}
private IntegerCache() {}
}
代码不难理解,默认情况下缓存的范围是[-128, 127]。这种共享常用对象的方式是一种设计模式,称为享元模式。
11.3. 剖析 String
字符串操作是计算机程序中最常用的操作之一,Java 操作字符串的类主要是String和StringBuilder类。
11.3.1. 基本用法
String s1 = "卢研";
String s2 = new String("真帅");
创建字符串对象可以直接使用常量赋值或者使用new创建。
s1 += s2;
System.out.println(s1 + "!");
字符串对象之间可以使用+表示字符串的连接。还可以使用+=表示连接并赋值。
String类里面有很多方法可以处理字符串,比如:
// 返回字符串是否为空串,空串指的是 length 为 0 的串
public boolean isEmpty()
// 返回字符串的长度即字符个数
public int length()
// 截取范围 [beginIndex, length) 的子串,下标越界会报错
public String substring(int beginIndex)
// 截取范围 [beginIndex, endIndex) 的子串,下标越界会报错
public String substring(int beginIndex, int endIndex)
// 返回字符 ch 在串里的下标(从左到右第一个),找不到返回 -1
public int indexOf(int ch)
// 返回字符串 str 在串里的下标(从左到右第一个),找不到返回 -1
public int indexOf(String str)
// 返回字符 ch 在串里的下标(从右到左第一个),找不到返回 -1
public int lastIndexOf(int ch)
// 返回字符串 str 在串里的下标(从右到左第一个),找不到返回 -1
public int lastIndexOf(String str)
// 返回字符串是否包含序列 s
public boolean contains(CharSequence s)
// 返回字符串是否以 prefix 开头
public boolean startsWith(String prefix)
// 返回字符串是否以 suffix 结尾
public boolean endsWith(String suffix)
// 返回两个串内容是否一样
public boolean equals(Object anObject)
// 返回两个串内容是否一样(忽略大小写)
public boolean equalsIgnoreCase(String anotherString)
// 比较两个串大小,返回一个整数,其符号表示大小关系
public int compareTo(String anotherString)
// 同上,但忽略大小写
public int compareToIgnoreCase(String str)
// 字符串转大写,返回的是新串,原串不变
public String toUpperCase()
// 字符串转小写,返回的是新串,原串不变
public String toLowerCase()
// 连接两个字符串,返回连接后的结果。返回的是新串,原串不变
public String concat(String str)
// 字符替换,将串里所有的 oldChar 替换成 newChar
public String replace(char oldChar, char newChar)
// 序列替换,将串里所有的 target 替换成 replacement
public String replace(CharSequence target, CharSequence replacement)
// 删除首尾的空白字符
public String trim()
// 使用 regex 分割字符串,返回分割后的字符串数组
public String[] split(String regex)
11.3.2. 走进 String
字符串内部使用char数组表示字符串,实例变量的定义为:
private final char value[];
String有两个和字符数组相关的构造方法:
public String(char value[])
public String(char value[], int offset, int count)
需要说明的是,String会创建一个新数组并将参数的字符复制进去,而不会直接使用参数数组。String里面大部分方法都是操作value数组:
length()方法返回的就是value数组的长度。substring()方法是根据参数调用构造String(char value[], int offset, int count)创建新字符串并返回。
还有很多方法就不一一列举。除此之外,还有很多方法和value数组相关:
// 返回 index 下标处的字符
public char charAt(int index)
// 返回字符串的数组表示,返回的是 value 的副本不是本身
public char[] toCharArray()
// 获取 value 数组 [srcBegin, srcEnd) 的内容放到数组 dst 下标 dstBegin 开始处
public void getChars(int srcBegin, int srcEnd, char dst[], int dstBegin)
11.3.3. 编码转换
Java 使用Charset类表示编码,它有两个常用的静态方法:
public static Charset defaultCharset()
public static Charset forName(String charsetName)
第一个方法返回系统的默认编码,第二个方法返回给定名称的Charset对象。常见的名称有UTF-8、GBK、windows-1252等。
Charset charset = Charset.forName("UTF-8");
String提供了以下方法返回字符串按照指定编码的字节表示:
public byte[] getBytes()
public byte[] getBytes(String charsetName)
public byte[] getBytes(Charset charset)
第一个没有参数,表示按照默认编码返回字节。
String类还提供对应的构造根据字节数组和编码来创建字符串。
public String(byte bytes[], int offset, int length, String charsetName)
public String(byte bytes[], Charset charset)
11.3.4. 不可变性
与包装类类似,String类也是不可变类。字符串对象一旦创建就不能修改,String很多看似在修改的方法其实都是创建新字符串返回,我们以concat方法举例子:
public String concat(String str) {
int otherLen = str.length();
if(otherLen == 0) {
return this;
}
int len = value.length;
char buf[] = Arrays.copyOf(value, len + otherLen);
str.getChars(buf, len);
return new String(buf, true);
}
可以看到字符串的value数组没有发生变化,而是使用Arrays.copyOf方法复制了一份,最后返回的是new出来的新串。
正是因为不可变性,每次字符串操作都是返回新串,效率比较低下。因此如果需要频繁修改字符串可以使用StringBuilder类提高效率。
11.3.5. 字符串常量
Java 里的字符串常量其实是一种特殊的字符串对象,它可以调用所有字符串方法。例如可以使用"我是帅哥".length()获取字符串常量的长度。这些常量对象会放在字符串常量池被所有人共享。
String s1 = "帅哥";
String s2 = "帅哥";
System.out.println(s1 == s2);
左边的案例会返回true,因为变量s1和s2指向同一个对象"帅哥"即变量里面存储的地址是一样的。
String s1 = new String("帅哥");
String s2 = new String("帅哥");
System.out.println(s1 == s2);
如果是这样就会返回false,因为变量s1和s2指向的对象是不一样的。
对于情形 2,有一个小细节。我们先看使用字符串作为参数的构造:
public String(String original) {
this.value = original.value;
this.hash = original.hash;
}
从实现可以看出,两个串value数组的引用是一样的。
因此对于例子 2,s1 == s2是false,但s1.value == s2.value却是true。当然,value是私有的,不能直接访问。
11.3.6. hashCode
String类还有一个私有实例变量hash,这个变量用来缓存串的哈希码。这个变量定义如下:
private int hash; //Default to 0
hash变量的默认值是 0,当我们第一次调用hashCode方法时会把计算出来的值缓存到这个变量里面。我们看下hashCode方法的源码:
public int hashCode() {
int h = hash;
if(h == 0 && value.length > 0) {
char val[] = value;
for(int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}
根据源码可以看出字符串哈希值的计算公式是:。
为什么这么实现呢?因为使用这样的公式能够让哈希值与字符串里每个字符有关,且与每个字符的位置也有关。
使用数字31的原因大致有两个:
- 使用 31 能产生更分散的散列,尽量满足不同串的哈希值不一样。
- 计算效率比较高,因为
31*h = 32*h-h而32*h = h<<5。这样可以使用效率更高的加减和位运算代替效率较低的乘法运算。
11.3.7. 正则表达式
在String类中有些方法的参数不是普通的字符串,而是正则表达式。Java 里有专门的类用于正则表达式,如Pattern和Matcher。但对于简单的情况,String类提供了更为简洁的操作:
public String[] split(String regex) // 字符串分割
public boolean matches(String regex) // 检查是否匹配
// 字符串替换,仅替换遇到的第一个
public String replaceFirst(String regex, String replacement)
// 字符串替换,替换所有
public String replaceAll(String regex, String replacement)
至于正则表达式的教程看之前整理过的文档:
此处为语雀内容卡片,点击链接查看:space-jiangsu.yuque.com/bcsfg9/qr2s…
String类和下面将要介绍的StringBuilder类在 Java9 之后内部就优化了一下,使用 byte 数组而不是 char 数组。
11.4. 剖析 StringBuilder
之前说过,字符串需要频繁修改的时候,使用String很慢。建议使用StringBuilder或StringBuffer。这两者使用方法几乎是一样的,实现也几乎是一样的,只不过StringBuffer使用关键字synchronized实现了线程安全。
11.4.1. 基本用法
StringBuilder sb = new StringBuilder();
sb.append("我是");
sb.append("帅哥");
String s = sb.toString();
我们创建StringBuilder对象之后,可以使用append方法往里面添加字符串。
最后再使用toString方法得到字符串。
11.4.2. 常用 API
11.4.2.1. append
StringBuilder append(?)函数可以往value后面追加内容,几乎所有类型的数据都可以追加。因为append有一个重载的参数是Object类型,此时是追加该对象的toString。除了直接追加内容,append方法还有以下形式:
StringBuilder append(char[] str, int offset, int len)表示将str[offset, offset+len)追加到value后面。StringBuilder append(CharSequence s, int start, int end)表示将s[start, end)追加到value后面。
11.4.2.2. insert
StringBuilder insert(int offset, ?)函数可以往value指定下标出插入内容,原先的内容会后移。和append类似,几乎所有类型的数据都可以插入进去,因为有一个Object类型的重载,会将对象的toString插入到value里面。除了直接插入内容,还有一下形式:
StringBuilder insert(int index, char[] str, int offset, int len)表示将str[offset, offset+len)插入到value的指定位置。StringBuilder insert(int dstOffset, CharSequence s, int start, int end)表示将s[start, end)插入到value的指定位置。
11.4.2.3. delete
StringBuilder delete(int start, int end)删除value[start, end)子串。
11.4.2.4. deleteCharAt
StringBuilder deleteCharAt(int index)删除value[index]字符。
11.4.2.5. charAt
char charAt(int index)返回value中下标 index 处的字符。
11.4.2.6. indexOf
int indexOf(String str)返回子串str在value中的下标(从左到右第一个)。int indexOf(String str, int fromIndex)返回子串str在value[fromIndex,length)中的下标(从左到右第一个)。
11.4.2.7. lastIndexOf
int lastIndexOf(String str)返回子串str在value中的下标(从右到左第一个)。int lastIndexOf(String str, int fromIndex)返回子串str在value[fromIndex,length)中的下标(从右到左第一个)。
11.4.2.8. replace
StringBuilder replace(int start, int end, String str)将value[start, end)替换成str。
11.4.2.9. reverse
StringBuilder reverse()翻转整个value。
11.4.2.10. substring
String substring(int start)返回子串value[start, length)。String substring(int start, int end)返回子串value[start, end)。
11.4.2.11. getChars
void getChars(int srcBegin, int srcEnd, char[] dst, int dstBegin)表示将value[srcBegin, srcEnd)复制到dst[dstBegin,...]处。
11.4.2.12. toString
String toString()返回value的字符串形式。
11.4.2.13. length
int length()返回value中字符的个数。
11.4.2.14. capacity
int capacity()函数返回value的当前容量。
11.4.3. 基本原理
StringBuilder和String类似,内部维护一个字符数组。定义如int count; char[] value;,其中count表示数组中字符的个数。StringBuilder继承于AbstractStringBuilder,默认的构造是:
public StringBuilder() {
super(16);
}
AbstractStringBuilder(int capacity) {
value = new char[capacity];
}
从代码可以看出,默认创建的StringBuilder容量是 16。
11.4.3.1. append
我们再看看append方法的实现:
public AbstractStringBuilder append(String str) {
if(str == null) str = "null";
int len = str.length();
ensureCapacityInternal(count + len);
str.getChars(0, len, value, count);
count += len;
return this;
}
append方法会将内容复制到自己的value数组中,不过在复制之前会先检查容量,如果容量不够会先扩容。下面看检查容量的代码:
private void ensureCapacityInternal(int minimumCapacity) {
//overflow-conscious code
if(minimumCapacity - value.length > 0)
expandCapacity(minimumCapacity);
}
这个代码很简单,只是判断一下需要的容量和当前最大容量的关系,不够就扩容。扩容的代码:
void expandCapacity(int minimumCapacity) {
int newCapacity = value.length * 2 + 2;
if(newCapacity - minimumCapacity < 0)newCapacity = minimumCapacity;
if(newCapacity < 0) {
if (minimumCapacity < 0) //overflow
throw new OutOfMemoryError();
newCapacity = Integer.MAX_VALUE;
}
value = Arrays.copyOf(value, newCapacity);
}
这边首先计算新容量的大小并分配一个新数组,然后将原来的内容复制到扩容后的数组中。最后将自己的value指向这个新数组。这边主要关注一下扩容的机制,每次在原有容量的基础上翻倍并+2,+2主要顾及到原始容量为 0 的场景。这是一种指数扩容机制,这在不知道要多长的情况下,是很常用的机制。
如果,我们在使用StringBuilder之前就能够粗略估算出最大容量,那我们就可以使用构造public StringBuilder(int capacity)来创建StringBuilder。
11.4.3.2. toString
字符串构建完毕之后,看看toString方法:
public String toString() {
//Create a copy, don't share the array
return new String(value, 0, count);
}
可以发现,构建String时使用的是value的副本而不是本身,这可以保证String的不可变性。
11.4.3.3. insert
insert方法能够在指定位置插入一个字符串,方法原型如下:
public StringBuilder insert(int offset, String str)
这个方法能在value数组下标offset处插入一个字符串,原先在这的内容会后移。下面看看这个方法内部的实现逻辑:
public AbstractStringBuilder insert(int offset, String str) {
if((offset < 0) || (offset > length()))
throw new StringIndexOutOfBoundsException(offset);
if(str == null)
str = "null";
int len = str.length();
ensureCapacityInternal(count + len);
System.arraycopy(value, offset, value, offset + len, count - offset);
str.getChars(value, offset);
count += len;
return this;
}
这个方法的主体逻辑是:首先确保容量足够;其次将指定位置及其后面的所有字符后移 n 个位置,n 是待插入串的长度;最后把新串插入到指定位置。
这里面用到了一个很好用的函数System.arraycopy,其原型如下:
public static native void arraycopy(Object src, int srcPos,
Object dest, int destPos, int length);
这个函数的含义是:将数组 src 里面范围[srcPos, srcPos+length)的元素复制到数组 dest 起始下标 destPos 处。
11.4.4. String 的 + 和 +=
Java 中字符串可以使用+和+=做连接,这是 Java 编译器提供的支持。背后 Java 编译会自己生成一个StringBuilder,然后调用append操作。
String hello = "hello";
hello+=",world";
System.out.println(hello);
StringBuilder hello = new StringBuilder("hello");
hello.append(",world");
System.out.println(hello.toString());
上面左侧的写法会被优化成右侧的写法,既然编译器会自己做这个事情,那我们使用的过程中为什么还要区分呢?对于简单的情况下,确实直接使用String更方便,但对于复杂情况,尤其是在循环里面使用,编译器就显得不够聪明了,看下面的例子:
tring hello = "hello";
for(int i=0;i<3;i++){
hello+=",world";
}
System.out.println(hello);
String hello = "hello";
for(int i=0;i<3;i++){
StringBuilder sb = new StringBuilder(hello);
sb.append(",world");
hello = sb.toString();
}
System.out.println(hello);
可以看到每一次循环都会生成StringBuilder对象,这就很低效了。所以,对于简单的情况,我们直接使用String;对于复杂的情况手动使用StringBuilder。
11.5. 剖析 Arrays
Arrays中有很多针对数组的方法,下面我们看一下。
11.5.1. toString
我们直接输出数组对象的时候,会输出其地址。此时我们可以调用Arrays.toString()方法来获取数组的字符串形式,即使是对象数组也会挨个调用对象的toString方法来生成数组的字符串形式。
Point p1 = new Point(1, 2);
Point p2 = new Point(3, 4);
Point[] ps = {p1, p2};
System.out.println(ps);
println(Arrays.toString(ps));
这个案例输出:
[Lcom.luyan.Point;@28a418fc
[(1,2), (3,4)]
11.5.2. 排序
11.5.2.1. 基本类型排序
对数组而言,排序是非常重要且常用的操作。对于基本数据类型排序,可以使用:
public static void sort(int[] a)
public static void sort(int[] a, int fromIndex, int toIndex)
上面第一个函数是对数组a所有元素升序排序,第二个是对子数组a[fromIndex, toIndex)升序排序。这两种方法对除 boolean 外所有的基本数据类型都有对应的重载。
11.5.2.2. 对象数组排序
除了基本数据类型,对象数组也可以排序,但前提是对象所属的类要实现Comparable接口:
public static void sort(Object[] a)
public static void sort(Object[] a, int fromIndex, int toIndex)
上面两个函数与基本数据类型的排序是一样的,只不过换成了对象数组。但对应的类一定要实现Comparable接口,不然会报错的,因为两个对象之间不知道如何比较。
11.5.2.3. 比较器
String类实现了Comparable接口,所以可以排序。如果我想对字符串忽略大小写排序怎么办?那么我们可以使用下面的方法:
public static <T> void sort(T[] a, Comparator<? super T> c)
public static <T> void sort(T[] a, int fromIndex, int toIndex,
Comparator<? super T> c)
Comparator是比较器接口,其定义如下:
public interface Comparator<T> {
int compare(T o1, T o2);
boolean equals(Object obj);
}
主要是compare方法,它会返回一个整数,根据整数的符号确定大小关系。String类中有一个公开的静态成员变量,表示忽略大小写的比较器:
public static final Comparator<String> CASE_INSENSITIVE_ORDER
= new CaseInsensitiveComparator();
想要忽略大小写对字符串数组排序就可以使用:
String[] arr = {"hello","world", "Break","abc"};
Arrays.sort(arr, String.CASE_INSENSITIVE_ORDER);
System.out.println(Arrays.toString(arr));
11.5.2.4. 降序排序
目前为止排序都是升序排序,如果想要逆序排序该如何操作?我们可以使用匿名内部类实现Comparator接口:
String[] arr = {"hello","world", "Break","abc"};
Arrays.sort(arr, new Comparator<String>() {
@Override
public int compare(String o1, String o2) {
return o2.compareToIgnoreCase(o1);
}
});
System.out.println(Arrays.toString(arr));
除了自己实现外,Collections类有两个静态方法能够返回逆序Comparator:
public static <T> Comparator<T> reverseOrder()
public static <T> Comparator<T> reverseOrder(Comparator<T> cmp)
这样忽略大小写降序排序就可以写成:
String[] arr = {"hello","world", "Break","abc"};
Arrays.sort(arr, Collections.reverseOrder(String.CASE_INSENSITIVE_ORDER));
System.out.println(Arrays.toString(arr));
11.5.3. 二分查找
对于有序数组,二分查找是效率非常高的查找方式。所有基本数据类型(boolean除外)都支持二分查找:
public static int binarySearch(int[] a, int key)
public static int binarySearch(int[] a, int fromIndex, int toIndex, int key)
函数里key指的是要查找的元素。
和排序类似,对象数组也可以进行查找,前提是对应的类需要实现Comparable接口:
public static int binarySearch(Object[] a, int key)
public static int binarySearch(Object[] a, int fromIndex, int toIndex, int key)
对于复杂类型,也可以传递Comparator指定排序方式:
public static <T> int binarySearch(T[] a, T key, Comparator<? super T> c)
public static <T> int binarySearch(T[] a, int fromIndex, int toIndex,
T key, Comparator<? super T> c)
查找注意点
- 使用二分查找的数组必须是升序数组,想要在降序数组里面查找,那么需要配合
Comparator实现。 - 如果数组使用
Comparator排序,那么查找时必须使用一样的比较器。因为只有这样,才能使两者认为的有序是一致的。 - 查找时若是没找到会返回一个负数,这个负数是
-(插入点+1)。插入点指的是将要查找的元素插入在某个地方还能保证数组有序。 - 如果数组有多个匹配的元素,那么返回谁是不确定的。
11.5.4. 更多方法
11.5.4.1. copyOf
copyOf是复制数组的方法,参数可以传递一个newLength表示新数组的长度。新长度比较短就复制前面一小部分,新长度比较长就在后面补null。
public static <T> T[] copyOf(T[] original, int newLength)
函数会返回复制后的新数组,但是注意对于引用类型,两个数组里面的元素其实是相同的元素。
11.5.4.2. copyOfRange
copyOfRange方法能够复制original[from, to)到新数组中,返回复制后的数组。对于引用类型,两个数组里面的元素其实是相同的元素。
public static <T> T[] copyOfRange(T[] original, int from, int to)
11.5.4.3. equals
equals方法能够返回两个数组是否相同。
public static boolean equals(Object[] a, Object[] a2)
数组相同指的是:
- 两个数组长度一致。
- 数组内对应元素相同,元素相同是指调用
equals方法返回true。
11.5.4.4. fill
fill方法用于使用一个元素填充数组。
public static void fill(Object[] a, Object val)
public static void fill(Object[] a, int fromIndex, int toIndex, Object val)
第一个方法是使用val填充整个数组a;第二个方法使用val填充a[fromIndex, toIndex)。
对于引用类型,填充过的元素都会指向同一个对象。
11.5.4.5. hashCode
hashCode方法用于返回一个数组的哈希值。我们直接看它的实现:
public static int hashCode(Object[] a) {
if (a == null) return 0;
int result = 1;
for (Object element : a)
result = 31 * result + (element == null ? 0 : element.hashCode());
return result;
}
可以发现和字符串的很像,这个实现能够照顾到数组里面每个元素以及元素的位置。
11.5.5. 多维数组
之前介绍的数组都是一维的,其实数组还可以有多维。下面使用二维数组举例子:
int[][] arr = new int[2][3];
for (int i = 0; i < arr.length; i++) {
for (int j = 0; j < arr[i].length; j++) {
arr[i][j] = i + j;
System.out.print(arr[i][j] + "\t");
}
System.out.println();
}
代码中我们首先定义了一个 2 行 3 列的二维数组,然后给每个元素赋值并输出。
我们还能发现,a和a[i]都有length属性。这就很有意思,实际上多维数组只是假象,这些多维数组本质上还是一维数组,只不过一维数组里面每个元素仍然是一维数组(每个元素都指向另一个一维数组)。既然如此,就会出现下面这些写法:
int[][] arr = new int[5][];
for (int i = 0; i < arr.length; i++) {
arr[i] = new int[i + 1];
for (int j = 0; j < arr[i].length; j++) {
arr[i][j] = i + j;
System.out.print(arr[i][j] + "\t");
}
System.out.println();
}
左边案例输出:
0
1 2
2 3 4
3 4 5 6
4 5 6 7 8
可以发现我们创建的二维数组并不是正方形,而是三角形。也就是说,二维数组里面每一行的列数可以自定义。
Arrays里面的toString、equals、hashCode方法都有对应的deepXXX方法,如下:
public static String deepToString(Object[] a)
public static boolean deepEquals(Object[] a1, Object[] a2)
public static int deepHashCode(Object a[])
这些方法在实现的时候会判断元素是不是也是一个数组,如果是就会递归进行操作。看例子:
int [][]arr = {{1, 2}, {3, 4}};
println(Arrays.toString(arr));
println(Arrays.deepToString(arr));
[[I@2f92e0f4, [I@28a418fc]
[[1, 2], [3, 4]]
11.5.6. 实现原理
11.5.6.1. 二分查找
private static <T> int binarySearch0(T[] a, int fromIndex, int toIndex,
T key, Comparator<? super T> c) {
int low = fromIndex;
int high = toIndex - 1;
while(low <= high) {
int mid = (low + high) >>> 1;
T midVal = a[mid];
int cmp = c.compare(midVal, key);
if(cmp < 0) low = mid + 1;
else if(cmp > 0) high = mid - 1;
else return mid; //key found
}
return -(low + 1); //key not found
}
主体框架还是很简单的,活学活用,以后也用右移表示除以 2。
11.5.6.2. 排序
对于基本类型的数组使用双枢轴快速排序(普通快排的优化版本),对象数组使用TimSort(对归并排序的优化)。当数组元素比较少的时候会采用效率更高的插入排序。
为什么要区分基本类型数组和对象数组?因为这涉及到稳定性的问题,即排序前后数组中值相同的元素先后顺序是否改变的问题。快排不稳定,而归并排序是稳定的。
对于基本数据类型,值相同就是完全相同,稳定性没有意义;但两个对象之间,相同指的是比较结果一样,两者仍然是不同的对象,它们的其它成员变量也可能不同等,所以稳定性很重要。
11.6. 剖析日期和时间
11.6.1. Date 类
Date是 Java 最早引入关于日期的类,但由于不能支持国际化,很多方法已经过时。Date内部主要维护一个long类型的成员变量:
private transient long fastTime;
fastTime表示距离纪元时(1970年1月1日0时0分0秒)的毫秒数,Date主要有两个构造方法:
public Date(long date) {
fastTime = date;
}
public Date() {
this(System.currentTimeMillis());
}
无参构造使用System.currentTimeMillis()进行初始化,这个函数会返回当前时刻距离纪元时的毫秒数,使用的还是比较多的。下面是一些Date里面没有过时的方法:
public long getTime() // 返回毫秒数
public boolean equals(Object obj) // 返回两个 Date 对象是否相同,比较的是毫秒数
public int compareTo(Date anotherDate) // 与另一个日期对象作比较,返回整数表示大小
public boolean before(Date when) // 判定是否在给定日期之前
public boolean after(Date when) // 判定是否在给定日期之后
public int hashCode() // 哈希值算法与 Long 类似
11.6.2. Calendar 类
Calendar是日历类,也是 Java 中操作日期和时间的主要类。Calendar内部维护以下成员:
protected long time;
protected int fields[];
time仍然是表示时刻的毫秒数,fields是一个存储日历中各字段值的数组。数组的长度是 17,主要存储的字段有:
| 字段名 | 含义 |
|---|---|
Calendar.YEAR | 表示年份。 |
Calendar.MONTH | 表示月份,用 0 表示 1 月,1 表示 2 月,... |
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,... |
Calendar类中定义了表示月份和周几的静态变量,例如Calendar.JULY表示 7 月、Calendar.SUNDAY表示周日。
Calendar是抽象类,不能直接创建对象,它提供了几个静态方法用来获取实例:
public static Calendar getInstance()
public static Calendar getInstance(TimeZone zone, Locale aLocale)
TimeZone表示时区,Locale表示国家(或地区)和语言。所有的getInstance方法都需要这两个参数,如果没有就使用默认值(默认当前的时区与国家、语言)。方法中会根据时区与国家选择合适的子类创建对象,中文系统中一般会创建GregorianCalendar类对象。
之前就说过,Calendar会将各字段的值存到fields数组里面。当我们想访问某个字段的时候,可以通过get方法获取:
Calendar calendar = Calendar.getInstance();
println("年:" + calendar.get(Calendar.YEAR));
println("月:" + calendar.get(Calendar.MONTH));
println("日:" + calendar.get(Calendar.DAY_OF_MONTH));
println("时:" + calendar.get(Calendar.HOUR));
println("分:" + calendar.get(Calendar.MINUTE));
println("秒:" + calendar.get(Calendar.SECOND));
println("毫秒:" + calendar.get(Calendar.MILLISECOND));
println("周:" + calendar.get(Calendar.DAY_OF_WEEK));
年:2023
月:11
日:13
时:11
分:17
秒:59
毫秒:534
周:4
因为月是从 0 开始排的,所以拿到字段后需要+1。周日表示 1,所以周三这边显示 4,也需要根据具体情况做变换。
Calendar类还支持修改各字段的值:
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, int second)
public void set(int field, int value) // 用的较多
除此之外,还可以使用add方法根据字段增加或减少时间:
public void add(int field, int amount)
举个例,我想时间变成第二天下午 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);
add方法强在不是无脑加法,而是会联动更新其它字段,比如加一天跨年了,那么年份也会联动加一。
还有一个方法使用和add一样叫做roll,这个方法也会让一个字段加一,但是不会影响到其它字段:
public void roll(int field, int amount)
假设现在是2023.12.31,如果使用calendar.add(Calendar.DAY_OF_MONTH, 1)日期会变成2024.1.1;而使用calendar.roll(Calendar.DAY_OF_MONTH, 1)之后日期会变成2023.12.1。
Calendar可以很方便的转换成Date或毫秒数:
public final Date getTime()
public long getTimeInMillis()
与Date类似,Calendar对象之间也可以进行比较:
public boolean equals(Object obj)
public int compareTo(Calendar anotherCalendar)
public boolean after(Object when)
public boolean before(Object when)
11.6.3. DateFormat
DateFormat类主要用于将Date与字符串互相转化,主要有下面方法:
public final String format(Date date)
public Date parse(String source)
字符串有四种风格,使用静态变量SHORT、MEDIUM、LONG、FULL表示,四种风格描述的详细程度不一样。除了四种风格,还有DEFAULT表示默认风格,默认是MEDIUM。DateFormat也是抽象类,使用的时候需要根据方法获取实例:
public final static DateFormat getDateTimeInstance()
public final static DateFormat getDateInstance()
public final static DateFormat getTimeInstance()
这三种方法分别表示:处理日期和时间、只处理日期、只处理时间。
Calendar calendar = Calendar.getInstance();
calendar.set(2023, Calendar.DECEMBER, 31);
Date date = calendar.getTime();
println(DateFormat.getDateInstance().format(date));
println(DateFormat.getTimeInstance().format(date));
println(DateFormat.getDateTimeInstance().format(date));
输出结果:
2023年12月31日
15:40:07
2023年12月31日 15:40:07
获取实例的方法都有两种重载:
DateFormat getDateTimeInstance(int dateStyle, int timeStyle)
DateFormat getDateTimeInstance(int dateStyle, int timeStyle, Locale aLocale)
简单例子println(DateFormat.getDateInstance(DateFormat.SHORT).format(date))输出简短的日期2023/12/31。
11.6.4. SimpleDateFormat
SimpleDateFormat是DateFormat的子类,它的优点是能够接受一个pattern表示日期的自定义格式。pattern里面所有英语字母都有特殊含义,其它字符原样输出,下面是一些常见字母:
yyyy表示四位数的年MM表示两位数的月dd表示两位数的日HH表示两位数的小时(24小时制)hh表示两位数的小时(12小时制)mm表示两位数的分钟ss表示两位数的秒SSS表示三位数的毫秒E表示周几a表示上午还是下午
我们仍然使用format方法将Date对象转换成字符串:
Calendar calendar = Calendar.getInstance();
String pattern = "yyyy年MM月dd日\nE hh:mm.ss a";
SimpleDateFormat sdf = new SimpleDateFormat(pattern);
System.out.println(sdf.format(calendar.getTime()));
输出如下:
2023年12月14日
周四 04:01.36 下午
我们除了可以将日期转换成字符串,还可以将指定格式的字符串转换成Date对象:
String pattern = "yyyy年MM月dd日\nE hh:mm.ss a";
String timeStr = "2023年12月14日\n周四 04:02.19 下午";
SimpleDateFormat sdf = new SimpleDateFormat(pattern);
try {
Date date = sdf.parse(timeStr);
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
System.out.println(calendar.get(Calendar.YEAR));
} catch (ParseException e) {
e.printStackTrace();
}
这个例子会输出2023,我们主要使用parse方法将指定格式的字符串转换成Date对象。这个方法会抛出一个受检异常ParseException,调用者必须处理。
DateFormat和SimpleDateFormat都是线程不安全的。
11.7. 随机
随机是计算机程序中非常常见的需求,例如:红包金额、随机密码、摇车牌等。
11.7.1. Math.random
Java 对随机的基本支持是Math类的静态方法random,这个方法会返回范围在[0, 1)的随机数。它的源码如下:
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();
}
可以看到Math类维护了一个Random对象,然后每次调用它的nextDouble方法。这个Random对象是单例的,只有第一次访问的时候会创建这个对象。
11.7.2. Random
11.7.2.1. 简单使用
Random提供了更丰富的随机数方法,这些方法不是静态的。下面是一个简单的案例:
Random rnd = new Random();
println(rnd.nextInt());
println(rnd.nextInt(10));
-1944456130
8
nextInt方法会产生一个随机的int(可正可负);nextInt(100)方法会产生一个随机的范围在[0, 100)的int。除了nextInt方法,还有以下方法:
public long nextLong() // 产生一个随机的 long
public boolean nextBoolean() // 产生一个随机的 boolean
public void nextBytes(byte[] bytes) // 产生一堆随机字节填满数组
public float nextFloat() // 产生一个随机的范围在 [0, 1) 的 float
public double nextDouble() // 产生一个随机的范围在 [0, 1) 的 double
Random除了默认构造之外,还有一个构造可以接受一个long类型的种子参数:
public Random(long seed)
种子决定了随机产生的序列,种子相同,那么产生的序列就是相同的,看例子:
Random rnd = new Random(20231214);
for (int i = 0; i < 5; i++) {
print(rnd.nextInt(100) + " ");
}
输出如下:
39 27 24 14 90
上面的程序不管执行多少次,结果都是39 27 24 14 90不会变,因为每次运行种子是一样的。我们除了在构造里面传递种子,还可以使用对应的setter设置种子:
synchronized public void setSeed(long seed)
使用种子的主要目的是为了实现可重复的随机,测试的时候可以复现场景。
11.7.2.2. 随机的基本原理
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中,然后将其与一个常数相乘存到next中,若next与current的值不一样就更新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));
}
代码主要使用公式(oldseed * multiplier + addend) & mask生成新种子,这个公式表示对旧种子乘以某个数(multiplier)再加上某个数(addend),最后取低 48 位。最后的随机数就是新种子的高bits位。这种方法有一个名字叫做线性同余随机数生成器(linear congruential pseudorandom number generator)。
11.7.2.3. 随机验证码/密码
随机验证码一般由 6 位数字组成,以下代码:
// 生成 n 位验证码,每一位都是数字
public static String generateVRCode(int n) {
char[] chars = new char[n];
Random rnd = new Random();
for (int i = 0; i < n; ++i) {
chars[i] = (char) ('0' + rnd.nextInt(10));
}
return new String(chars);
}
public static void main(String[] args) {
String vrcode = generateVRCode(6);
System.out.println(vrcode);
}
密码由数字、字母、特殊符号组成,以下代码:
private static final String SPECIAL_CHARS = "!@#$%^&*_=+-/";
private static char nextChar(Random rnd) {
int type = rnd.nextInt(4);
switch (type) {
case 0:
return (char) ('0' + rnd.nextInt(10));
case 1:
return (char) ('a' + rnd.nextInt(26));
case 2:
return (char) ('A' + rnd.nextInt(26));
default:
return SPECIAL_CHARS.charAt(rnd.nextInt(SPECIAL_CHARS.length()));
}
}
public static String generatePwd(int n) {
char[] chars = new char[n];
Random rnd = new Random();
for (int i = 0; i < n; ++i) {
chars[i] = nextChar(rnd);
}
return new String(chars);
}
public static void main(String[] args) {
String pwd = generatePwd(8);
System.out.println(pwd);
}
对于一些复杂的密码要求至少一位大写字母、小写字母、特殊符号、数字,其它的随机:
private static final String SPECIAL_CHARS = "!@#$%^&*_=+-/";
// 随机返回一个没有内容的下标
private static int nextIndex(char[] chars, Random rnd) {
int n = 0;
int[] idxes = new int[chars.length];
for (int i = 0; i < chars.length; i++) {
if (chars[i] > 0) continue;
idxes[n++] = i;
}
return idxes[rnd.nextInt(n)];
}
private static char nextNumber(Random rnd) {
return (char) ('0' + rnd.nextInt(10));
}
private static char nextLowerLetter(Random rnd) {
return (char) ('a' + rnd.nextInt(26));
}
private static char nextUpperLetter(Random rnd) {
return (char) ('A' + rnd.nextInt(26));
}
private static char nextSpecialChar(Random rnd) {
return SPECIAL_CHARS.charAt(rnd.nextInt(SPECIAL_CHARS.length()));
}
private static char nextChar(Random rnd) {
int type = rnd.nextInt(4);
return switch (type) {
case 0 -> nextNumber(rnd);
case 1 -> nextLowerLetter(rnd);
case 2 -> nextUpperLetter(rnd);
default -> nextSpecialChar(rnd);
};
}
public static String generatePwd(int n) {
char[] chars = new char[n];
Random rnd = new Random();
chars[nextIndex(chars, rnd)] = nextNumber(rnd);
chars[nextIndex(chars, rnd)] = nextLowerLetter(rnd);
chars[nextIndex(chars, rnd)] = nextUpperLetter(rnd);
chars[nextIndex(chars, rnd)] = nextSpecialChar(rnd);
for (int i = 0; i < n; ++i) {
if (chars[i] > 0) continue;
chars[i] = nextChar(rnd);
}
return new String(chars);
}
public static void main(String[] args) {
String pwd = generatePwd(8);
System.out.println(pwd);
}
11.7.2.4. 洗牌
洗牌就是将数组里面的元素打乱顺序,看代码:
public static void shuffle(int[] nums, Random rnd) {
for (int i = nums.length - 1; i > 0; --i) {
int idx = rnd.nextInt(i);
int t = nums[i];
nums[i] = nums[idx];
nums[idx] = t;
}
}
public static void main(String[] args) {
Random rnd = new Random();
int[] nums = {1, 2, 3, 4, 5};
shuffle(nums, rnd);
System.out.println(Arrays.toString(nums));
}
洗牌的基本思路就是从后往前每个元素与它前面的随机一个元素交换。
11.7.2.5. 带权重的随机
实际场景中,带权重随机还是比较常见的。比如抽奖有一、二、三等奖这三种奖项,它们抽中的概率分别是 10%、20%、70%。我们实现的基本思路是使用累积分布概率,这个例子里面我们随机一个[0,1)的小数n,看它的范围:
n∈[0, 10%)就是一等奖;n∈[10%, 30%)就是二等奖;n∈[30%, 1)就是三等奖;
我们首先定义一个类Pair表示奖品和奖品对应的权重:
class Pair {
private Object item;
private 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:
class WeightRandom {
private Pair[] options;
private double[] cmPro; //cumulativeProbabilities;
private Random rnd;
public WeightRandom(Pair[] options) {
this.rnd = new Random();
this.options = options;
initCumulativeProbabilities();
}
private void initCumulativeProbabilities() {
int weights = 0;
double sum = 0.0;
for (Pair option : options) {
weights += option.getWeight();
}
this.cmPro = new double[options.length];
for (int i = 0; i < options.length; ++i) {
sum += options[i].getWeight();
cmPro[i] = sum / weights;
}
}
public Object nextItem() {
double n = rnd.nextDouble();
int idx = Arrays.binarySearch(cmPro, n);
if (idx < 0) {
idx = -idx - 1;
}
return options[idx].getItem();
}
}
nextItem方法里面可以使用循环从前到后寻找,我们给定的寻找方案是使用二分查找。因为概率累积数组本就是递增的,而且就算找不到函数的返回值表示应该插入在哪个位置。
11.7.2.6. 抢红包算法
给定一个钱数和人数,设计一个抢红包算法,每个人最少 0.01 元。我们有如下思路:
每次分配金额的时候,根据人数和钱数计算出平均金额avg。然后每个人的红包金额在范围[0.01, 2 * avg),除此之外还有一些特殊情况需要考虑:
- 只剩最后一个人时,这个人获得所有金钱。
- 每次金额随机出来时,我们至少要确保剩下的人每人都还能分得 0.01。
class RandomPacket {
private int num;
private double money;
private Random rnd;
public RandomPacket(int num, double money) {
if (money < num * 0.01) {
throw new IllegalStateException("money < num * 0.01");
}
this.num = num;
this.money = money;
this.rnd = new Random();
}
public double next() {
double m;
if (num == 1) {
m = money;
} else {
double max = money / num * 2;
m = rnd.nextDouble() * max;
m = Math.round(m * 100) / 100.0;
System.err.println(m);
m = Math.max(m, 0.01);
m = Math.min(m, money - (num - 1) * 0.01);
}
this.num--;
this.money -= m;
return m;
}
}
11.7.2.7. 总结
Random类是线程安全的,但在并发很高的情况下会产生竞争,此时建议考虑ThreadLocalRandom类。除此之外,SecureRandom类可以产生安全性更高、随机性更强的随机数,用于安全加密等领域。