Java基础-实用类

Java基础-实用类

参考文章:菜鸟教程-Java教程

1.Java Number 类

参考文章:廖雪峰-包装类型

包装类型

在实际开发过程中,我们经常会遇到需要使用对象,而不是内置数据类型的情形。为了解决这个问题,Java 语言为每一个内置数据类型提供了对应的包装类。所有的包装类(Integer、Long、Byte、Double、Float、Short)都是抽象类 Number 的子类。

包装类 基本数据类型
Boolean boolean
Byte byte
Short short
Integer int
Long long
Character char
Float float
Double double

Java Number类

这种由编译器特别支持的包装称为装箱,所以当内置数据类型被当作对象使用的时候,编译器会把内置类型装箱为包装类。相似的,编译器也可以把一个对象拆箱为内置类型。Number 类属于 java.lang 包。

1
2
3
4
5
6
7
8
9
10
11
12
public class Main {
public static void main(String[] args) {
int i = 100;
// 通过new操作符创建Integer实例(不推荐使用,会有编译警告):
Integer n1 = new Integer(i);
// 通过静态方法valueOf(int)创建Integer实例:
Integer n2 = Integer.valueOf(i);
// 通过静态方法valueOf(String)创建Integer实例:
Integer n3 = Integer.valueOf("100");
System.out.println(n3.intValue());
}
}

因为intInteger可以互相转换:

1
2
3
int i = 100;
Integer n = Integer.valueOf(i);
int x = n.intValue();

所以,Java编译器可以帮助我们自动在intInteger之间转型:

1
2
Integer n = 100; // 编译器自动使用Integer.valueOf(int)
int x = n; // 编译器自动使用Integer.intValue()

这种直接把int变为Integer的赋值写法,称为自动装箱(Auto Boxing),反过来,把Integer变为int的赋值写法,称为自动拆箱(Auto Unboxing)。

注意:自动装箱和自动拆箱只发生在编译阶段,目的是为了少写代码。

装箱和拆箱会影响代码的执行效率,因为编译后的class代码是严格区分基本类型和引用类型的。并且,自动拆箱执行时可能会报NullPointerException

1
2
3
4
5
6
public class Main {
public static void main(String[] args) {
Integer n = null;
int i = n;
}
}

不变类

所有的包装类型都是不变类。我们查看Integer的源码可知,它的核心代码如下:

1
2
3
public final class Integer {
private final int value;
}

因此,一旦创建了Integer对象,该对象就是不变的。

对两个Integer实例进行比较要特别注意:绝对不能用==比较,因为Integer是引用类型,必须使用equals()比较:

1
2
3
4
5
6
7
8
9
10
11
12
public class Main {
public static void main(String[] args) {
Integer x = 127;
Integer y = 127;
Integer m = 99999;
Integer n = 99999;
System.out.println("x == y: " + (x==y)); // true
System.out.println("m == n: " + (m==n)); // false
System.out.println("x.equals(y): " + x.equals(y)); // true
System.out.println("m.equals(n): " + m.equals(n)); // true
}
}

仔细观察结果的童鞋可以发现,==比较,较小的两个相同的Integer返回true,较大的两个相同的Integer返回false,这是因为Integer是不变类,编译器把Integer x = 127;自动变为Integer x = Integer.valueOf(127);,为了节省内存,Integer.valueOf()对于较小的数,始终返回相同的实例,因此,==比较“恰好”为true,但我们绝不能因为Java标准库的Integer内部有缓存优化就用==比较,必须用equals()方法比较两个Integer

因为Integer.valueOf()可能始终返回同一个Integer实例,因此,在我们自己创建Integer的时候,以下两种方法:

  • 方法1:Integer n = new Integer(100);
  • 方法2:Integer n = Integer.valueOf(100);

方法2更好,因为方法1总是创建新的Integer实例,方法2把内部优化留给Integer的实现者去做,即使在当前版本没有优化,也有可能在下一个版本进行优化。

我们把能创建“新”对象的静态方法称为静态工厂方法。Integer.valueOf()就是静态工厂方法,它尽可能地返回缓存的实例以节省内存。

创建新对象时,优先选用静态工厂方法而不是new操作符。

如果我们考察Byte.valueOf()方法的源码,可以看到,标准库返回的Byte实例全部是缓存实例,但调用者并不关心静态工厂方法以何种方式创建新实例还是直接返回缓存的实例。

进制转换

Integer类本身还提供了大量方法,例如,最常用的静态方法parseInt()可以把字符串解析成一个整数:

1
2
int x1 = Integer.parseInt("100"); // 100
int x2 = Integer.parseInt("100", 16); // 256,因为按16进制解析

Integer还可以把整数格式化为指定进制的字符串:

1
2
3
4
5
6
7
8
9
public class Main {
public static void main(String[] args) {
System.out.println(Integer.toString(100)); // "100",表示为10进制
System.out.println(Integer.toString(100, 36)); // "2s",表示为36进制
System.out.println(Integer.toHexString(100)); // "64",表示为16进制
System.out.println(Integer.toOctalString(100)); // "144",表示为8进制
System.out.println(Integer.toBinaryString(100)); // "1100100",表示为2进制
}
}

注意:上述方法的输出都是String,在计算机内存中,只用二进制表示,不存在十进制或十六进制的表示方法。int n = 100在内存中总是以4字节的二进制表示:

1
2
3
┌────────┬────────┬────────┬────────┐
│00000000│00000000│00000000│01100100│
└────────┴────────┴────────┴────────┘

我们经常使用的System.out.println(n);是依靠核心库自动把整数格式化为10进制输出并显示在屏幕上,使用Integer.toHexString(n)则通过核心库自动把整数格式化为16进制。

这里我们注意到程序设计的一个重要原则:数据的存储和显示要分离。

处理无符号整型

在Java中,并没有无符号整型(Unsigned)的基本数据类型。byteshortintlong都是带符号整型,最高位是符号位。而C语言则提供了CPU支持的全部数据类型,包括无符号整型。无符号整型和有符号整型的转换在Java中就需要借助包装类型的静态方法完成。

例如,byte是有符号整型,范围是-128-+127,但如果把byte看作无符号整型,它的范围就是0~`255。我们把一个负的byte按无符号整型转换为int`:

1
2
3
4
5
6
7
8
public class Main {
public static void main(String[] args) {
byte x = -1;
byte y = 127;
System.out.println(Byte.toUnsignedInt(x)); // 255
System.out.println(Byte.toUnsignedInt(y)); // 127
}
}

因为byte-1的二进制表示是11111111,以无符号整型转换后的int就是255

类似的,可以把一个short按unsigned转换为int,把一个int按unsigned转换为long

2.Java Character 类

Character类提供了一系列方法来操纵字符。你可以使用Character的构造方法创建一个Character类对象

1
Character ch = new Character('a');

在某些情况下,Java编译器会自动创建一个Character对象。例如,将一个char类型的参数传递给需要一个Character类型参数的方法时,那么编译器会自动地将char类型参数转换为Character对象。这种特征称为装箱,反过来称为拆箱。

1
2
3
4
5
6
// 原始字符 'a' 装箱到 Character 对象 ch 中
Character ch = 'a';

// 原始字符 'x' 用 test 方法装箱
// 返回拆箱的值到 'c'
char c = test('x');
转义序列 描述
\t 在文中该处插入一个tab键
\b 在文中该处插入一个后退键
\n 在文中该处换行
\r 在文中该处插入回车
\f 在文中该处插入换页符
\‘ 在文中该处插入单引号
\“ 在文中该处插入双引号
\\ 在文中该处插入反斜杠

image-20230803101740174

3.Java String 类

参考文章:廖雪峰-字符串和编码

字符串创建

字符串广泛应用 在 Java 编程中,Java 字符串属于对象,Java 提供了 String 类来创建和操作字符串。String 创建的字符串存储在公共池中,而 new 创建的字符串对象在堆上:

1
2
3
4
5
String s1 = "Runoob";               // String 直接创建
String s2 = "Runoob"; // String 直接创建
String s3 = s1; // 相同引用
String s4 = new String("Runoob"); // String 对象创建
String s5 = new String("Runoob"); // String 对象创建;

img

**注意:**String 类是不可改变的,所以你一旦创建了 String 对象,那它的值就无法改变了。

字符串比较

String 中 == 比较引用地址是否相同,equals() 比较字符串的内容是否相同:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
String s1 = "Hello";              // String 直接创建
String s2 = "Hello";              // String 直接创建
String s3 = s1;                   // 相同引用
String s4 = new String("Hello");  // String 对象创建
String s5 = new String("Hello");  // String 对象创建
 
s1 == s1;         // true, 相同引用
s1 == s2;         // true, s1 和 s2 都在公共池中,引用相同
s1 == s3;         // true, s3 与 s1 引用相同
s1 == s4;         // false, 不同引用地址
s4 == s5;         // false, 堆中不同引用地址
 
s1.equals(s3);    // true, 相同内容
s1.equals(s4);    // true, 相同内容
s4.equals(s5);    // true, 相同内容

String类中的一个有意思的函数:getBytes()使用平台的默认字符集将字符串编码为 byte 序列,并将结果存储到一个新的 byte 数组中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
String Str1 = new String("hello hulingF");
try{
byte[] Str2 = Str1.getBytes();
System.out.println("返回值:" + Str2 );
// 默认返回拉丁字符集编码的字节数组,其中一个字符只占一个字节位置
for (byte b : Str2) {
System.out.println(b);
}
Str2 = Str1.getBytes( "UTF-8" );
System.out.println("返回值:" + Str2 );
Str2 = Str1.getBytes( "ISO-8859-1" );
System.out.println("返回值:" + Str2 );
} catch ( UnsupportedEncodingException e){
System.out.println("不支持的字符集");
}

image-20230803110543561

String类还提供了多种方法来搜索子串、提取子串。常用的方法有:

1
2
// 是否包含子串:
"Hello".contains("ll"); // true

注意到contains()方法的参数是CharSequence而不是String,因为CharSequenceString实现的一个接口。

搜索子串的更多的例子:

1
2
3
4
"Hello".indexOf("l"); // 2
"Hello".lastIndexOf("l"); // 3
"Hello".startsWith("He"); // true
"Hello".endsWith("lo"); // true

提取子串的例子:

1
2
"Hello".substring(2); // "llo"
"Hello".substring(2, 4); "ll"

注意索引号是从0开始的。

去除首尾空白字符

使用trim()方法可以移除字符串首尾空白字符。空白字符包括空格,\t\r\n

1
"  \tHello\r\n ".trim(); // "Hello"

注意:trim()并没有改变字符串的内容,而是返回了一个新字符串。

另一个strip()方法也可以移除字符串首尾空白字符。它和trim()不同的是,类似中文的空格字符\u3000也会被移除:

1
2
3
"\u3000Hello\u3000".strip(); // "Hello"
" Hello ".stripLeading(); // "Hello "
" Hello ".stripTrailing(); // " Hello"

String还提供了isEmpty()isBlank()来判断字符串是否为空和空白字符串:

1
2
3
4
"".isEmpty(); // true,因为字符串长度为0
" ".isEmpty(); // false,因为字符串长度不为0
" \n".isBlank(); // true,因为只包含空白字符
" Hello ".isBlank(); // false,因为包含非空白字符

替换子串

要在字符串中替换子串,有两种方法。一种是根据字符或字符串替换:

1
2
3
String s = "hello";
s.replace('l', 'w'); // "hewwo",所有字符'l'被替换为'w'
s.replace("ll", "~~"); // "he~~o",所有子串"ll"被替换为"~~"

另一种是通过正则表达式替换:

1
2
String s = "A,,B;C ,D";
s.replaceAll("[\\,\\;\\s]+", ","); // "A,B,C,D"

上面的代码通过正则表达式,把匹配的子串统一替换为","

分割字符串

要分割字符串,使用split()方法,并且传入的也是正则表达式:

1
2
String s = "A,B,C,D";
String[] ss = s.split("\\,"); // {"A", "B", "C", "D"}

拼接字符串

拼接字符串使用静态方法join(),它用指定的字符串连接字符串数组:

1
2
String[] arr = {"A", "B", "C"};
String s = String.join("***", arr); // "A***B***C"

格式化字符串

字符串提供了formatted()方法和format()静态方法,可以传入其他参数,替换占位符,然后生成新的字符串:

1
2
3
4
5
6
7
public class Main {
public static void main(String[] args) {
String s = "Hi %s, your score is %d!";
System.out.println(s.formatted("Alice", 80));
System.out.println(String.format("Hi %s, your score is %.2f!", "Bob", 59.5));
}
}

类型转换

要把任意基本类型或引用类型转换为字符串,可以使用静态方法valueOf()。这是一个重载方法,编译器会根据参数自动选择合适的方法:

1
2
3
4
String.valueOf(123); // "123"
String.valueOf(45.67); // "45.67"
String.valueOf(true); // "true"
String.valueOf(new Object()); // 类似java.lang.Object@636be97c

要把字符串转换为其他类型,就需要根据情况。例如,把字符串转换为int类型:

1
2
int n1 = Integer.parseInt("123"); // 123
int n2 = Integer.parseInt("ff", 16); // 按十六进制转换,255

把字符串转换为boolean类型:

1
2
boolean b1 = Boolean.parseBoolean("true"); // true
boolean b2 = Boolean.parseBoolean("FALSE"); // false

要特别注意,Integer有个getInteger(String)方法,它不是将字符串转换为int,而是把该字符串对应的系统变量转换为Integer

1
Integer.getInteger("java.version"); // 版本号,11

4.Java StringBuilder 类

参考文章:廖雪峰-StringBuilder

StringBuilder由来

考察下面的循环代码:

1
2
3
4
String s = "";
for (int i = 0; i < 1000; i++) {
s = s + "," + i;
}

虽然可以直接拼接字符串,但是,在循环中,每次循环都会创建新的字符串对象,然后扔掉旧的字符串。这样,绝大部分字符串都是临时对象,不但浪费内存,还会影响GC效率。

为了能高效拼接字符串,Java标准库提供了StringBuilder,它是一个可变对象,可以预分配缓冲区,这样,往StringBuilder中新增字符时,不会创建新的临时对象:

1
2
3
4
5
6
StringBuilder sb = new StringBuilder(1024);
for (int i = 0; i < 1000; i++) {
sb.append(',');
sb.append(i);
}
String s = sb.toString();

StringBuilder创建

当对字符串进行修改的时候,需要使用 StringBufferStringBuilder 类。和 String 类不同的是,StringBuffer 和 StringBuilder 类的对象能够被多次的修改,并且不产生新的未使用对象。

img

StringBuilder 类在 Java 5 中被提出,它和 StringBuffer 之间的最大不同在于 StringBuilder 的方法不是线程安全的(不能同步访问)。

由于 StringBuilder 相较于 StringBuffer 有速度优势,所以多数情况下建议使用 StringBuilder 类。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class RunoobTest{
public static void main(String args[]){
StringBuilder sb = new StringBuilder(10);
sb.append("Runoob..");
System.out.println(sb);
sb.append("!");
System.out.println(sb);
sb.insert(8, "Java");
System.out.println(sb);
sb.delete(5,8);
System.out.println(sb);
}
}

img

StringBuilder彩蛋

注意:StringBuilder还可以进行链式操作!

1
2
3
4
5
6
7
8
9
10
public class Main {
public static void main(String[] args) {
var sb = new StringBuilder(1024);
sb.append("Mr ")
.append("Bob")
.append("!")
.insert(0, "Hello, ");
System.out.println(sb.toString());
}
}

如果我们查看StringBuilder的源码,可以发现,进行链式操作的关键是,定义的append()方法会返回this,这样,就可以不断调用自身的其他方法。

仿照StringBuilder,我们也可以设计支持链式操作的类。例如,一个可以不断增加的计数器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class Main {
public static void main(String[] args) {
Adder adder = new Adder();
adder.add(3)
.add(5)
.inc()
.add(10);
System.out.println(adder.value());
}
}

class Adder {
private int sum = 0;

public Adder add(int n) {
sum += n;
return this;
}

public Adder inc() {
sum ++;
return this;
}

public int value() {
return sum;
}
}

注意:对于普通的字符串+操作,并不需要我们将其改写为StringBuilder,因为Java编译器在编译时就自动把多个连续的+操作编码为StringConcatFactory的操作。在运行期,StringConcatFactory会自动把字符串连接操作优化为数组复制或者StringBuilder操作。

5.Java StringJoiner类

要高效拼接字符串,应该使用StringJoiner。很多时候,我们拼接的字符串像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class StringJoinerTest {
public static void main(String[] args) {
// 自动添加分隔符,指定开头和结尾
StringJoiner joiner = new StringJoiner(",", "[", "]");
joiner.add("A");
joiner.add("B");
joiner.add("C");
System.out.println(joiner.toString());

// 自动添加分隔符
System.out.println(String.join(",", "A", "B", "C"));
}
}

image-20230812223915273

6.Java Bean

JavaBean定义

在Java中,有很多class的定义都符合这样的规范:

  • 若干private实例字段;
  • 通过public方法来读写实例字段。
1
2
3
4
5
6
7
8
9
10
public class Person {
private String name;
private int age;

public String getName() { return this.name; }
public void setName(String name) { this.name = name; }

public int getAge() { return this.age; }
public void setAge(int age) { this.age = age; }
}

如果读写方法符合以下这种命名规范:

1
2
3
4
// 读方法:
public Type getXyz()
// 写方法:
public void setXyz(Type value)

那么这种class被称为JavaBean

上面的字段是xyz,那么读写方法名分别以getset开头,并且后接大写字母开头的字段名Xyz,因此两个读写方法名分别是getXyz()setXyz()boolean字段比较特殊,它的读方法一般命名为isXyz()

1
2
3
4
// 读方法:
public boolean isChild()
// 写方法:
public void setChild(boolean value)

我们通常把一组对应的读方法(getter)和写方法(setter)称为属性(property)。例如,name属性:

  • 对应的读方法是String getName()
  • 对应的写方法是setName(String)

属性只需要定义gettersetter方法,不一定需要对应的字段。例如,child只读属性定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Person {
private String name;
private int age;

public String getName() { return this.name; }
public void setName(String name) { this.name = name; }

public int getAge() { return this.age; }
public void setAge(int age) { this.age = age; }

// 只读属性
public boolean isChild() {
return age <= 6;
}
}

可以看出,gettersetter也是一种数据封装的方法。

枚举JavaBean属性

要枚举一个JavaBean的所有属性,可以直接使用Java核心库提供的Introspector

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class Main {
public static void main(String[] args) throws Exception {
BeanInfo info = Introspector.getBeanInfo(Person.class);
for (PropertyDescriptor pd : info.getPropertyDescriptors()) {
System.out.println(pd.getName());
System.out.println(" " + pd.getReadMethod());
System.out.println(" " + pd.getWriteMethod());
}
}
}

class Person {
private String name;
private int age;

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public int getAge() {
return age;
}

public void setAge(int age) {
this.age = age;
}
}

image-20230814091056241

注意class属性是从Object继承的getClass()方法带来的,是只读属性。

7.Java BigInteger类

在Java中,由CPU原生提供的整型最大范围是64位long型整数。使用long型整数可以直接通过CPU指令进行计算,速度非常快。

如果我们使用的整数范围超过了long型怎么办?这个时候,就只能用软件来模拟一个大整数。java.math.BigInteger就是用来表示任意大小的整数。BigInteger内部用一个int[]数组来模拟一个非常大的整数。

如果BigInteger表示的范围超过了基本类型的范围,转换时将丢失高位信息,即结果不一定是准确的。如果需要准确地转换成基本类型,可以使用intValueExact()longValueExact()等方法,在转换时如果超出范围,将直接抛出ArithmeticException异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class BigIntegerTest {
public static void main(String[] args) {
BigInteger i1 = new BigInteger("1234567890000");
BigInteger i2 = new BigInteger("12345678901234567890");
BigInteger sum = i1.add(i2);
System.out.println(i1.pow(5));
System.out.println(i1.longValue());
System.out.println(i1.multiply(i1).longValue());
// java.lang.ArithmeticException: BigInteger out of long range
// System.out.println(i1.multiply(i1).longValueExact());
BigInteger n = new BigInteger("999999").pow(99);
float f = n.floatValue();
System.out.println(f);
}
}

image-20230814115119397

BigInteger也是不变类,并且继承自Number

8.Java BigDecimal类

使用BigDecimal

BigInteger类似,BigDecimal可以表示一个任意大小且精度完全准确的浮点数。

1
2
BigDecimal bd = new BigDecimal("123.4567");
System.out.println(bd.multiply(bd)); // 15241.55677489

BigDecimalscale()表示小数位数,例如:

1
2
3
4
5
6
BigDecimal d1 = new BigDecimal("123.45");
BigDecimal d2 = new BigDecimal("123.4500");
BigDecimal d3 = new BigDecimal("1234500");
System.out.println(d1.scale()); // 2,两位小数
System.out.println(d2.scale()); // 4
System.out.println(d3.scale()); // 0

通过BigDecimalstripTrailingZeros()方法,可以将一个BigDecimal格式化为一个相等的,但去掉了末尾0的BigDecimal

1
2
3
4
5
6
7
8
9
BigDecimal d1 = new BigDecimal("123.4500");
BigDecimal d2 = d1.stripTrailingZeros();
System.out.println(d1.scale()); // 4
System.out.println(d2.scale()); // 2,因为去掉了00

BigDecimal d3 = new BigDecimal("1234500");
BigDecimal d4 = d3.stripTrailingZeros();
System.out.println(d3.scale()); // 0
System.out.println(d4.scale()); // -2

如果一个BigDecimalscale()返回负数,例如,-2,表示这个数是个整数,并且末尾有2个0。

可以对一个BigDecimal设置它的scale,如果精度比原始值低,那么按照指定的方法进行四舍五入或者直接截断:

1
2
3
4
5
6
7
8
9
public class Main {
public static void main(String[] args) {
BigDecimal d1 = new BigDecimal("123.456789");
BigDecimal d2 = d1.setScale(4, RoundingMode.HALF_UP); // 四舍五入,123.4568
BigDecimal d3 = d1.setScale(4, RoundingMode.DOWN); // 直接截断,123.4567
System.out.println(d2);
System.out.println(d3);
}
}

运算BigDecimal

BigDecimal做加、减、乘时,精度不会丢失,但是做除法时,存在无法除尽的情况,这时,就必须指定精度以及如何进行截断:

1
2
3
4
BigDecimal d1 = new BigDecimal("123.456");
BigDecimal d2 = new BigDecimal("23.456789");
BigDecimal d3 = d1.divide(d2, 10, RoundingMode.HALF_UP); // 保留10位小数并四舍五入
BigDecimal d4 = d1.divide(d2); // 报错:ArithmeticException,因为除不尽

还可以对BigDecimal做除法的同时求余数:

1
2
3
4
5
6
7
8
9
public class Main {
public static void main(String[] args) {
BigDecimal n = new BigDecimal("12.345");
BigDecimal m = new BigDecimal("0.12");
BigDecimal[] dr = n.divideAndRemainder(m);
System.out.println(dr[0]); // 102
System.out.println(dr[1]); // 0.105
}
}

调用divideAndRemainder()方法时,返回的数组包含两个BigDecimal,分别是商和余数,其中商总是整数,余数不会大于除数。我们可以利用这个方法判断两个BigDecimal是否是整数倍数:

1
2
3
4
5
6
BigDecimal n = new BigDecimal("12.75");
BigDecimal m = new BigDecimal("0.15");
BigDecimal[] dr = n.divideAndRemainder(m);
if (dr[1].signum() == 0) {
// n是m的整数倍
}

比较BigDecimal

在比较两个BigDecimal的值是否相等时,要特别注意,使用equals()方法不但要求两个BigDecimal的值相等,还要求它们的scale()相等:

1
2
3
4
5
BigDecimal d1 = new BigDecimal("123.456");
BigDecimal d2 = new BigDecimal("123.45600");
System.out.println(d1.equals(d2)); // false,因为scale不同
System.out.println(d1.equals(d2.stripTrailingZeros())); // true,因为d2去除尾部0后scale变为3
System.out.println(d1.compareTo(d2)); // 0

必须使用compareTo()方法来比较,它根据两个值的大小分别返回负数、正数和0,分别表示小于、大于和等于。

总是使用compareTo()比较两个BigDecimal的值,不要使用equals()!

如果查看BigDecimal的源码,可以发现,实际上一个BigDecimal是通过一个BigInteger和一个scale来表示的,即BigInteger表示一个完整的整数,而scale表示小数位数:

1
2
3
4
public class BigDecimal extends Number implements Comparable<BigDecimal> {
private final BigInteger intVal;
private final int scale;
}

BigDecimal也是从Number继承的,也是不可变对象。

9.Java 常用工具类

HexFormat

在处理byte[]数组时,我们经常需要与十六进制字符串转换,自己写起来比较麻烦,用Java标准库提供的HexFormat则可以方便地帮我们转换。

要将byte[]数组转换为十六进制字符串,可以用formatHex()方法:

1
2
3
4
5
6
7
8
9
import java.util.HexFormat;

public class Main {
public static void main(String[] args) throws InterruptedException {
byte[] data = "Hello".getBytes();
HexFormat hf = HexFormat.of();
String hexData = hf.formatHex(data); // 48656c6c6f
}
}

如果要定制转换格式,则使用定制的HexFormat实例:

1
2
3
// 分隔符为空格,添加前缀0x,大写字母:
HexFormat hf = HexFormat.ofDelimiter(" ").withPrefix("0x").withUpperCase();
hf.formatHex("Hello".getBytes())); // 0x48 0x65 0x6C 0x6C 0x6F

从十六进制字符串到byte[]数组转换,使用parseHex()方法:

1
byte[] bs = HexFormat.of().parseHex("48656c6c6f");

Random

Random用来创建伪随机数。所谓伪随机数,是指只要给定一个初始的种子,产生的随机数序列是完全一样的。

要生成一个随机数,可以使用nextInt()nextLong()nextFloat()nextDouble()

1
2
3
4
5
6
Random r = new Random();
r.nextInt(); // 2071575453,每次都不一样
r.nextInt(10); // 5,生成一个[0,10)之间的int
r.nextLong(); // 8811649292570369305,每次都不一样
r.nextFloat(); // 0.54335...生成一个[0,1)之间的float
r.nextDouble(); // 0.3716...生成一个[0,1)之间的double

有童鞋问,每次运行程序,生成的随机数都是不同的,没看出伪随机数的特性来。

这是因为我们创建Random实例时,如果不给定种子,就使用系统当前时间戳作为种子,因此每次运行时,种子不同,得到的伪随机数序列就不同。如果我们在创建Random实例时指定一个种子,就会得到完全确定的随机数序列:

1
2
3
4
5
6
7
8
9
10
import java.util.Random;
public class Main {
public static void main(String[] args) {
Random r = new Random(12345);
for (int i = 0; i < 10; i++) {
System.out.println(r.nextInt(100));
}
// 51, 80, 41, 28, 55...
}
}

我们使用的Math.random()实际上内部调用了Random类,所以它也是伪随机数,只是我们无法指定种子。

SecureRandom

有伪随机数,就有真随机数。实际上真正的真随机数只能通过量子力学原理来获取,而我们想要的是一个不可预测的安全的随机数,SecureRandom就是用来创建安全的随机数的:

1
2
SecureRandom sr = new SecureRandom();
System.out.println(sr.nextInt(100));

SecureRandom无法指定种子,它使用RNG(random number generator)算法。JDK的SecureRandom实际上有多种不同的底层实现,有的使用安全随机种子加上伪随机数算法来产生安全的随机数,有的使用真正的随机数生成器。实际使用的时候,可以优先获取高强度的安全随机数生成器,如果没有提供,再使用普通等级的安全随机数生成器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import java.util.Arrays;
import java.security.SecureRandom;
import java.security.NoSuchAlgorithmException;
public class Main {
public static void main(String[] args) {
SecureRandom sr = null;
try {
sr = SecureRandom.getInstanceStrong(); // 获取高强度安全随机数生成器
} catch (NoSuchAlgorithmException e) {
sr = new SecureRandom(); // 获取普通的安全随机数生成器
}
byte[] buffer = new byte[16];
sr.nextBytes(buffer); // 用安全随机数填充buffer
System.out.println(Arrays.toString(buffer)); // [29, 29, -78, 6, -32, 4, -29, -36, -75, -21, -125, -22, -65, -35, 2, -6]每次都不一样
}
}

SecureRandom的安全性是通过操作系统提供的安全的随机种子来生成随机数。这个种子是通过CPU的热噪声、读写磁盘的字节、网络流量等各种随机事件产生的“熵”。

在密码学中,安全的随机数非常重要。如果使用不安全的伪随机数,所有加密体系都将被攻破。因此,时刻牢记必须使用SecureRandom来产生安全的随机数。

需要使用安全随机数的时候,必须使用SecureRandom,绝不能使用Random!

10.Java 数组

参考文章:Java数组的内存布局、Java数组在内存中如何存放与分配 - 黎先生 - 博客园 (cnblogs.com)](https://www.cnblogs.com/lixiansheng/p/11299993.html))

Java 中数组和类对象都被看做为引用类型(与之对应的是八个基本类型),==创建的数组对象位于堆内存中,而数组引用变量位于栈内存中==。

Java 语言是典型的静态语言,因此 Java 数组是静态的,即当数组被初始化之后,该数组所占的内存空间、数组长度都是不可变的。Java 程序中的数组必须经过初始化才可使用。所谓初始化,即创建实际的数组对象,也就是在内存中为数组对象分配内存空间,并为每个数组元素指定初始值。

数组的初始化有以下两种方式:

  静态初始化:初始化时由程序员显式指定每个数组元素的初始值,由系统决定数组长度。

  动态初始化:初始化时程序员只指定数组长度,由系统为数组元素分配初始值。

不管采用哪种方式初始化Java数组,一旦初始化完成,该数组的长度就不可改变,Java语言允许通过数组的length属性来访问数组的长度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class ArrayReferenceTest {
public static void main(String[] args) {
//采用静态初始化方式初始化第一个数组
String[] books = new String[]{"1", "2", "3", "4"};
//采用静态初始化的简化形式初始化第二个数组
String[] names = {"孙悟空", "猪八戒", "白骨精"};
//采用动态初始化的语法初始化第三个数组
String[] strArr = new String[5];
//访问三个数组的长度
System.out.println("第一个数组的长度: " + books.length);
System.out.println("第二个数组的长度: " + names.length);
System.out.println("第三个数组的长度: " + strArr.length);
}
}

image-20230803142901722

前面已经指出,Java 语言的数组变量是引用类型的变量。books、names 、strArr 这三个变量,以及各自引用的数组在内存中的分配示意图如图所示:

img

执行动态初始化时,程序员只需指定数组的长度,即为每个数组元素指定所需的内存空间,系统将负责为这些数组元素分配初始值(初始化规则与成员变量/静态变量的规则一样)。

需要指出的是,Java 的数组变量是一种引用类型的变量,数组变量并不是数组本身,它只是指向堆内存中的数组对象。

基本数据类型数组的初始化

对于基本类型数组而言,数组元素的值直接存储在对应的数组元素中,因此基本类型数组的初始化比较简单:程序直接先为数组分配内存空间,再将数组元素的值存入对应内存里。

1
2
3
4
5
6
7
8
public class PrimitiveArrayTest{
public static void main(String[] args) {
//定义一个int[]类型的数组变量
int[] iArr;
//静态初始化数组,数组长度为4
iArr = new int[]{2, 5, -12, 50};
}
}

img

==所有局部变量都是放在栈内存里保存的,不管其是基本类型的变量,还是引用类型的变量,都是存储在各自的方法栈内存中的;但引用类型的变量所引用的对象(包括数组、普通的 Java 对象)则总是存储在堆内存中。==

对于 Java 语言而言,堆内存中的对象(不管是数组对象,还是普通的 Java 对象)通常不允许直接访问,为了访问堆内存中的对象,通常只能通过引用变量。这也是很容易混淆的地方。 例如,iArr 本质上只是 main 栈区的引用变量,但使用 iArr.length、iArr[2] 时,系统将会自动变为访问堆内存中的数组对象。

对于很多 Java 程序员而言,他们最容易混淆的是:引用类型的变量何时只是栈内存中的变量本身,何时又变为引用实际的 Java 对象。其实规则很简单:引用变量本质上只是一个指针,只要程序通过引用变量访问属性,或者通过引用变量来调用方法,该引用变量就会由它所引用的对象代替。

1
2
3
4
5
6
7
8
9
10
11
12
public class PrimitiveArrayTest{
public static void main(String[] args) {
//定义一个int[]类型的数组变量
int[] iArr = null;
//只要不访问 iArr 的属性和方法,程序完全可以使用该数组变量
System.out.println(iArr); //①
// 动态初始化数组,数组长度为5
iArr = new int[5];
//只有当 iArr 指向有效的数组对象后,下面才可访问 iArr 的属性
System.out.println(iArr.length); //②
}
}

对于①行代码而言,虽然此时的 iArr 数组变量并未引用到有效的数组对象,但程序在①行代码处并不会出现任何问题,因为此时并未通过 iArr 访问属性或调用方法,因此程序只是访问 iArr 引用变量本身,并不会去访问 iArr 所引用的数组对象。

对于②行代码而言,此时程序通过 iArr 访问了 length 属性,程序将自动变为访问 iArr 所引用的数组对象,这就要求 iArr 必须引用一个有效的对象。

引用数据类型数组的初始化

引用类型数组的数组元素依然是引用类型的,因此数组元素里存储的还是引用,它指向另一块内存,这块内存里存储了该引用变量所引用的对象(包括数组和 Java 对象)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class Person{
public int age;
public double height;
public void info(){
System.out.println("我的年龄是:" + age + ",我的身高是:" + height);
}
}

public class ReferenceArrayTest{
public static void main(String[] args) {
// 定义一个 students 数组变量,其类型是Person[]
Person[] students;
// 执行动态初始化
students = new Person[2];
System.out.println("students所引用的数组的长度是:" + students.length); //①
// 创建一个 Person 实例,并将这个 Person 实例赋给 leslie 变量
Person leslie = new Person();
// 为 leslie 所引用的 Person 对象的属性赋值
leslie.age = 22;
leslie.hight = 180;
// 创建一个 Person 实例,并将这个 Person 实例赋给 lee 变量
Person lee = new Person();
lee.age = 21;
lee.hight = 178;
// 将 leslie 变量的值赋给第一个数组元素
students[0] = leslie;
// 将 lee 变量的值赋给第二个数组元素
students[1] = lee;

// 下面两行代码的结果完全一样,
// 因为lee和students[1]指向的是同一个Person实例
lee.info();
students[1].info();
}
}

img

Arrays 类

==java.util.Arrays== 类能方便地操作数组,它提供的所有方法都是静态的。

序号 方法和说明
1 public static int binarySearch(Object[] a, Object key) 用二分查找算法在给定数组中搜索给定值的对象(byte,int,double等)。数组在调用前必须排序好的。如果查找值包含在数组中,则返回搜索键的索引。
2 public static boolean equals(long[] a, long[] a2) 如果两个指定的 long 型数组彼此相等,则返回 true。如果两个数组包含相同数量的元素,并且两个数组中的所有相应元素对都是相等的,则认为这两个数组是相等的。换句话说,如果两个数组以相同顺序包含相同的元素,则两个数组是相等的。同样的方法适用于所有的其他基本数据类型(byte,short,Int等)。
3 public static void fill(int[] a, int val) 将指定的 int 值分配给指定 int 型数组指定范围中的每个元素。同样的方法适用于所有的其他基本数据类型(byte,short,int等)。
4 public static void sort(Object[] a) 对指定对象数组根据其元素的自然顺序进行升序排列。同样的方法适用于所有的其他基本数据类型(byte,short,int等)。

11.Java 中的字符编码

参考文章:廖雪峰-字符串和编码

参考文章:字符编码笔记

ASCII码

在早期的计算机系统中,为了给字符编码,美国国家标准学会(American National Standard Institute:ANSI)制定了一套英文字母、数字和常用符号的编码,它占用一个字节,编码范围从0127,最高位始终为0,称为ASCII编码。例如,字符'A'的编码是0x41,字符'1'的编码是0x31

如果要把汉字也纳入计算机编码,很显然一个字节是不够的。GB2312标准使用两个字节表示一个汉字,其中第一个字节的最高位始终为1,以便和ASCII编码区分开。例如,汉字'中'GB2312编码是0xd6d0。类似的,日文有Shift_JIS编码,韩文有EUC-KR编码,这些编码因为标准不统一,同时使用,就会产生冲突。

为了统一全球所有语言的编码,全球统一码联盟发布了Unicode编码,它把世界上主要语言都纳入同一个编码,这样,中文、日文、韩文和其他语言就不会冲突。

Unicode方案

Unicode没有规定字符对应的二进制码具体如何存储。以汉字“汉”为例,它的Unicode码位0x6c49,对应的二进制数是 110110001001001,二进制数有15位,这也就说明了它至少需要2个字节来表示。可以想象,在 Unicode 字典中往后的字符可能就需要3个字节或者4个字节,甚至更多字节来表示了。

这就导致了一些问题,计算机怎么知道你这个2个字节表示的是一个字符,而不是分别表示两个字符呢?这里我们可能会想到,那就取个最大的,假如Unicode中最大的字符用4字节就可以表示了,那么我们就将所有的字符都用4个字节来表示,不够的就往前面补0。这样确实可以解决编码问题,但是却造成了空间的极大浪费,如果是一个英文文档,那文件大小就大出了3倍,这显然是无法接受的。

它们造成的结果是:

  1. 出现了Unicode的多种存储方式,也就是说有许多种不同的二进制格式可以用来表示 Unicode;
  2. Unicode在很长一段时间内无法推广,直到互联网的出现。

于是,为了较好的解决Unicode的编码问题,UTF-8UTF-16两种当前比较流行的编码方式诞生了。当然还有一个UTF-32的编码方式,也就是上述那种定长编码,字符统一使用4个字节,虽然看似方便,但是却不如另外两种编码方式使用广泛。

UTF-8

UTF-8是一个非常惊艳的编码方式,漂亮的实现了==对ASCII码的向后兼容==,以保证Unicode可以被大众接受。这里的关系是,UTF-8是Unicode的实现方式之一。

UTF-8是目前互联网上使用最广泛的一种Unicode编码方式,它的最大特点就是可变长。它可以使用1 - 4个字节表示一个字符,根据字符的不同变换长度。编码规则如下:

  1. 对于单个字节的字符,第一位设为0,后面的7位对应这个字符的Unicode码位。因此,对于英文中的0-127号字符,与ASCII码完全相同。这意味着ASCII码那个年代的文档用UTF-8编码打开完全没有问题。
  2. 对于需要使用N个字节来表示的字符(N>1),第一个字节的前N位都设为1,第N+1位设为0,剩余的N-1个字节的前两位都设位10,剩下的二进制位则使用这个字符的Unicode码位来填充。

image-20230907145610146

根据上面编码规则对照表,进行UTF-8编码和解码就简单多了。下面以汉字“汉”为利,具体说明如何进行UTF-8编码和解码。

“汉”的Unicode码位0x6c49(110 1100 0100 1001),通过上面的对照表可以发现,0x6c49位于第三行的范围,那么得出其格式为1110xxxx 10xxxxxx 10xxxxxx。接着,从“汉”的二进制数最后一位开始,从后向前依次填充对应格式中的x,多出的x用0补上。这样,就得到了“汉”的UTF-8编码为11100110 10110001 10001001,转换成十六进制就是0xE6B789

解码的过程也十分简单:如果一个字节的第一位是0 ,则说明这个字节对应一个字符;如果一个字节的第一位1,那么连续有多少个1,就表示该字符占用多少个字节。

Big/Little endian

以汉字严为例,Unicode码位是4E25,需要用两个字节存储,一个字节是4E,另一个字节是25。存储的时候,4E在前,25在后,这就是Big endian方式;25在前,4E在后,这是Little endian方式。第一个字节在前,就是”大头方式”(Big endian),第二个字节在前就是”小头方式”(Little endian)。

那么很自然的,就会出现一个问题:计算机怎么知道某一个文件到底采用哪一种方式编码?

==Unicode规范定义,每一个文件的最前面分别加入一个表示编码顺序的字符,这个字符的名字叫做”零宽度非换行空格”(zero width no-break space),用FEFF表示。这正好是两个字节,而且FF比FE大1。==如果一个文本文件的头两个字节是FE FF,就表示该文件采用大头方式;如果头两个字节是FF FE,就表示该文件采用小头方式。

UTF-16

Unicode的编码空间从U+0000+10FFFF,共有1,112,064个码位(code point)可用来映射字符。Unicode的编码空间可以划分为17个平面(plane),每个平面包含2^16^(65,536)个码位。17个平面的码位可表示为从U+xx0000U+xxFFFF,其中xx表示十六进制值从0016到1016,共计17个平面。第一个平面称为基本多语言平面(Basic Multilingual Plane, BMP),或称第零平面(Plane 0),其他平面称为辅助平面(Supplementary Planes)。基本多语言平面内,从U+D800U+DFFF之间的码位区段是永久保留不映射到Unicode字符。==UTF-16就利用保留下来的0xD800-0xDFFF区段的码位来对辅助平面的字符的码位进行编码。==

从U+0000至U+D7FF以及从U+E000至U+FFFF的码位

第一个Unicode平面(码位从U+0000至U+FFFF)包含了最常用的字符。该平面被称为基本多语言平面,缩写为BMP(Basic Multilingual Plane)。UTF-16编码这个范围内的码位为16比特长的单个码元,数值等价于对应的码位。

从U+10000到U+10FFFF的码位

辅助平面(Supplementary Planes)中的码位,在UTF-16中被编码为一对16比特长的码元(即32位元,4字节),称作代理对(Surrogate Pair),具体方法是:

  1. 码位减去 0x10000,得到的值的范围为20比特长的 0...0xFFFFF
  2. 高位的10比特的值(值的范围为 0...0x3FF)被加上 0xD800 得到第一个码元或称作高位代理(high surrogate),值的范围是 0xD800...0xDBFF。由于高位代理比低位代理的值要小,所以为了避免混淆使用,Unicode标准现在称高位代理为前导代理(lead surrogates)。
  3. 低位的10比特的值(值的范围也是 0...0x3FF)被加上 0xDC00 得到第二个码元或称作低位代理(low surrogate),现在值的范围是 0xDC00...0xDFFF。由于低位代理比高位代理的值要大,所以为了避免混淆使用,Unicode标准现在称低位代理为后尾代理(trail surrogates)。

上述算法可理解为:辅助平面中的码位从U+10000到U+10FFFF,共计FFFFF个,即2^20^=1,048,576个,需要20位来表示。如果用两个16位长的整数组成的序列来表示,第一个整数(称为前导代理)要容纳上述20位的前10位,第二个整数(称为后尾代理)容纳上述20位的后10位。还要能根据16位整数的值直接判明属于前导整数代理的值的范围(2^10^=1024),还是后尾整数代理的值的范围(也是2^10^=1024)。因此,需要在基本多语言平面中保留不对应于Unicode字符的2048个码位,就足以容纳前导代理与后尾代理所需要的编码空间。这对于基本多语言平面总计65536个码位来说,仅占3.125%。

由于前导代理、后尾代理、BMP中的有效字符的码位,三者互不重叠,搜索是简单的:一个字符编码的一部分不可能与另一个字符编码的不同部分相重叠。这意味着UTF-16是自同步(self-synchronizing)的:可以通过仅检查一个码元来判定给定字符的下一个字符的起始码元。UTF-8也有类似优点,但许多早期的编码模式就不是这样,必须从头开始分析文本才能确定不同字符的码元的边界。

image-20230907153413797

ISO-8859-1/Latin-1

ISO8859-1,通常叫做Latin-1。Latin-1包括了书写所有西方欧洲语言不可缺少的附加字符。

ISO-8859-1编码是单字节编码向下兼容ASCII,其编码范围是0x00-0xFF,==0x00-0x7F之间完全和ASCII一致,0x80-0x9F之间是控制字符,0xA0-0xFF之间是文字符号。==

ISO-8859-1收录的字符除ASCII收录的字符外,还包括西欧语言、希腊语、泰语、阿拉伯语、希伯来语对应的文字符号。欧元符号出现的比较晚,没有被收录在ISO-8859-1当中。

因为ISO-8859-1编码范围使用了单字节内的所有空间,在支持ISO-8859-1的系统中传输和存储其他任何编码的字节流都不会被抛弃。换言之,把其他任何编码的字节流当作ISO-8859-1编码看待都没有问题。这是个很重要的特性,MySQL数据库默认编码是Latin1就是利用了这个特性。ASCII编码是一个7位的容器,ISO-8859-1编码是一个8位的容器。

ISO-8859-1的较低部分(从1到127之间的代码)是最初的7比特ASCII。

ISO-8859-1的较高部分(从160到255之间的代码)全都有实体名称。

延伸阅读

char数据类型是一个采用UTF-16编码表示Unicode码位的码元。

码位:码位(code point)是指与一个编码表中的某个字符对应的代码值。在Unicode标准中,码位采用十六进制书写,并加上前缀U+,例如U+0041就是字母A的码位。

Unicode的码位可以分成17个代码级别(code plane)。第一个代码级别称为基本的多语言级别(简称BMP),码位从U+0000到U+FFFF,其中包括了经典的Unicode代码;其余的16个附加级别,码位从U+10000到U+FFFFF,其中包括了一些辅助字符。

码元:在BMP中,每个字符用16位表示,称为码元(code unit)。辅助字符采用一对连续的码元进行编码。

对于不同版本的JDK,String类在内存中有不同的优化方式。具体来说,早期JDK版本的String总是以char[]存储,它的定义如下:

1
2
3
4
5
public final class String {
private final char[] value;
private final int offset;
private final int count;
}

而较新的JDK版本的String则以byte[]存储:如果String仅包含ASCII字符,则每个byte存储一个字符,否则,每两个byte存储一个字符,这样做的目的是为了节省内存,因为大量的长度较短的String通常仅包含ASCII字符:

1
2
3
public final class String {
private final byte[] value;
private final byte coder; // 0 = LATIN1, 1 = UTF16

对于使用者来说,String内部的优化不影响任何已有代码,因为它的public方法签名是不变的。