字符串和编码#
String#
在Java中,String
是一个引用类型,它本身也是一个class
。但是,Java编译器对String
有特殊处理,即可以直接用"..."
来表示一个字符串:
1
String s1 = "Hello!";
copy
实际上字符串在String
内部是通过一个char[]
数组表示的,因此,按下面的写法也是可以的:
1
String s2 = new String(new char[] {'H', 'e', 'l', 'l', 'o', '!'});
copy
因为String
太常用了,所以Java提供了"..."
这种字符串字面量表示方法。
Java字符串的一个重要特点就是字符串不可变 。这种不可变性是通过内部的private final char[]
字段,以及没有任何修改char[]
的方法实现的。
我们来看一个例子:
1
2
3
4
5
6
7
8
public class Main {
public static void main(String[] args) {
String s = "Hello" ;
System.out .println (s);
s = s.toUpperCase ();
System.out .println (s);
}
}
copy
根据上面代码的输出,试解释字符串内容是否改变。
字符串比较#
当我们想要比较两个字符串是否相同时,要特别注意,我们实际上是想比较字符串的内容是否相同。必须使用equals()
方法而不能用==
。
我们看下面的例子:
1
2
3
4
5
6
7
8
public class Main {
public static void main(String[] args) {
String s1 = "hello" ;
String s2 = "hello" ;
System.out .println (s1 == s2);
System.out .println (s1.equals (s2));
}
}
copy
从表面上看,两个字符串用==
和equals()
比较都为true
,但实际上那只是Java编译器在编译期,会自动把所有相同的字符串当作一个对象放入常量池,自然s1
和s2
的引用就是相同的。
所以,这种==
比较返回true
纯属巧合。换一种写法,==
比较就会失败:
1
2
3
4
5
6
7
8
public class Main {
public static void main(String[] args) {
String s1 = "hello" ;
String s2 = "HELLO" .toLowerCase ();
System.out .println (s1 == s2);
System.out .println (s1.equals (s2));
}
}
copy
结论:两个字符串比较,必须总是使用equals()
方法。
要忽略大小写比较,使用equalsIgnoreCase()
方法。
String
类还提供了多种方法来搜索子串、提取子串。常用的方法有:
1
2
// 是否包含子串:
"Hello".contains("ll"); // true
copy
注意到contains()
方法的参数是CharSequence
而不是String
,因为CharSequence
是String
的父类。
搜索子串的更多的例子:
1
2
3
4
"Hello".indexOf("l"); // 2
"Hello".lastIndexOf("l"); // 3
"Hello".startsWith("He"); // true
"Hello".endsWith("lo"); // true
copy
提取子串的例子:
1
2
"Hello".substring(2); // "llo"
"Hello".substring(2, 4); "ll"
copy
注意索引号是从0
开始的。
去除首尾空白字符#
使用trim()
方法可以移除字符串首尾空白字符。空白字符包括空格,\t
,\r
,\n
:
1
" \tHello\r\n ".trim(); // "Hello"
copy
注意:trim()
并没有改变字符串的内容,而是返回了一个新字符串。
另一个strip()
方法也可以移除字符串首尾空白字符。它和trim()
不同的是,类似中文的空格字符\u3000
也会被移除:
1
2
3
"\u3000Hello\u3000".strip(); // "Hello"
" Hello ".stripLeading(); // "Hello "
" Hello ".stripTrailing(); // " Hello"
copy
String
还提供了isEmpty()
和isBlank()
来判断字符串是否为空和空白字符串:
1
2
3
4
"" .isEmpty (); // true,因为字符串长度为0
" " .isEmpty (); // false,因为字符串长度不为0
" \n" .isBlank (); // true,因为只包含空白字符
" Hello " .isBlank (); // false,因为包含非空白字符
copy
替换子串#
要在字符串中替换子串,有两种方法。一种是根据字符或字符串替换:
1
2
3
String s = "hello";
s.replace('l', 'w'); // "hewwo",所有字符'l'被替换为'w'
s.replace("ll", "~~"); // "he~~o",所有子串"ll"被替换为"~~"
copy
另一种是通过正则表达式替换:
1
2
String s = "A,,B;C ,D";
s.replaceAll("[\\,\\;\\s]+", ","); // "A,B,C,D"
copy
上面的代码通过正则表达式,把匹配的子串统一替换为","
。关于正则表达式的用法我们会在后面详细讲解。
分割字符串#
要分割字符串,使用split()
方法,并且传入的也是正则表达式:
1
2
String s = "A,B,C,D";
String[] ss = s.split("\\,"); // {"A", "B", "C", "D"}
copy
拼接字符串#
拼接字符串使用静态方法join()
,它用指定的字符串连接字符串数组:
1
2
String[] arr = {"A", "B", "C"};
String s = String.join("***", arr); // "A***B***C"
copy
格式化字符串#
字符串提供了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 ));
}
}
copy
有几个占位符,后面就传入几个参数。参数类型要和占位符一致。我们经常用这个方法来格式化信息。常用的占位符有:
%s
:显示字符串;
%d
:显示整数;
%x
:显示十六进制整数;
%f
:显示浮点数。
占位符还可以带格式,例如%.2f
表示显示两位小数。如果你不确定用啥占位符,那就始终用%s
,因为%s
可以显示任何数据类型。要查看完整的格式化语法,请参考JDK文档 。
类型转换#
要把任意基本类型或引用类型转换为字符串,可以使用静态方法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
copy
要把字符串转换为其他类型,就需要根据情况。例如,把字符串转换为int
类型:
1
2
int n1 = Integer.parseInt("123"); // 123
int n2 = Integer.parseInt("ff", 16); // 按十六进制转换,255
copy
把字符串转换为boolean
类型:
1
2
boolean b1 = Boolean.parseBoolean("true"); // true
boolean b2 = Boolean.parseBoolean("FALSE"); // false
copy
要特别注意,Integer
有个getInteger(String)
方法,它不是将字符串转换为int
,而是把该字符串对应的系统变量转换为Integer
:
1
Integer.getInteger("java.version"); // 版本号,11
copy
转换为char[]#
String
和char[]
类型可以互相转换,方法是:
1
2
char[] cs = "Hello".toCharArray(); // String -> char[]
String s = new String(cs); // char[] -> String
copy
如果修改了char[]
数组,String
并不会改变:
1
2
3
4
5
6
7
8
9
public class Main {
public static void main(String[] args) {
char [] cs = "Hello" .toCharArray ();
String s = new String(cs);
System.out .println (s);
cs[0] = 'X' ;
System.out .println (s);
}
}
copy
这是因为通过new String(char[])
创建新的String
实例时,它并不会直接引用传入的char[]
数组,而是会复制一份,所以,修改外部的char[]
数组不会影响String
实例内部的char[]
数组,因为这是两个不同的数组。
从String
的不变性设计可以看出,如果传入的对象有可能改变,我们需要复制而不是直接引用。
例如,下面的代码设计了一个Score
类保存一组学生的成绩:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Main {
public static void main(String[] args) {
int [] scores = new int [] { 88, 77, 51, 66 };
Score s = new Score(scores);
s.printScores ();
scores[2] = 99;
s.printScores ();
}
}
class Score {
private int [] scores;
public Score(int [] scores) {
this .scores = scores;
}
public void printScores() {
System.out .println (Arrays.toString (scores));
}
}
copy
观察两次输出,由于Score
内部直接引用了外部传入的int[]
数组,这会造成外部代码对int[]
数组的修改,影响到Score
类的字段。如果外部代码不可信,这就会造成安全隐患。
请修复Score
的构造方法,使得外部代码对数组的修改不影响Score
实例的int[]
字段。
字符编码#
在早期的计算机系统中,为了给字符编码,美国国家标准学会(American National Standard Institute:ANSI)制定了一套英文字母、数字和常用符号的编码,它占用一个字节,编码范围从0
到127
,最高位始终为0
,称为ASCII
编码。例如,字符'A'
的编码是0x41
,字符'1'
的编码是0x31
。
如果要把汉字也纳入计算机编码,很显然一个字节是不够的。GB2312
标准使用两个字节表示一个汉字,其中第一个字节的最高位始终为1
,以便和ASCII
编码区分开。例如,汉字'中'
的GB2312
编码是0xd6d0
。
类似的,日文有Shift_JIS
编码,韩文有EUC-KR
编码,这些编码因为标准不统一,同时使用,就会产生冲突。
为了统一全球所有语言的编码,全球统一码联盟发布了Unicode
编码,它把世界上主要语言都纳入同一个编码,这样,中文、日文、韩文和其他语言就不会冲突。
Unicode
编码需要两个或者更多字节表示,我们可以比较中英文字符在ASCII
、GB2312
和Unicode
的编码:
英文字符'A'
的ASCII
编码和Unicode
编码:
1
2
3
4
5
6
┌────┐
ASCII: │ 41 │
└────┘
┌────┬────┐
Unicode: │ 00 │ 41 │
└────┴────┘
copy
英文字符的Unicode
编码就是简单地在前面添加一个00
字节。
中文字符'中'
的GB2312
编码和Unicode
编码:
1
2
3
4
5
6
┌────┬────┐
GB2312: │ d6 │ d0 │
└────┴────┘
┌────┬────┐
Unicode: │ 4e │ 2d │
└────┴────┘
copy
那我们经常使用的UTF-8
又是什么编码呢?因为英文字符的Unicode
编码高字节总是00
,包含大量英文的文本会浪费空间,所以,出现了UTF-8
编码,它是一种变长编码,用来把固定长度的Unicode
编码变成1~4字节的变长编码。通过UTF-8
编码,英文字符'A'
的UTF-8
编码变为0x41
,正好和ASCII
码一致,而中文'中'
的UTF-8
编码为3字节0xe4b8ad
。
UTF-8
编码的另一个好处是容错能力强。如果传输过程中某些字符出错,不会影响后续字符,因为UTF-8
编码依靠高字节位来确定一个字符究竟是几个字节,它经常用来作为传输编码。
在Java中,char
类型实际上就是两个字节的Unicode
编码。如果我们要手动把字符串转换成其他编码,可以这样做:
1
2
3
4
byte[] b1 = "Hello".getBytes(); // 按系统默认编码转换,不推荐
byte[] b2 = "Hello".getBytes("UTF-8"); // 按UTF-8编码转换
byte[] b2 = "Hello".getBytes("GBK"); // 按GBK编码转换
byte[] b3 = "Hello".getBytes(StandardCharsets.UTF_8); // 按UTF-8编码转换
copy
注意:转换编码后,就不再是char
类型,而是byte
类型表示的数组。
如果要把已知编码的byte[]
转换为String
,可以这样做:
1
2
3
byte[] b = ...
String s1 = new String(b, "GBK"); // 按GBK转换
String s2 = new String(b, StandardCharsets.UTF_8); // 按UTF-8转换
copy
始终牢记:Java的String
和char
在内存中总是以Unicode编码表示。
延伸阅读#
对于不同版本的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;
}
copy
而较新的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
copy
对于使用者来说,String
内部的优化不影响任何已有代码,因为它的public
方法签名是不变的。
Java字符串String
是不可变对象;
字符串操作不改变原字符串内容,而是返回新字符串;
常用的字符串操作:提取子串、查找、替换、大小写转换等;
Java使用Unicode编码表示String
和char
;
转换编码就是将String
和byte[]
转换,需要指定编码;
转换为byte[]
时,始终优先考虑UTF-8
编码。
StringBuilder#
Java编译器对String
做了特殊处理,使得我们可以直接用+
拼接字符串。
考察下面的循环代码:
1
2
3
4
String s = "" ;
for (int i = 0; i < 1000; i++) {
s = s + "," + i;
}
copy
虽然可以直接拼接字符串,但是,在循环中,每次循环都会创建新的字符串对象,然后扔掉旧的字符串。这样,绝大部分字符串都是临时对象,不但浪费内存,还会影响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 ();
copy
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 ());
}
}
copy
如果我们查看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;
}
}
copy
注意:对于普通的字符串+
操作,并不需要我们将其改写为StringBuilder
,因为Java编译器在编译时就自动把多个连续的+
操作编码为StringConcatFactory
的操作。在运行期,StringConcatFactory
会自动把字符串连接操作优化为数组复制或者StringBuilder
操作。
你可能还听说过StringBuffer
,这是Java早期的一个StringBuilder
的线程安全版本,它通过同步来保证多个线程操作StringBuffer
也是安全的,但是同步会带来执行速度的下降。
StringBuilder
和StringBuffer
接口完全相同,现在完全没有必要使用StringBuffer
。
StringJoiner#
要高效拼接字符串,应该使用StringBuilder
。
很多时候,我们拼接的字符串像这样:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Main {
public static void main(String[] args) {
String[] names = {"Bob" , "Alice" , "Grace" };
var sb = new StringBuilder();
sb.append ("Hello " );
for (String name : names) {
sb.append (name).append (", " );
}
// 注意去掉最后的", ":
sb.delete (sb.length () - 2, sb.length ());
sb.append ("!" );
System.out .println (sb.toString ());
}
}
copy
类似用分隔符拼接数组的需求很常见,所以Java标准库还提供了一个StringJoiner
来干这个事:
1
2
3
4
5
6
7
8
9
10
public class Main {
public static void main(String[] args) {
String[] names = {"Bob" , "Alice" , "Grace" };
var sj = new StringJoiner(", " );
for (String name : names) {
sj.add (name);
}
System.out .println (sj.toString ());
}
}
copy
慢着!用StringJoiner
的结果少了前面的"Hello "
和结尾的"!"
!遇到这种情况,需要给StringJoiner
指定“开头”和“结尾”:
1
2
3
4
5
6
7
8
9
10
public class Main {
public static void main(String[] args) {
String[] names = {"Bob" , "Alice" , "Grace" };
var sj = new StringJoiner(", " , "Hello " , "!" );
for (String name : names) {
sj.add (name);
}
System.out .println (sj.toString ());
}
}
copy
String.join()#
String
还提供了一个静态方法join()
,这个方法在内部使用了StringJoiner
来拼接字符串,在不需要指定“开头”和“结尾”的时候,用String.join()
更方便:
1
2
String[] names = {"Bob" , "Alice" , "Grace" };
var s = String.join (", " , names);
copy
包装类型#
我们已经知道,Java的数据类型分两种:
基本类型:byte
,short
,int
,long
,boolean
,float
,double
,char
引用类型:所有class
和interface
类型
引用类型可以赋值为null
,表示空,但基本类型不能赋值为null
:
1
2
String s = null;
int n = null; // compile error!
copy
那么,如何把一个基本类型视为对象(引用类型)?
比如,想要把int
基本类型变成一个引用类型,我们可以定义一个Integer
类,它只包含一个实例字段int
,这样,Integer
类就可以视为int
的包装类(Wrapper Class):
1
2
3
4
5
6
7
8
9
10
11
public class Integer {
private int value;
public Integer(int value) {
this .value = value;
}
public int intValue() {
return this .value ;
}
}
copy
定义好了Integer
类,我们就可以把int
和Integer
互相转换:
1
2
3
Integer n = null ;
Integer n2 = new Integer(99);
int n3 = n2.intValue ();
copy
实际上,因为包装类型非常有用,Java核心库为每种基本类型都提供了对应的包装类型:
基本类型
对应的引用类型
boolean
java.lang.Boolean
byte
java.lang.Byte
short
java.lang.Short
int
java.lang.Integer
long
java.lang.Long
float
java.lang.Float
double
java.lang.Double
char
java.lang.Character
我们可以直接使用,并不需要自己去定义:
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 ());
}
}
copy
Auto Boxing#
因为int
和Integer
可以互相转换:
1
2
3
int i = 100;
Integer n = Integer.valueOf (i);
int x = n.intValue ();
copy
所以,Java编译器可以帮助我们自动在int
和Integer
之间转型:
1
2
Integer n = 100; // 编译器自动使用Integer.valueOf(int)
int x = n; // 编译器自动使用Integer.intValue()
copy
这种直接把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;
}
}
copy
不变类#
所有的包装类型都是不变类。我们查看Integer
的源码可知,它的核心代码如下:
1
2
3
public final class Integer {
private final int value;
}
copy
因此,一旦创建了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
}
}
copy
仔细观察结果的童鞋可以发现,==
比较,较小的两个相同的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进制解析
copy
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进制
}
}
copy
注意:上述方法的输出都是String
,在计算机内存中,只用二进制表示,不存在十进制或十六进制的表示方法。int n = 100
在内存中总是以4字节的二进制表示:
1
2
3
┌────────┬────────┬────────┬────────┐
│00000000│00000000│00000000│01100100│
└────────┴────────┴────────┴────────┘
copy
我们经常使用的System.out.println(n);
是依靠核心库自动把整数格式化为10进制输出并显示在屏幕上,使用Integer.toHexString(n)
则通过核心库自动把整数格式化为16进制。
这里我们注意到程序设计的一个重要原则:数据的存储和显示要分离。
Java的包装类型还定义了一些有用的静态变量
1
2
3
4
5
6
7
8
9
// boolean只有两个值true/false,其包装类型只需要引用Boolean提供的静态字段:
Boolean t = Boolean.TRUE ;
Boolean f = Boolean.FALSE ;
// int可表示的最大/最小值:
int max = Integer.MAX_VALUE ; // 2147483647
int min = Integer.MIN_VALUE ; // -2147483648
// long类型占用的bit和byte数量:
int sizeOfLong = Long.SIZE ; // 64 (bits)
int bytesOfLong = Long.BYTES ; // 8 (bytes)
copy
最后,所有的整数和浮点数的包装类型都继承自Number
,因此,可以非常方便地直接通过包装类型获取各种基本类型:
1
2
3
4
5
6
7
8
// 向上转型为Number:
Number num = new Integer(999);
// 获取byte, int, long, float, double:
byte b = num.byteValue ();
int n = num.intValue ();
long ln = num.longValue ();
float f = num.floatValue ();
double d = num.doubleValue ();
copy
处理无符号整型#
在Java中,并没有无符号整型(Unsigned)的基本数据类型。byte
、short
、int
和long
都是带符号整型,最高位是符号位。而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
}
}
copy
因为byte
的-1
的二进制表示是11111111
,以无符号整型转换后的int
就是255
。
类似的,可以把一个short
按unsigned转换为int
,把一个int
按unsigned转换为long
。
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; }
}
copy
如果读写方法符合以下这种命名规范:
1
2
3
4
// 读方法:
public Type getXyz()
// 写方法:
public void setXyz(Type value)
copy
那么这种class
被称为JavaBean
。
上面的字段是xyz
,那么读写方法名分别以get
和set
开头,并且后接大写字母开头的字段名Xyz
,因此两个读写方法名分别是getXyz()
和setXyz()
。
boolean
字段比较特殊,它的读方法一般命名为isXyz()
:
1
2
3
4
// 读方法:
public boolean isChild()
// 写方法:
public void setChild(boolean value)
copy
我们通常把一组对应的读方法(getter
)和写方法(setter
)称为属性(property
)。例如,name
属性:
对应的读方法是String getName()
对应的写方法是setName(String)
只有getter
的属性称为只读属性(read-only),例如,定义一个age只读属性:
对应的读方法是int getAge()
无对应的写方法setAge(int)
类似的,只有setter
的属性称为只写属性(write-only)。
很明显,只读属性很常见,只写属性不常见。
属性只需要定义getter
和setter
方法,不一定需要对应的字段。例如,child
只读属性定义如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
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;
}
}
copy
可以看出,getter
和setter
也是一种数据封装的方法。
JavaBean的作用#
JavaBean主要用来传递数据,即把一组数据组合成一个JavaBean便于传输。此外,JavaBean可以方便地被IDE工具分析,生成读写属性的代码,主要用在图形界面的可视化设计中。
通过IDE,可以快速生成getter
和setter
。例如,在Eclipse中,先输入以下代码:
1
2
3
4
public class Person {
private String name;
private int age;
}
copy
然后,点击右键,在弹出的菜单中选择“Source”,“Generate Getters and Setters”,在弹出的对话框中选中需要生成getter
和setter
方法的字段,点击确定即可由IDE自动完成所有方法代码。
枚举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;
}
}
copy
运行上述代码,可以列出所有的属性,以及对应的读写方法。注意class
属性是从Object
继承的getClass()
方法带来的。
JavaBean是一种符合命名规范的class
,它通过getter
和setter
来定义属性;
属性是一种通用的叫法,并非Java语法规定;
可以利用IDE快速生成getter
和setter
;
使用Introspector.getBeanInfo()
可以获取属性列表。
JavaBean,可序列化的POJO,sun在早期有对其规范
JavaBeans Spec
它是Java中的可重用组件,主要规范约定如下:
JavaBean为公共类,并且具有一个空构造函数
所有属性为私有属性,提供getter和setter,不应该有公共属性
实现序列化接口:java.io.Serializable
JavaBean已经成为Java的一种规范,也是Java社区的共同语言,许多工具框架也是遵循JavaBean的规范的,例如,Spring的BeanUtils,一些Json工具都是基于JavaBean的规范来实现的,这些都是基于约定,所以也有人把JavaBean叫为可以持久化的POJO。
POJO(Plain Ordinary Java Object),简单Java对象,就是一个我们最常见的普通Java对象,这个概念是被大家叫出来的,它具有一些属性,然后提供对应的getter和setter,如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Foo {
private String name;
private String type;
public String getName(){
return this .name ;
}
public void setName(String name){
this .name = name;
}
public String getType(){
return this .Type ;
}
public void setType(String type){
this .type = type;
}
}
copy
DO
DO(Domain Object),领域对象,也就是ORM框架中对应数据库的对象,业务实体,例如,对现实世界中的用户建模,抽象出来的DO可以叫为UserDO,通常情况下它用于与数据库的数据交互,通常也是一个JavaBean。
PO
PO(Persistent Object),持久化对象,主要用于持久化层,与数据库对应,通常也是ORM框架中的实体对象,例如,使用JPA时候的Entity与数据库表做映射,通常是一个JavaBean。
DTO
DTO(Data Transfer Object),数据传输对象,顾名思义就是用于传输数据的对象,通常用于处于不同架构层次或者不同子系统之间的数据传递,或者用于外部接口参数传递,以便提供不同粒度不同信息的数据,以免造成困惑干扰,通常也是一个JavaBean。
VO
VO(Value Object),就是用于保存数据的对象;在提供给页面使用的时候,也有人解释为View Object,就是对应页面展示数据的对象。
DAO
DAO(Data Access Object),数据访问对象,与数据库做交互的对象,提供不同的接口访问数据库来实现对数据库的操作,而接口使用的数据交互通常就是PO或者DO,通过它可以使用面向对象的方式来与数据库交互。
总结
DO、PO、VO、DTO等其实都是一个JavaBean,只是应用的范围不同,表示的意义不同,而这些不同是Java这么多年来形成的一种约定,这种习惯形成Java程序员之间一种共识。
DO和PO大部分时间是一样的,不过PO更倾向于有状态的对象,例如,使用Hibernate的时候,通过DAO操作过的对象会被框架所持有,如果一旦对其操作,在Session关闭的时候会写回数据库,这个时候使用PO就要非常小心,特别是与其他业务层交互的时候,最好转成DTO或者VO提供数据,不能把持久化对象暴露出去,不然可能会导致数据被修改。
在使用这类对象的时候,我们还是要明白其意义,然后在合适的场景使用,毕竟这是Java形成的通用语言,想要读懂他人代码就要理解,想要他人能读懂自己的代码就要遵循。
DTO的使用#
我们通常使用DTO给前端返回数据,而不是直接返回实体类,如果返回实体类,会返回一些前端并不需要的参数,有的字段为null,影响接口效率,整洁程度,还有可能泄露数据库表结构。
使用dto时,我们有两种选择
另外,通常在使用对于一对一或一对多时,我们在DTO中加入字段,如:
1
2
3
4
5
6
7
public class BusinessDto extends Business {
//与商户账户一对一
private BusinessAccount businessAccount;
//与商户图片一对多
private List<BusinessImg> imgList;
// ...省略get()、set()方法
}
copy
这样还是暴露了 BusinessAccount 和 BusinessImg 的属性,针对这种情况,我们可以使用内部类。
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
36
37
38
39
40
41
42
public class modelDto implements Serializable {
/** 主键*/
private Integer id;
/** 名字*/
private String name;
/** 联系电话*/
private String tel;
/** 收货地址*/
private String address;
/** 一对一关系 */
private InnerOne innerOne;
/** 详情一对多关系*/
List<InnerDetailDto> detailList;
//微信群内部类
static class InnerOne implements Serializable{
//主键
private Integer innerId;
//群名
private String innerName;
省略get/set方法...
}
//订单详情一对多内部类
static class InnerDetailDto implements Serializable{
//主键
private Integer detailId;
//商品价格
private BigDecimal goodsPrice;
// 省略get/set方法...
}
// 省略get/set方法...
}
copy
注意:
内部类也要实现序列化接口implements Serializable
可能需要加 static
可能需要加 public
枚举类Enum#
在Java中,我们可以通过static final
来定义常量。例如,我们希望定义周一到周日这7个常量,可以用7个不同的int
表示:
1
2
3
4
5
6
7
8
9
public class Weekday {
public static final int SUN = 0;
public static final int MON = 1;
public static final int TUE = 2;
public static final int WED = 3;
public static final int THU = 4;
public static final int FRI = 5;
public static final int SAT = 6;
}
copy
使用常量的时候,可以这么引用:
1
2
3
if (day == Weekday.SAT || day == Weekday.SUN ) {
// TODO: work at home
}
copy
也可以把常量定义为字符串类型,例如,定义3种颜色的常量:
1
2
3
4
5
public class Color {
public static final String RED = "r" ;
public static final String GREEN = "g" ;
public static final String BLUE = "b" ;
}
copy
使用常量的时候,可以这么引用:
1
2
3
4
String color = ...
if (Color.RED .equals (color)) {
// TODO:
}
copy
无论是int
常量还是String
常量,使用这些常量来表示一组枚举值的时候,有一个严重的问题就是,编译器无法检查每个值的合理性。例如:
1
2
3
4
5
if (weekday == 6 || weekday == 7) {
if (tasks == Weekday.MON ) {
// TODO:
}
}
copy
上述代码编译和运行均不会报错,但存在两个问题:
注意到Weekday
定义的常量范围是0
~6
,并不包含7
,编译器无法检查不在枚举中的int
值;
定义的常量仍可与其他变量比较,但其用途并非是枚举星期值。
enum#
为了让编译器能自动检查某个值在枚举的集合内,并且,不同用途的枚举需要不同的类型来标记,不能混用,我们可以使用enum
来定义枚举类:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Main {
public static void main(String[] args) {
Weekday day = Weekday.SUN ;
if (day == Weekday.SAT || day == Weekday.SUN ) {
System.out .println ("Work at home!" );
} else {
System.out .println ("Work at office!" );
}
}
}
enum Weekday {
SUN, MON, TUE, WED, THU, FRI, SAT;
}
copy
注意到定义枚举类是通过关键字enum
实现的,我们只需依次列出枚举的常量名。
和int
定义的常量相比,使用enum
定义枚举有如下好处:
首先,enum
常量本身带有类型信息,即Weekday.SUN
类型是Weekday
,编译器会自动检查出类型错误。例如,下面的语句不可能编译通过:
1
2
3
int day = 1;
if (day == Weekday.SUN ) { // Compile error: bad operand types for binary operator '=='
}
copy
其次,不可能引用到非枚举的值,因为无法通过编译。
最后,不同类型的枚举不能互相比较或者赋值,因为类型不符。例如,不能给一个Weekday
枚举类型的变量赋值为Color
枚举类型的值:
1
2
Weekday x = Weekday.SUN ; // ok!
Weekday y = Color.RED ; // Compile error: incompatible types
copy
这就使得编译器可以在编译期自动检查出所有可能的潜在错误。
enum的比较#
使用enum
定义的枚举类是一种引用类型。前面我们讲到,引用类型比较,要使用equals()
方法,如果使用==
比较,它比较的是两个引用类型的变量是否是同一个对象。因此,引用类型比较,要始终使用equals()
方法,但enum
类型可以例外。
这是因为enum
类型的每个常量在JVM中只有一个唯一实例,所以可以直接用==
比较:
1
2
3
4
if (day == Weekday.FRI ) { // ok!
}
if (day.equals (Weekday.SUN )) { // ok, but more code!
}
copy
enum类型#
通过enum
定义的枚举类,和其他的class
有什么区别?
答案是没有任何区别。enum
定义的类型就是class
,只不过它有以下几个特点:
定义的enum
类型总是继承自java.lang.Enum
,且无法被继承;
只能定义出enum
的实例,而无法通过new
操作符创建enum
的实例;
定义的每个实例都是引用类型的唯一实例;
可以将enum
类型用于switch
语句。
例如,我们定义的Color
枚举类:
1
2
3
public enum Color {
RED, GREEN, BLUE;
}
copy
编译器编译出的class
大概就像这样:
1
2
3
4
5
6
7
8
public final class Color extends Enum { // 继承自Enum,标记为final class
// 每个实例均为全局唯一:
public static final Color RED = new Color();
public static final Color GREEN = new Color();
public static final Color BLUE = new Color();
// private构造方法,确保外部无法调用new操作符:
private Color() {}
}
copy
所以,编译后的enum
类和普通class
并没有任何区别。但是我们自己无法按定义普通class
那样来定义enum
,必须使用enum
关键字,这是Java语法规定的。
因为enum
是一个class
,每个枚举的值都是class
实例,因此,这些实例有一些方法:
name()#
返回常量名,例如:
1
String s = Weekday.SUN .name (); // "SUN"
copy
ordinal()#
返回定义的常量的顺序,从0开始计数,例如:
1
int n = Weekday.MON .ordinal (); // 1
copy
改变枚举常量定义的顺序就会导致ordinal()
返回值发生变化。例如:
1
2
3
public enum Weekday {
SUN, MON, TUE, WED, THU, FRI, SAT;
}
copy
和
1
2
3
public enum Weekday {
MON, TUE, WED, THU, FRI, SAT, SUN;
}
copy
的ordinal
就是不同的。如果在代码中编写了类似if(x.ordinal()==1)
这样的语句,就要保证enum
的枚举顺序不能变。新增的常量必须放在最后。
有些童鞋会想,Weekday
的枚举常量如果要和int
转换,使用ordinal()
不是非常方便?比如这样写:
1
2
String task = Weekday.MON .ordinal () + "/ppt" ;
saveToFile(task);
copy
但是,如果不小心修改了枚举的顺序,编译器是无法检查出这种逻辑错误的。要编写健壮的代码,就不要依靠ordinal()
的返回值。因为enum
本身是class
,所以我们可以定义private
的构造方法,并且,给每个枚举常量添加字段:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Main {
public static void main(String[] args) {
Weekday day = Weekday.SUN ;
if (day.dayValue == 6 || day.dayValue == 0) {
System.out .println ("Work at home!" );
} else {
System.out .println ("Work at office!" );
}
}
}
enum Weekday {
MON(1), TUE(2), WED(3), THU(4), FRI(5), SAT(6), SUN(0);
public final int dayValue;
private Weekday(int dayValue) {
this .dayValue = dayValue;
}
}
copy
这样就无需担心顺序的变化,新增枚举常量时,也需要指定一个int
值。
注意:枚举类的字段也可以是非final类型,即可以在运行期修改,但是不推荐这样做!
默认情况下,对枚举常量调用toString()
会返回和name()
一样的字符串。但是,toString()
可以被覆写,而name()
则不行。我们可以给Weekday
添加toString()
方法:
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
public class Main {
public static void main(String[] args) {
Weekday day = Weekday.SUN ;
if (day.dayValue == 6 || day.dayValue == 0) {
System.out .println ("Today is " + day + ". Work at home!" );
} else {
System.out .println ("Today is " + day + ". Work at office!" );
}
}
}
enum Weekday {
MON(1, "星期一" ), TUE(2, "星期二" ), WED(3, "星期三" ), THU(4, "星期四" ), FRI(5, "星期五" ), SAT(6, "星期六" ), SUN(0, "星期日" );
public final int dayValue;
private final String chinese;
private Weekday(int dayValue, String chinese) {
this .dayValue = dayValue;
this .chinese = chinese;
}
@Override
public String toString() {
return this .chinese ;
}
}
copy
覆写toString()
的目的是在输出时更有可读性。注意:判断枚举常量的名字,要始终使用name()方法,绝不能调用toString()!
switch#
最后,枚举类可以应用在switch
语句中。因为枚举类天生具有类型信息和有限个枚举常量,所以比int
、String
类型更适合用在switch
语句中:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class Main {
public static void main(String[] args) {
Weekday day = Weekday.SUN ;
switch (day) {
case MON:
case TUE:
case WED:
case THU:
case FRI:
System.out .println ("Today is " + day + ". Work at office!" );
break ;
case SAT:
case SUN:
System.out .println ("Today is " + day + ". Work at home!" );
break ;
default :
throw new RuntimeException("cannot process " + day);
}
}
}
enum Weekday {
MON, TUE, WED, THU, FRI, SAT, SUN;
}
copy
加上default
语句,可以在漏写某个枚举常量时自动报错,从而及时发现错误。
Java使用enum
定义枚举类型,它被编译器编译为final class Xxx extends Enum { … }
;
通过name()
获取常量定义的字符串,注意不要使用toString()
;
通过ordinal()
返回常量定义的顺序(无实质意义);
可以为enum
编写构造方法、字段和方法
enum
的构造方法要声明为private
,字段强烈建议声明为final
;
enum
适合用在switch
语句中。
记录类Record#
使用String
、Integer
等类型的时候,这些类型都是不变类,一个不变类具有以下特点:
定义class时使用final
,无法派生子类;
每个字段使用final
,保证创建实例后无法修改任何字段。
假设我们希望定义一个Point
类,有x
、y
两个变量,同时它是一个不变类,可以这么写:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public final class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this .x = x;
this .y = y;
}
public int x() {
return this .x ;
}
public int y() {
return this .y ;
}
}
copy
为了保证不变类的比较,还需要正确覆写equals()
和hashCode()
方法,这样才能在集合类中正常使用。后续我们会详细讲解正确覆写equals()
和hashCode()
,这里演示Point
不变类的写法目的是,这些代码写起来都非常简单,但是很繁琐。
record#
从Java 14开始,引入了新的Record
类。我们定义Record
类时,使用关键字record
。把上述Point
类改写为Record
类,代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
public class Main {
public static void main(String[] args) {
Point p = new Point(123, 456);
System.out .println (p.x ());
System.out .println (p.y ());
System.out .println (p);
}
}
public record Point(int x, int y) {
}
copy
仔细观察Point
的定义:
1
public record Point(int x, int y) {}
copy
把上述定义改写为class,相当于以下代码:
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 final class Point extends Record {
private final int x;
private final int y;
public Point(int x, int y) {
this .x = x;
this .y = y;
}
public int x() {
return this .x ;
}
public int y() {
return this .y ;
}
public String toString() {
return String.format ("Point[x=%s, y=%s]" , x, y);
}
public boolean equals(Object o) {
...
}
public int hashCode() {
...
}
}
copy
除了用final
修饰class以及每个字段外,编译器还自动为我们创建了构造方法,和字段名同名的方法,以及覆写toString()
、equals()
和hashCode()
方法。
换句话说,使用record
关键字,可以一行写出一个不变类。
和enum
类似,我们自己不能直接从Record
派生,只能通过record
关键字由编译器实现继承。
构造方法#
编译器默认按照record
声明的变量顺序自动创建一个构造方法,并在方法内给字段赋值。那么问题来了,如果我们要检查参数,应该怎么办?
假设Point
类的x
、y
不允许负数,我们就得给Point
的构造方法加上检查逻辑:
1
2
3
4
5
6
7
public record Point(int x, int y) {
public Point {
if (x < 0 || y < 0) {
throw new IllegalArgumentException();
}
}
}
copy
注意到方法public Point {...}
被称为Compact Constructor,它的目的是让我们编写检查逻辑,编译器最终生成的构造方法如下:
1
2
3
4
5
6
7
8
9
10
11
12
public final class Point extends Record {
public Point(int x, int y) {
// 这是我们编写的Compact Constructor:
if (x < 0 || y < 0) {
throw new IllegalArgumentException();
}
// 这是编译器继续生成的赋值代码:
this .x = x;
this .y = y;
}
...
}
copy
作为record
的Point
仍然可以添加静态方法。一种常用的静态方法是of()
方法,用来创建Point
:
1
2
3
4
5
6
7
8
public record Point(int x, int y) {
public static Point of() {
return new Point(0, 0);
}
public static Point of(int x, int y) {
return new Point(x, y);
}
}
copy
这样我们可以写出更简洁的代码:
1
2
var z = Point.of ();
var p = Point.of (123, 456);
copy
从Java 14开始,提供新的record
关键字,可以非常方便地定义Data Class:
使用record
定义的是不变类;
可以编写Compact Constructor对参数进行验证;
可以定义静态方法。
BigInteger#
BigInteger#
在Java中,由CPU原生提供的整型最大范围是64位long
型整数。使用long
型整数可以直接通过CPU指令进行计算,速度非常快。
如果我们使用的整数范围超过了long
型怎么办?这个时候,就只能用软件来模拟一个大整数。java.math.BigInteger
就是用来表示任意大小的整数。BigInteger
内部用一个int[]
数组来模拟一个非常大的整数:
1
2
BigInteger bi = new BigInteger("1234567890" );
System.out .println (bi.pow (5)); // 2867971860299718107233761438093672048294900000
copy
对BigInteger
做运算的时候,只能使用实例方法,例如,加法运算:
1
2
3
BigInteger i1 = new BigInteger("1234567890" );
BigInteger i2 = new BigInteger("12345678901234567890" );
BigInteger sum = i1.add (i2); // 12345678902469135780
copy
和long
型整数运算比,BigInteger
不会有范围限制,但缺点是速度比较慢。
也可以把BigInteger
转换成long
型:
1
2
3
BigInteger i = new BigInteger("123456789000" );
System.out .println (i.longValue ()); // 123456789000
System.out .println (i.multiply (i).longValueExact ()); // java.lang.ArithmeticException: BigInteger out of long range
copy
使用longValueExact()
方法时,如果超出了long
型的范围,会抛出ArithmeticException
。
BigInteger
和Integer
、Long
一样,也是不可变类,并且也继承自Number
类。因为Number
定义了转换为基本类型的几个方法:
转换为byte
:byteValue()
转换为short
:shortValue()
转换为int
:intValue()
转换为long
:longValue()
转换为float
:floatValue()
转换为double
:doubleValue()
因此,通过上述方法,可以把BigInteger
转换成基本类型。如果BigInteger
表示的范围超过了基本类型的范围,转换时将丢失高位信息,即结果不一定是准确的。如果需要准确地转换成基本类型,可以使用intValueExact()
、longValueExact()
等方法,在转换时如果超出范围,将直接抛出ArithmeticException
异常。
如果BigInteger
的值甚至超过了float
的最大范围(3.4x1038),那么返回的float是什么呢?
1
2
3
4
5
6
7
public class Main {
public static void main(String[] args) {
BigInteger n = new BigInteger("999999" ).pow (99);
float f = n.floatValue ();
System.out .println (f);
}
}
copy
BigDecimal的使用#
和BigInteger
类似,BigDecimal
可以表示一个任意大小且精度完全准确的浮点数。
1
2
BigDecimal bd = new BigDecimal("123.4567" );
System.out .println (bd.multiply (bd)); // 15241.55677489
copy
BigDecimal
用scale()
表示小数位数,例如:
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
copy
通过BigDecimal
的stripTrailingZeros()
方法,可以将一个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
copy
如果一个BigDecimal
的scale()
返回负数,例如,-2
,表示这个数是个整数,并且末尾有2个0。
可以对一个BigDecimal
设置它的scale
,如果精度比原始值低,那么按照指定的方法进行四舍五入或者直接截断:
1
2
3
4
5
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);
copy
对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,因为除不尽
copy
还可以对BigDecimal
做除法的同时求余数:
1
2
3
4
5
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
copy
调用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的整数倍
}
copy
创建BigDecimal#
禁止使用构造方法BigDecimal(double)
的方式把double值转化为BigDecimal对象。
说明:BigDecimal(double)
存在精度损失风险,在精确计算或值比较的场景中可能会导致业务逻辑异常。
如:BigDecimal g=new BigDecimal(0.1F);
实际的存储值为:0.10000000149
正例:优先推荐入参为String的构造方法,或使用BigDecimal的valueOf方法,此方法内部其实执行了
Double的toString,而Double的toString按double的实际能表达的精度对尾数进行了截断。
1
2
3
4
// string 构造方法
BigDecimal recommend1 new BigDecimal("0.1" );
// 或者使用工厂方法
BigDecimal recommend2 BigDecimal.valueOf (0.1 );
copy
使用对比
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
36
37
38
// 这里对比了两种形式,第一种直接value写数字的值,第二种用string来表示
BigDecimal num1 = new BigDecimal(0.005 );
BigDecimal num2 = new BigDecimal(1000000);
BigDecimal num3 = new BigDecimal(-1000000);
//尽量用字符串的形式初始化
BigDecimal num12 = new BigDecimal("0.005" );
BigDecimal num22 = new BigDecimal("1000000" );
BigDecimal num32 = new BigDecimal("-1000000" );
// 我们对其进行加减乘除绝对值的运算。
//加法
BigDecimal result1 = num1.add (num2);
BigDecimal result12 = num12.add (num22);
//减法
BigDecimal result2 = num1.subtract (num2);
BigDecimal result22 = num12.subtract (num22);
//乘法
BigDecimal result3 = num1.multiply (num2);
BigDecimal result32 = num12.multiply (num22);
//绝对值
BigDecimal result4 = num3.abs ();
BigDecimal result42 = num32.abs ();
//除法
BigDecimal result5 = num2.divide (num1,20,BigDecimal.ROUND_HALF_UP );
BigDecimal result52 = num22.divide (num12,20,BigDecimal.ROUND_HALF_UP );
我把result全部输出可以看到结果, 这里出现了差异, 这也是为什么初始化建议使用string的原因。
※ 注意:
- System.out .println ()中的数字默认是double类型的, double类型小数计算不精准。
- 使用BigDecimal类构造方法传入double类型时, 计算的结果也是不精确的!
因为不是所有的浮点数都能够被精确的表示成一个double 类型值, 有些浮点数值不能够被精确的表示成 double 类型值, 因此它会被表示成与它最接近的 double 类型的值。 必须改用传入String的构造方法。 这一点在BigDecimal类的构造方法注释中有说明。
copy
加减乘除#
add
方法用于将两个 BigDecimal
对象相加,
subtract
方法用于将两个 BigDecimal
对象相减。
multiply
方法用于将两个 BigDecimal
对象相乘,
divide
方法用于将两个 BigDecimal
对象相除。
1
2
3
4
5
6
7
BigDecimal a = new BigDecimal("1.0" );
BigDecimal b = new BigDecimal("0.9" );
System.out .println (a.add (b));// 1.9
System.out .println (a.subtract (b));// 0.1
System.out .println (a.multiply (b));// 0.90
System.out .println (a.divide (b));// 无法除尽,抛出 ArithmeticException 异常
System.out .println (a.divide (b, 2, RoundingMode.HALF_UP ));// 1.11
copy
这里需要注意的是,在我们使用 divide
方法的时候尽量使用 3 个参数版本,并且RoundingMode
不要选择 UNNECESSARY
,否则很可能会遇到 ArithmeticException
(无法除尽出现无限循环小数的时候),其中 scale
表示要保留几位小数,roundingMode
代表保留规则。
1
2
3
public BigDecimal divide(BigDecimal divisor, int scale, RoundingMode roundingMode) {
return divide(divisor, scale, roundingMode.oldMode );
}
copy
除法divide()参数使用#
使用除法函数在divide的时候要设置各种参数,要精确的小数位数和舍入模式,不然会出现报错。
我们可以看到divide函数配置的参数如下:
(BigDecimal divisor 除数, int scale 精确小数位, int roundingMode 舍入模式)
可以看到舍入模式有很多种BigDecimal.ROUND_XXXX_XXX
, 具体都是什么意思呢。
八种舍入模式解释如下
ROUND_UP
舍入远离零的舍入模式。
在丢弃非零部分之前始终增加数字(始终对非零舍弃部分前面的数字加1)。
注意,此舍入模式始终不会减少计算值的大小。
ROUND_DOWN
接近零的舍入模式。
在丢弃某部分之前始终不增加数字(从不对舍弃部分前面的数字加1,即截短)。
注意,此舍入模式始终不会增加计算值的大小。
ROUND_CEILING
接近正无穷大的舍入模式。
如果 BigDecimal 为正,则舍入行为与 ROUND_UP 相同;
如果为负,则舍入行为与 ROUND_DOWN 相同。
注意,此舍入模式始终不会减少计算值。
ROUND_FLOOR
接近负无穷大的舍入模式。
如果 BigDecimal 为正,则舍入行为与 ROUND_DOWN 相同;
如果为负,则舍入行为与 ROUND_UP 相同。
注意,此舍入模式始终不会增加计算值。
ROUND_HALF_UP
向“最接近的”数字舍入,如果与两个相邻数字的距离相等,则为向上舍入的舍入模式。
如果舍弃部分 >= 0.5,则舍入行为与 ROUND_UP 相同;否则舍入行为与 ROUND_DOWN 相同。
注意,这是我们大多数人在小学时就学过的舍入模式(四舍五入)。
ROUND_HALF_DOWN
向“最接近的”数字舍入,如果与两个相邻数字的距离相等,则为上舍入的舍入模式。
如果舍弃部分 > 0.5,则舍入行为与 ROUND_UP 相同;否则舍入行为与 ROUND_DOWN 相同(五舍六入)。
ROUND_HALF_EVEN
向“最接近的”数字舍入,如果与两个相邻数字的距离相等,则向相邻的偶数舍入。
如果舍弃部分左边的数字为奇数,则舍入行为与 ROUND_HALF_UP 相同;
如果为偶数,则舍入行为与 ROUND_HALF_DOWN 相同。
注意,在重复进行一系列计算时,此舍入模式可以将累加错误减到最小。
此舍入模式也称为“银行家舍入法”,主要在美国使用。四舍六入,五分两种情况。
如果前一位为奇数,则入位,否则舍去。
以下例子为保留小数点1位,那么这种舍入方式下的结果。
1.15>1.2 1.25>1.2
ROUND_UNNECESSARY
断言请求的操作具有精确的结果,因此不需要舍入。
如果对获得精确结果的操作指定此舍入模式,则抛出ArithmeticException。
保留几位小数#
通过 setScale
方法设置保留几位小数以及保留规则。
1
2
3
BigDecimal m = new BigDecimal("1.255433" );
BigDecimal n = m.setScale (3,RoundingMode.HALF_DOWN );
System.out .println (n);// 1.255
copy
浮点数运算精度丢失(为什么使用bigDecimal)#
1
2
3
4
5
float a = 2.0f - 1.9f ;
float b = 1.8f - 1.7f ;
System.out .println (a);// 0.100000024
System.out .println (b);// 0.099999905
System.out .println (a == b);// false
copy
为什么浮点数 float
或 double
运算的时候会有精度丢失的风险呢?
这个和计算机保存浮点数的机制有很大关系。我们知道计算机是二进制的,而且计算机在表示一个数字时,宽度是有限的,无限循环的小数存储在计算机时,只能被截断,所以就会导致小数精度发生损失的情况。这也就是解释了为什么浮点数没有办法用二进制精确表示。
就比如说十进制下的 0.2 就没办法精确转换成二进制小数:
1
2
3
4
5
6
7
// 0.2 转换为二进制数的过程为,不断乘以 2,直到不存在小数为止,
// 在这个计算过程中,得到的整数部分从上到下排列就是二进制的结果。
0.2 * 2 = 0.4 -> 0
0.4 * 2 = 0.8 -> 0
0.8 * 2 = 1.6 -> 1
0.6 * 2 = 1.2 -> 1
0.2 * 2 = 0.4 -> 0( 发生循环)
copy
BigDecimal比较大小#
在比较两个BigDecimal
的值是否相等时,要特别注意,使用equals()
方法不但要求两个BigDecimal
的值相等,还要求它们的scale()
相等:
1
2
3
4
5
BigDecimal d1 = new BigDecimal("1.0" );
BigDecimal d2 = new BigDecimal("1.00" );
System.out .println (d1.equals (d2)); // false,因为scale不同
System.out .println (d1.equals (d2.stripTrailingZeros ())); // true,因为d2去除尾部0后scale变为2
System.out .println (d1.compareTo (d2)); // 0
copy
必须使用compareTo()
方法来比较,它根据两个值的大小分别返回负数、正数和0
,分别表示小于、大于和等于。
总是使用compareTo()比较两个BigDecimal的值,不要使用equals()!
应该使用compareTo
比较
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
BigDecimal a = new BigDecimal (1);
BigDecimal b = new BigDecimal (2);
//使用compareTo方法比较
//注意:a、b均不能为null,否则会报空指针
System.out .println (a.compareTo (b));
if (a.compareTo (b) == -1){
System.out .println ("a小于b" );
}
if (a.compareTo (b) == 0){
System.out .println ("a等于b" );
}
if (a.compareTo (b) == 1){
System.out .println ("a大于b" );
}
copy
如果查看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;
}
copy
BigDecimal
也是从Number
继承的,也是不可变对象。
BigDecimal 在方法中累加的问题(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
private static void totalScore(List<BigDecimal> scoreList, BigDecimal totalScore) {
// 统计这些题的总分和学生得分
for (BigDecimal i : scoreList) {
totalScore = totalScore.add (i);
}
}
public static void main(String[] args) {
BigDecimal a = new BigDecimal("1" );
BigDecimal b = new BigDecimal("2" );
List<BigDecimal> list = new ArrayList<>(10);
list.add (a);
list.add (b);
BigDecimal all = new BigDecimal("0" );
// 调用方法
totalScore(list,all);
// 不调用方法
for (BigDecimal i : list) {
all = all.add (i);
}
System.out .println (all);
// 调用方法结果是0
// 不调用方法结果是3
}
copy
为什么结果是0呢?
这是因为Java中的方法参数传递方式是按值传递(pass-by-value)。按值传递意味着方法接收的是实际参数值的一个副本,而不是原始参数本身。当你将一个变量作为参数传递给方法时,方法会获得该变量的副本,对副本的修改不会影响原始变量。
在给定的代码中,all1
和all2
是BigDecimal
类型的对象,在Java中,对象是引用类型。当你将这些对象作为参数传递给totalScore
方法时,实际上是将对象的引用(副本)传递给了方法。这个副本指向相同的对象,但是它不是原始变量本身。
在totalScore
方法中,虽然你修改了score
和totalScore
的值,实际上是修改了它们引用的对象。但是这些修改只在方法内部有效,不会影响到原始的all1
和all2
变量。
如果你想要在方法中修改原始变量的值,你可以考虑将它们定义为实例变量或者使用数组或容器类来传递,并通过索引或引用进行修改。
BigDecimal工具类#
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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
import java.math.BigDecimal;
import java.math.RoundingMode;
/**
* 简化BigDecimal计算的小工具类
*/
public class BigDecimalUtil {
/**
* 默认除法运算精度
*/
private static final int DEF_DIV_SCALE = 10;
private BigDecimalUtil() {
}
/**
* 提供精确的加法运算。
*
* @param v1 被加数
* @param v2 加数
* @return 两个参数的和
*/
public static double add(double v1, double v2) {
BigDecimal b1 = BigDecimal.valueOf (v1);
BigDecimal b2 = BigDecimal.valueOf (v2);
return b1.add (b2).doubleValue ();
}
/**
* 提供精确的减法运算。
*
* @param v1 被减数
* @param v2 减数
* @return 两个参数的差
*/
public static double subtract(double v1, double v2) {
BigDecimal b1 = BigDecimal.valueOf (v1);
BigDecimal b2 = BigDecimal.valueOf (v2);
return b1.subtract (b2).doubleValue ();
}
/**
* 提供精确的乘法运算。
*
* @param v1 被乘数
* @param v2 乘数
* @return 两个参数的积
*/
public static double multiply(double v1, double v2) {
BigDecimal b1 = BigDecimal.valueOf (v1);
BigDecimal b2 = BigDecimal.valueOf (v2);
return b1.multiply (b2).doubleValue ();
}
/**
* 提供(相对)精确的除法运算,当发生除不尽的情况时,精确到
* 小数点以后10位,以后的数字四舍五入。
*
* @param v1 被除数
* @param v2 除数
* @return 两个参数的商
*/
public static double divide(double v1, double v2) {
return divide(v1, v2, DEF_DIV_SCALE);
}
/**
* 提供(相对)精确的除法运算。当发生除不尽的情况时,由scale参数指
* 定精度,以后的数字四舍五入。
*
* @param v1 被除数
* @param v2 除数
* @param scale 表示表示需要精确到小数点以后几位。
* @return 两个参数的商
*/
public static double divide(double v1, double v2, int scale) {
if (scale < 0) {
throw new IllegalArgumentException(
"The scale must be a positive integer or zero" );
}
BigDecimal b1 = BigDecimal.valueOf (v1);
BigDecimal b2 = BigDecimal.valueOf (v2);
return b1.divide (b2, scale, RoundingMode.HALF_UP ).doubleValue ();
}
/**
* 提供精确的小数位四舍五入处理。
*
* @param v 需要四舍五入的数字
* @param scale 小数点后保留几位
* @return 四舍五入后的结果
*/
public static double round(double v, int scale) {
if (scale < 0) {
throw new IllegalArgumentException(
"The scale must be a positive integer or zero" );
}
BigDecimal b = BigDecimal.valueOf (v);
BigDecimal one = new BigDecimal("1" );
return b.divide (one, scale, RoundingMode.HALF_UP ).doubleValue ();
}
/**
* 提供精确的类型转换(Float)
*
* @param v 需要被转换的数字
* @return 返回转换结果
*/
public static float convertToFloat(double v) {
BigDecimal b = new BigDecimal(v);
return b.floatValue ();
}
/**
* 提供精确的类型转换(Int)不进行四舍五入
*
* @param v 需要被转换的数字
* @return 返回转换结果
*/
public static int convertsToInt(double v) {
BigDecimal b = new BigDecimal(v);
return b.intValue ();
}
/**
* 提供精确的类型转换(Long)
*
* @param v 需要被转换的数字
* @return 返回转换结果
*/
public static long convertsToLong(double v) {
BigDecimal b = new BigDecimal(v);
return b.longValue ();
}
/**
* 返回两个数中大的一个值
*
* @param v1 需要被对比的第一个数
* @param v2 需要被对比的第二个数
* @return 返回两个数中大的一个值
*/
public static double returnMax(double v1, double v2) {
BigDecimal b1 = new BigDecimal(v1);
BigDecimal b2 = new BigDecimal(v2);
return b1.max (b2).doubleValue ();
}
/**
* 返回两个数中小的一个值
*
* @param v1 需要被对比的第一个数
* @param v2 需要被对比的第二个数
* @return 返回两个数中小的一个值
*/
public static double returnMin(double v1, double v2) {
BigDecimal b1 = new BigDecimal(v1);
BigDecimal b2 = new BigDecimal(v2);
return b1.min (b2).doubleValue ();
}
/**
* 精确对比两个数字
*
* @param v1 需要被对比的第一个数
* @param v2 需要被对比的第二个数
* @return 如果两个数一样则返回0,如果第一个数比第二个数大则返回1,反之返回-1
*/
public static int compareTo(double v1, double v2) {
BigDecimal b1 = BigDecimal.valueOf (v1);
BigDecimal b2 = BigDecimal.valueOf (v2);
return b1.compareTo (b2);
}
}
copy
常用工具类#
Java的核心库提供了大量的现成的类供我们使用。本节我们介绍几个常用的工具类。
Math#
顾名思义,Math
类就是用来进行数学计算的,它提供了大量的静态方法来便于我们实现数学计算:
求绝对值:
1
2
Math.abs(-100); // 100
Math.abs(-7.8); // 7.8
copy
取最大或最小值:
1
2
Math.max(100, 99); // 100
Math.min(1.2, 2.3); // 1.2
copy
计算x的y次方:
1
Math.pow(2, 10); // 2的10次方=1024
copy
计算x开平方:
1
Math.sqrt(2); // 1.414...
copy
计算e的x次方:
1
Math.exp(2); // 7.389...
copy
计算以e为底的对数:
1
Math.log(4); // 1.386...
copy
计算以10为底的对数:
1
Math.log10(100); // 2
copy
三角函数:
1
2
3
4
5
Math.sin(3.14); // 0.00159...
Math.cos(3.14); // -0.9999...
Math.tan(3.14); // -0.0015...
Math.asin(1.0); // 1.57079...
Math.acos(1.0); // 0.0
copy
Math还提供了几个数学常量:
1
2
3
double pi = Math.PI; // 3.14159...
double e = Math.E; // 2.7182818...
Math.sin(Math.PI / 6); // sin(π/6) = 0.5
copy
生成一个随机数x,x的范围是0 <= x < 1
:返回的是0(包含)到1(不包含)之间的double值
1
double d = Math.random(); // 0.53907... 每次都不一样
copy
若要获取int类型的整数,只需要将上面的结果转行成int类型即可。比如,获取[0, 100)之间的int整数
。方法如下:
1
2
final double d = Math.random ();
final int i = (int )(d*100);
copy
如果我们要生成一个区间在[MIN, MAX)
的随机数,可以借助Math.random()
实现,计算如下:
1
2
3
4
5
6
7
8
9
10
11
12
// 区间在[MIN, MAX)的随机数
public class Main {
public static void main(String[] args) {
double x = Math.random (); // x的范围是[0,1)
double min = 10;
double max = 50;
double y = x * (max - min) + min; // y的范围是[10,50)
long n = (long ) y; // n的范围是[10,50)的整数
System.out .println (y);
System.out .println (n);
}
}
copy
有些童鞋可能注意到Java标准库还提供了一个StrictMath
,它提供了和Math
几乎一模一样的方法。这两个类的区别在于,由于浮点数计算存在误差,不同的平台(例如x86和ARM)计算的结果可能不一致(指误差不同),因此,StrictMath
保证所有平台计算结果都是完全相同的,而Math
会尽量针对平台优化计算速度,所以,绝大多数情况下,使用Math
就足够了。
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
copy
有童鞋问,每次运行程序,生成的随机数都是不同的,没看出伪随机数 的特性来。
这是因为我们创建Random
实例时,如果不给定种子,就使用系统当前时间戳作为种子,因此每次运行时,种子不同,得到的伪随机数序列就不同。
如果我们在创建Random
实例时指定一个种子,就会得到完全确定的随机数序列:
1
2
3
4
5
6
7
8
9
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...
}
}
copy
前面我们使用的Math.random()
实际上内部调用了Random
类,所以它也是伪随机数,只是我们无法指定种子。
SecureRandom#
有伪随机数,就有真随机数。实际上真正的真随机数只能通过量子力学原理来获取,而我们想要的是一个不可预测的安全的随机数,SecureRandom
就是用来创建安全的随机数的:
1
2
SecureRandom sr = new SecureRandom();
System.out .println (sr.nextInt (100));
copy
SecureRandom
无法指定种子,它使用RNG(random number generator)算法。JDK的SecureRandom
实际上有多种不同的底层实现,有的使用安全随机种子加上伪随机数算法来产生安全的随机数,有的使用真正的随机数生成器。实际使用的时候,可以优先获取高强度的安全随机数生成器,如果没有提供,再使用普通等级的安全随机数生成器:
1
2
3
4
5
6
7
8
9
10
11
12
13
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));
}
}
copy
需要使用安全随机数的时候,必须使用SecureRandom,绝不能使用Random!