yeskery

表达式中的陷阱

关于字符串的陷阱

字符串是 Java 程序中使用最广泛的一种对象,虽然它具有简单易用的特征,但实际上使用字符串也有一些潜在的陷阱,这些陷阱往往会给实际开发带来潜在的困扰。

JVM 对字符串的处理

了解 JVM 对字符串处理之前,首先来看如下一条简单的 Java 语句。
String java = new String("疯狂 Java");

常见的问题是:上面语句创建了几个字符串对象?上面语句实际上创建了 2 个字符串对象,一个是“疯狂 Java”这个直接量对应的字符串对象,一个是由 new String() 构造器返回的字符串对象。

Java程序中创建对象的常规方式有如下4种:

  • 通过 new 调用构造器创建 Java 对象;
  • 通过 Class 对象的 newInstance() 方法调用构造器创建 Java 对象;
  • 通过 Java 的反序列化机制从 IO 流中恢复 Java 对象;
  • 通过 Java 对象提供的 clone() 方法复制一个新的 Java 对象。

除此之外,对于字符串以及 Byte、Short、Int、Long、Character、Float、Double 和 Boolean 这些基本类型的包装类,Java 还允许以直接量的方式来创建 Java 对象,例如如下语句所示:

  1. String str = "abc";
  2. Integer in = 5;

除此之外,也可通过简单的算法表达式、连接运算来创建 Java 对象,例如如下语句所示:

  1. String str2 = "abc" + "xyz"'
  2. Long price = 23 + 12;

对于 Java 程序中的字符直接量,JVM 会使用一个字符串池来保存它们:当第一次使用某个字符串直接量时,JVM 会将它放入字符串池进行缓存。在一般情况下,字符串池中字符串对象不会被垃圾回收,当程序再次使用该字符串时,无需重新创建一个新的字符串,而是直接让引用变量指向字符串池中已有的字符串。示例如下:

  1. public class StringTest {
  2. public static void main(String[] args) {
  3. //str1 的值是字符串直接量
  4. //因此,str1 指向字符串缓存池中的“Hello Java”字符串
  5. String str1 = "Hello Java";
  6. //str2 也指向字符串缓存池中的“Hello Java”字符串
  7. String str2 = "Hello Java";
  8. //下面的程序将输出 true
  9. System.out.println(str1 == str2);
  10. }
  11. }

上面程序中 str1、str2 两个字符串变量的值都是直接量,它们都指向 JVM 的字符串池里的“Hello Java”字符串,因此程序判断 str1 == str2 时将输出 true。

前面已经指出除了通过字符串直接量创建字符串对象之外,也可以通过字符串连接表达式来创建字符串对象,因此也可将一个字符串连接表达式赋给字符串变量。如果这个字符串连接表达式的值可以在编译时确定下来,那么 JVM 会在编译时计算该字符串变量的值,并让它指向字符串池中对应的字符串。示例如下:

  1. public class StringJoinTest {
  2. public static void main(String[] args) {
  3. String str1 = "Hello Java的长度:10";
  4. //虽然 str2 的值不是直接量,但因为 str2 的值可以在编译时确定
  5. //所以 str2 也会直接引用字符串池中对应的字符串
  6. String str2 = "Hello " + "Java" + "的长度:" + 10;
  7. System.out.println(str1 == str2);
  8. }
  9. }

上面程序中定义了一个 str2 变量,虽然它的值是一个字符串连接表达式,但由于这个字符串连接表达式的值可以在编译时就确定下来,因此 JVM 将在编译时计算 str2 的值,并让 str2 指向字符串池中对应的字符串。因此,上面程序判断 str1 == str2 时将输出 true。

注意上面程序中代码中的所有运算数,它们都是字符串直接量、整数直接量,没有变量参与,没有方法调用。因此,JVM 可以在编译时就确定该字符串连接表达式的值,可以让该字符串变量指向字符串池中对应的字符串。但如果程序使用了变量,或者调用了方法,那就只能等运行时才可确定该字符串连接表达式的值,也就无法在编译时确定该字符串变量的值,因此无法利用 JVM 的字符串池。示例如下:

  1. public class StringJoinTest2 {
  2. public static void main(String[] args) {
  3. String str1 = "Hello Java 的长度:10";
  4. //因为 str2 的值包含了方法调用,因此不能在编译时确定
  5. String str2 = "Hello " + "Java" + "的长度:" + "Hello Java".length();
  6. System.out.println(str1 == str2);
  7. int len = 10;
  8. //因为 str3 的值包含了变量,因此不能在编译时确定
  9. String str3 = "Hello " + "Java" + "的长度:" + len;
  10. System.out.println(str1 == str3);
  11. }
  12. }

上面字符串变量 str2 和 str3 的值也是字符串连接运算,但由于 str2 变量对应的连接表达式中包含了一个方法调用,因此程序无法在编译时确定 str2 变量的值,也就不会让 str2 指向 JVM 字符串池中对应的字符串。类似地,str3 的值也是字符串连接的表达式,但由于这个字符串连接表达式中包含了一个 len 变量,因此 str3 变量也不会指向 JVM 字符串池中对应的字符串。因此,程序判断 str1 == str2str1 == str3 时都将输出 false。

当然有一种情况例外,如果字符串连接运算中所有变量都可执行“宏替换”,那么 JVM 一样可以在编译时就确定字符串连接表达式的值,一样会让字符串变量指向 JVM 字符串池中对应字符串。示例如下:

  1. public class StringJoinTest3 {
  2. public static void main(String[] args) {
  3. String str1 = "Hello Java 的长度:10";
  4. final String s1 = "Hello ";
  5. String str2 = s1 + "Java" + "的长度:10";
  6. System.out.println(str1 == str2);
  7. final int len = 10;
  8. //因为 str3 的值包含了变量,因此不能在编译时确定
  9. String str3 = "Hello " + "Java" + "的长度:" + len;
  10. System.out.println(str1 == str3);
  11. }
  12. }

上面程序中 str2 对应字符串连接表达式中包含了 s1 变量,但由于编译器会对 s1 执行“宏替换”,JVM 同样可以在编译时确定 str2 变量的值,因此可以让 str2 指向字符串池中对应的字符串。类似地,str3 对应的字符串连接表达式中包含了 len 变量,但由于编译器会对 len 执行“宏替换”,JVM 也会在编译时确定 str3 变量的值,因此会让 str3 指向字符串池中对应的字符串。因此,程序判断 str1 == str2str1 == str3 时都将输出 true。

最后有一个简单的问题:下面的语句到底创建了几个字符串对象?
String str = "Hello " + "Java ,"
其实这条代码只创建了一个字符串对象,因为 str 的值可以在编译时确定下来,JVM 会在编译时就计算出 str 的值,然后将该字符串直接量放入字符串池中,并让 str 指向字符串池中对应的字符串。

通过这里可以看出一点:当程序中需要使用字符串、基本类型包装类实例时,应该尽量使用字符串直接量、基本类型值的直接量,避免通过 new String()new Interger() 的形式来创建字符串、基本类型包装类实例。这样能保存较好的性能。

不可变的字符串

String 类是一个典型的不可变类。当一个 String 对象创建完成后,该 String 类里包含的字符序列就被固定下来了,以后永远都不能改变。

  1. public class ImmutableString {
  2. public static void main(String[] args) {
  3. //定义一个字符串变量
  4. String str = "Hello ";
  5. System.out.println(System.identityHashCode(str));
  6. //进行字符串连接运算
  7. str = str + "Java";
  8. System.out.println(System.identityHashCode(str));
  9. }
  10. }

前面说过,当一个 String 对象创建完成后,该 String 里包含的字符序列将不能被改变。但可能有些读者感到疑惑:上面 str 变量对应的字符序列不是一直在改变吗?开始等于“Hello”,第一次连接运算后等于“Hello Java”,看起来 str 对应的字符序列可以发生改变。但是要记住,str 只是一个引用类型变量,它并不是真正的 String 对象,它只是指向 String 对象而已。

当执行完 String str = "Hello "; 之后,此时堆内存中有一个“Hello ”的字符串,并且在 main 方法栈中有一个名为 str 的引用类型变量指向了这个字符串。

接着程序执行到 str = str + "Java"; 时,此时的连接运算会把“Hello ”、“Java”两个字符串连接起来得到一个新的字符串,并让 str 指向这个新的字符串。执行这行代码之后,此时在堆内存中有两个字符串,一个是“Hello ”,另一个是“Hello Java”,并且 str 这个引用类型变量也指向了“Hello Java”这个字符串。

从上面的过程中可以看出,str 变量原来指向的字符串对象并没有发生任何改变,它所包含的字符序列依然是“Hello ”,只是 str 变量不再指向它而已。str 变量指向了一个新的 String 对象,因此看到 str 变量所引用 String 对象的字符序列发生了改变。也就是说,发生改变的不是 String 对象,而是 str 变量本身,它改变了指向,指向了一个新的 String 对象。需要指出的是,原来的“Hello ”字符串也许以后永远都不会被用到,但这个字符串并不会被垃圾回收,因为它将一直存在于字符串缓存池中————这就是 Java 内存泄漏的原因之一。

上面程序中使用了 System 类的 identityHashCode() 静态方法来获取 str 的 identityHashCode 值,将会发生 2 次返回的 identityHashCode 值并不相同的状况,这表明 2 次访问 str 时分别指向 2 个不同的 String 对象。

System 提供的 identityHashCode() 静态方法用于获取某个对象唯一的 hashCode 值,这个 identityHashCode() 的返回值与该类是否重写了 hashCode() 方法无关。只有当两个对象相同时,它们的 identityHashCode 值才会相等。

对于 String 类而言,它代表字符串序列不可改变的字符串,因此如果程序需要一个字符序列会发生改变的字符串,那应该考虑使用 StringBuilder 或 StringBuffer。很多资料上都推荐使用 StringBuffer,那是因为这些资料都是 JDK1.5 问世之前的————过时了。

实际上通常应该优先考虑使用 StringBuilder。StringBuffer 与 StringBuilder 唯一区别在于,StringBuffer 是线程安全的,也就是说 StringBuffer 类里绝大部分方法都增加了 synchronized 修饰符。对方法增加 synchronized 修饰符可以保证该方法线程安全,但会降低该方法的执行效率。在没有多线程的环境下,应该优先使用 StringBuilder 类来表示字符串。示例如下:

  1. public class MutableString {
  2. public static void main(String[] args) {
  3. StringBuilder str = new StringBuilder("Hello ");
  4. System.out.println(str);
  5. System.out.println(System.identityHashCode(str));
  6. //追加"Java"
  7. str.append("Java");
  8. System.out.println(str);
  9. System.out.println(System.identityHashCode(str));
  10. }
  11. }

上面程序中创建了一个 StringBuilder 对象,用该对象代表字符串。程序两次调用 StringBuilder 的 append() 方法为该字符串追加了另外的子串,程序中 str 引用变量没有发生改变,它一直指向同一个 StringBuilder 对象,但它所指向的 StringBuilder 所包含的字符序列发生了改变。程序 2 次打印 StringBuilder 对象将看到输出不同的字符串,但程序 2 次输出 str 的 identityHashCode 值时将会完全相同,因为 str 依然引用同一个 StringBuilder 对象。

StringBuilder、StringBuffer 都代表字符序列可改变的字符串,其中 StringBuffer 是线程安全的版本,而 StringBuilder 是线程不安全的版本。String 则代表字符串序列不可变的字符串,但 String 不需要线程安全、线程不完全两个版本,因为 String 本身是不可变类,而不可变类总是线程安全的。

字符串比较

如果程序需要比较两个字符串是否相同,用 == 进行判断就可以了;但如果要判断两个字符串所包含的字符序列是否相同,则应该用 String 重写过的 equals() 方法进行比较。String 类重写的 equals() 方法的代码如下:

  1. public boolean equals(Object anObject) {
  2. //如果两个字符串相同,返回 true
  3. if (this == anObject) {
  4. return true;
  5. }
  6. //如果 anObject 是 String 类型
  7. if (anObject instanceof String) {
  8. String anotherString = (String)anObject;
  9. //n代表当前字符串的长度
  10. int n = value.length;
  11. //如果两个字符串的长度相同
  12. if (n == anotherString.value.length) {
  13. //获取当前字符串、anotherString 底层封装的字符数组
  14. char v1[] = value;
  15. char v2[] = anotherString.value;
  16. int i = 0;
  17. //逐个比较 v1 数组和 v2 数组里的每个字符
  18. while (n-- != 0) {
  19. if (v1[i] != v2[i])
  20. return false;
  21. i++;
  22. }
  23. return true;
  24. }
  25. }
  26. return false;
  27. }

从上面的代码可以看出,字符串底层实际上采用一个字符数组来保存该字符串所包含的字符序列,如果当前字符串和被比较字符串底层的字符数组所包含的字符序列完全相等,程序通过 equals() 方法判断两个字符串是否相同就会返回 true。

除此之外,由于 String 类还实现了 Comparable 接口,因此程序还可通过 String 提供的 compareTo() 方法来判断两个字符串之间的大小。当两个字符串所包含的字符串序列相等时,程序通过 compareTo(),将返回 0。下面程序示范了字符串比较的效果:

  1. public class StringCompare {
  2. public static void main(String[] args) {
  3. String s1 = new String("abc");
  4. String s2 = new String("z");
  5. String s3 = new String("abc");
  6. //通过compareTo比较字符串的大小
  7. if (s1.compareTo(s3) == 0) {
  8. System.out.println("s1和s3包含的字符序列相等");
  9. }
  10. if (s1.compareTo(s2) < 0) {
  11. System.out.println("s1 小于 s2");
  12. }
  13. //通过equals比较字符串包含的字符序列是否相同
  14. System.out.println("s1和s3包含的字符序列是否相同:" + s1.equals(s3));
  15. //通过==运算符比较两个字符串引用变量是否指向同一个字符串对象
  16. System.out.println("s1和s3所指向的字符串是否相同:" + (s1 == s3));
  17. }
  18. }

上面程序中的 equals() 方法、==运算符的执行结果都比较清楚,问题是 compareTo() 方法如何判断两个字符串的大小?它的比较规则是这样的:先将两个字符串左对齐,然后从左到右一次比较两个字符串所包含的每个字符,包含较大字符的字符串的值比较大。如果两个字符串通过 compareTo() 比较返回了 0,即说明两个字符串相等,也就是它们所包含的字符序列相同。

表达式类型的陷阱

Java 语言是一门强类型的语言,不仅每个变量具有指定的数据类型,它的表达式也具有指定的数据类型。因此,使用表达式时一定要注意它的数据类型。

所谓强类型的语言,通常具有两个基本特征:1.所有变量必须先声明,然后才能使用,声明变量时必须指定该变量的数据类型;2.一旦某个变量的数据确定下来了,那这个变量将永远只能接受该类型的值,不能“盛装”其它类型的值。

表达式类型的自动提升

Java 语言规定:当一个算术表达式中包含多个基本类型的值时,整个算术表达式的数据类型将发生自动提成。Java 语言中的自动提升规则如下:

  • 所有 byte 型、short 型 和 char 型将被提升到 int 型;
  • 整个算术表达式的数据类型自动提升到与表达式中最高等级操作数同样的类型。操作数的等级排列如下所示:
    char ——→ int ——→ long ——→ float ——→ double
    byte ——→ short ——→ int ——→ long ——→ float ——→ double

下面程序演示了自动类型转换的几种情形:

  1. public class AutoPromote {
  2. public static void main(String[] args) {
  3. //定义一个short类型变量
  4. short sValue = 5;
  5. //表达式中的sValue将自动提升到int类型,则右边的表达式类型为int
  6. //将一个int类型赋给short类型的变量时将发生错误
  7. sValue = sValue - 2;
  8. byte b = 40;
  9. char c = 'a';
  10. int i = 23;
  11. double d = .314;
  12. //右边表达式中最高等级操作数为d(double型)
  13. //则右边表达式的类型为double型,故赋给一个double型变量
  14. double result = b + c + i * d;
  15. //将输出144.222
  16. System.out.println(result);
  17. int val = 3;
  18. //右边表达式中 2 个操作数都是int,故右边表达式的类型为int
  19. //因此,虽然23/3不能除尽,依然得到一个int
  20. int intResult = 23 / val;
  21. //将输出7
  22. System.out.println(intResult);
  23. //程序将自动将7、'a'等基本变量转换为字符串,输出字符串Hello!a7
  24. System.out.println("Hello!" + 'a' + 7);
  25. //程序将'a'当成int处理,因此'a' + 7 得到104,输出字符串104Hello!
  26. System.out.println('a' + 7 + "Hello");
  27. }
  28. }

上面程序中 sValue = sValue - 2; 无法通过编译,因为 sValue 是一个 short 类型的变量,但 sValue - 2 表达式的类型是 int 类型(与 2 保持一致),因此改行代码编译时将无法编译通过。

程序中 double result = b + c + i * d; 代码完全正确,左边算术表达式中等级最高的是 d,它是 double 类型,因此该表达式的类型是 double 类型。

程序中 int intResult = 23 / val; 代码也没有问题,虽然 23 / val 不能整除,但由于 val 是 int 类型,因此 23 / val 也是 int 类型。即使程序无法整除, 23 / val 表达式的类型依然保持为 int 类型,因此 intResult 的值将等于 7。

程序中 System.out.println("Hello!" + 'a' + 7);System.out.println('a' + 7 + "Hello"); 代码则示范了表达式自动转换为字符串的情形:当基本类型的值和 String 进行连接运算时(+ 也可作为连接运算符使用),系统会将基本类型的值自动转换为 String 类型,这样才可让连接运算正常进行。

复合赋值运算符的陷阱

经过前面的介绍,可知下面的语句将会引起编译错误:

  1. short sValue = 5;
  2. sValue = sValue - 2;

因为 sValue - 2 表达式的类型将自动提升为 int 类型,所以程序将一个 int 类型的值赋给 short 类型的变量(sValue) 时导致了编译错误。

但如果将上面代码改为如下形式就没有问题了。

  1. short sValue = 5;
  2. sValue -= 2;

上面的程序使用了 -= 这个复合赋值运算符,此时将不会导致编译错误。

Java 语言几乎允许所有的双目运算符和 = 一起结合成复合赋值运算符,如 +=、-=、*=、/=、%=、<<=、>>=、>>>=、&=、^=、|= 等。根据 Java 语言规范,复合赋值运算符包含了一个隐式的类型转换,也就是说,下面两条语句并不等价:
a = a + 5;
a += 5;

实际上,a += 5 等价于 a = (a 的类型)(a + 5);,这就是复合赋值运算符中包含的隐式类型转换。

对于复合赋值运算符而言,语句 E1 op= E2(其中 op 可以是 +、-、\*、/、%、<<、>>、>>>、&、^、| 等双目运算符) 并不等价于 E1 = E1 op E2,而是等价于如下语句:E1 = (E1的类型)(E1 op E2)

也就是说,复合赋值运算符会自动地将它计算的结果值强制转换为其左侧变量的类型。如果结果的类型与该变量的类型相同,那么这个转型不会造成任何影响。

如果结果值的类型比该变量的类型要大,那么复合赋值运算符将会执行一次类型转换,这个强制类型转换将有可能导致高位“截断”。

  1. public class CompasiteAssign {
  2. public static void main(String[] args) {
  3. short st = 5;
  4. //没有任何问题,系统执行隐式的类型转换
  5. st += 10;
  6. System.out.println(st);
  7. //此时有问题了,因为系统有一个隐式的类型转换,将会发生“溢出”
  8. st += 90000;
  9. System.out.println(st);
  10. }
  11. }

上面的程序两次使用了复合赋值运算符。对于第一次执行 st += 10; 代码而言,程序完全没有任何问题,这条语句相当于 st = (short)(st + 10);,执行完这条语句之后,可以看到 st 依然是一个 short 类型的变量,该变量的值是 15。接下来执行的 st += 90000; 代码会引起高位“截断”,该代码相当于如下语句,st = (short) (90010);

问题是:short 类型变量只能接受 -32768~32767 之间的整数,因此上面程序会将 90010 的高位“截断”,程序最后输出 st 时将看到 24479。

由此可见,复合赋值运算符简单、方便,而且具有性能上的优势,但复合赋值运算符可能有一定的危险:它潜在的隐式类型转换可能在不知不觉中导致计算结果的高位被“截断”。为了避免这种潜在的危险,在如下几种情况下需要特别注意。

  • 将复合赋值运算符运用于 byte、short 或 char 等类型的变量;
  • 将复合赋值运算符运用于 int 类型的变量,而表达式右侧是 long、float 或 double 类的值;
  • 将复合赋值运算符运用于 float 类型的变量,而表达式右侧是 double 类型的值。

以上 3 种情况中复合赋值运算符的隐式类型都可能导致计算结果的高位被“截断”,从而导致实际数据丢失的情形。

大部分时候,因为复合赋值运算符包含一个隐式的类型转换,所以复合赋值运算符比简单赋值运算符更简洁。

但如果把 + 当成字符串连接运算符使用,则 += 运算符左边的变量只能是 String 类型,而不可能是 String 的父类型(如 Object 或 CharSequence 等)。 在新版本的JDK中,已经不存在该问题,Java 会自动调用对象的 toString() 方法,与右侧对象进行连接运算。

  1. public class CompositeAssisgn2 {
  2. public static void main(String[] args) {
  3. //定义两个字符串
  4. Object he = new CompositeAssisgn2();
  5. String crazy = "crazyit.org ,";
  6. //因为 += 左边变量的类型是 String,所以下面的语句是正确的
  7. //he将自动转换为String(也就是使用它的 toString()返回值)
  8. crazy += he;
  9. System.out.println(crazy);
  10. //新版本JDK中,该代码也不会报错,会自动执行对象的toString()方法进行连接运算
  11. System.out.println((he += crazy));
  12. }
  13. }

输入法导致的陷阱

对于 Java 程序而言,它使用空格、Tab 制表符(半角状态)作为分隔符,因此一个 Java 程序中通常需要包含大量的空格。但如果不小心把输入法切换到了全角状态,那么输入的空格都会变成全角空格,编译该程序将会提示“非法字符:\12288”的错误。

基本上,如果编译 Java 程序时编译提示形如“非法字符:\xxxxx”的错误提示,那么就可断定该 Java 程序中包含“全角字符”,逐个删除它们即可。

Java 程序中通常不能包含“全角字符”,但 Java 程序的字符串中完全可以包含“全角字符”,Java 程序的注释中也可以包含“全角字符”。

注释的字符必须合法

大部分时候,Java 编译器会直接忽略到注释部分,但有一种情况例外:Java 要求注释部分的所有字符必须是合法字符。因为Java文档生成的时候,需要用到文档注释,如果文档注释中包含的代码不合法就发发生编译错误,例如:在注释中存在 c:\codes\unit\Hello.java,此时就会出现非法字符错误,Java 程序允许直接使用 \uXXXX 的形式代表字符,它要求 \u 后面的 4 个字符必须是 0~F 字符,而注释中包含了 \unit,这不符合 Java 对 Unicode 转义字符的要求。

转义字符的陷阱

Java 提供了 3 种方式来表示字符:

  • 直接使用单引号括起来的字符值,如 ‘a’;
  • 使用转义字符,如 ‘\n’;
  • 使用 Unicode 转义字符,如 ‘\u0062’。

Java 对待 Unicode 转义字符时不会进行任何处理,它会将 Unicode 转义字符直接替换成对应的字符,这将给 Java 程序带来一些潜在的陷阱。

慎用字符的 Unicode 转义形式

理论上,Unicode 转义字符可以代表任何字符(不考虑那些不在 Unicode 码表内的字符),因此很容易想到:所有字符都应该可以使用 Unicode 转义字符的形式。为了了解 Unicode 转义字符带来的危险,来看如下程序:

  1. public class StringLength {
  2. public static void main(String[] args) {
  3. System.out.println("abc\u000a".length());
  4. }
  5. }

上面的程序会编译的时候会提示“未结束的字符串字面量”,这是因为对于 \u000a 而言,它相当于一个换行符(相当于 \n),所以编译器会提示如上错误。

在极端情况下,完全可以将 public、class 等关键词使用 Unicode 转义字符来表示,如果的 Java 程序是正确的:

  1. \u0070u\u0062lic \u0063l\u0061ss Hello {
  2. public static void main(String[] args) {
  3. System.out.println("Hello World!");
  4. }
  5. }

上面的 Java 程序中包含了大量 Unicode 转义字符,Java编译器将 \u0070 替换成 p,将 \u0062 替换成 b,将 \u0063 替换成 c,将 \u0061 替换成 a。

中止行注释的转义字符

正如前面程序中看到的,在 Java 程序中使用 \u000a 时,它将直接被替换成换行字符(相当于 \n),因此在 Java 注释中使用这个 Unicode 转义字符时要特别小心。示例如下:

  1. public class CommentError {
  2. public static void main(String[] args) {
  3. //\u000a代表一个换行符
  4. char c = 0x000a;
  5. System.out.println(c);
  6. }
  7. }

Java 编译时就会产生“不是语句”的错误。

泛型可能引起的错误

泛型是 JDK1.5 新增的知识点,它允许在使用 Java 类、调用方法时传入一个类型实参,这样就可以让 Java 类、调用方法动态地改变类型。

原始类型变量的赋值

在严格的泛型程序中,使用代泛型声明的类时总是为之指定类型实参,但为了与老的 Java 代码保持一致,Java 也允许使用带泛型声明的类时不指定类型实参。如果使用代泛型声明的类时没有传入实参,那么这个类型参数默认是声明该参数时的第一个上限类型,这个类型参数也被称为 raw type(原始类型)。

当尝试把原始类型的变量赋给带泛型类型的变量时,会发生于一些有趣的事情。示例如下:

  1. public class RawTypeTest {
  2. public static void main(String[] args) {
  3. //创建一个RawType的List集合
  4. List list = new ArrayList();
  5. //为该集合添加3个元素
  6. list.add("疯狂Java讲义");
  7. list.add("轻量级Java EE企业应用实战");
  8. list.add("疯狂Ajax讲义");
  9. //将原始类型的list集合赋给带泛型声明的List集合
  10. List<Integer> intList = list;
  11. //遍历intList集合的每个元素
  12. for (int i = 0;i < intList.size();i++) {
  13. System.out.println(intList.get(i));
  14. }
  15. }
  16. }

上面程序中先定义了一个不带泛型信息的 List 集合,其中所有集合元素都是 String 类型。接着,尝试将该 List 集合赋给一个 List<Integer> 变量。尝试编译上面的程序,一切正常。尝试运行上面的程序,也可以正常输出 intList 集合的 3 个元素:它们都是普通字符串。

通过上面的介绍可以看出当程序把一个原始类型的变量赋给一个带泛型信息的变量时,只要它们的类型保持兼容————例如将 List 变量赋给 List<Integer>,无论 List 集合里实际包含什么类型的元素,系统都不会有任何问题。

不过需要指出的是,当把一个原始类型的变量(如 List 变量)赋给带泛型信息的变量(如 List<Integer>)时会有一个潜在的问题:JVM 会把集合里盛装的所有元素都当作 Integer 来处理。上面程序遍历 List<Integer> 集合时,只是简单地输出每个集合元素,并未涉及到集合元素的类型,因此程序并没有出现异常;否则,程序要么在运行时出现 CLassCastException,要么在编译时提示编译错误。

下面程序在遍历时试图将 intList 集合的每个元素赋给 Integer 变量,如下所示:

  1. public class RawTypeTest2 {
  2. public static void main(String[] args) {
  3. //创建一个RawType的List集合
  4. List list = new ArrayList();
  5. //为该集合添加3个元素
  6. list.add("疯狂Java讲义");
  7. list.add("轻量级Java EE企业应用实战");
  8. list.add("疯狂Ajax讲义");
  9. //将原始类型的list集合赋给带泛型声明的List集合
  10. List<Integer> intList = list;
  11. //遍历intList集合的每个元素
  12. for (int i = 0;i < intList.size();i++) {
  13. Integer in = intList.get(i);
  14. System.out.println(in);
  15. }
  16. }
  17. }

上面的程序遍历 intList 集合时,尝试将每个集合元素赋给 Integer 变量。由于 intList 集合的类型本身就是 List<Integer> 类型,因此编译器会将每个元素都当作 Integer 处理。也就说,上面的程序在编译时不会有任何问题。尝试运行上面的程序,将看到如下运行时异常。
Exception in thread "main" java.lang.ClassCastException: java.lang.String cannot be cast to java.lang.Integer

这个异常信息非常明显,代码 Integer in = intList.get(i); 相当于 Integer in = (Integer) intList.get(i),很明显,intList 所引用的集合里包含的集合元素的类型是 String,而不是 Integer,因此程序在运行这行代码时将引发 ClassCastException 异常。

既然 intList 所引用的集合里包含的集合元素是 String,那么尝试把 intList 集合元素当成 String 类型处理。示例如下:

  1. public class RawTypeTest3 {
  2. public static void main(String[] args) {
  3. //创建一个RawType的List集合
  4. List list = new ArrayList();
  5. //为该集合添加3个元素
  6. list.add("疯狂Java讲义");
  7. list.add("轻量级Java EE企业应用实战");
  8. list.add("疯狂Ajax讲义");
  9. //将原始类型的list集合赋给带泛型声明的List集合
  10. List<Integer> intList = list;
  11. //遍历intList集合的每个元素
  12. for (int i = 0;i < intList.size();i++) {
  13. String in = intList.get(i);
  14. System.out.println(in);
  15. }
  16. }
  17. }

尝试编译上面的程序,将发现编译器直接提示“不兼容的类型”错误。对于程序中的 intList 集合而言,它的类型是 List<Integer> 类型,因此编译器会认为该集合的每个元素都是 Integer 类型,而上面程序尝试将该集合元素赋给一个 String 类型的变量,因此编译器提示编译错误。

上面程序给出的教训有 3 点:

  • 当程序把一个原始类型的变量赋给一个带泛型信息的变量时,总是可以通过编译————只会会提示一些警告信息;
  • 当程序试图访问带泛型声明的集合的集合元素时,编译器总是把集合元素当成泛型类型处理————它并不关心集合里集合元素实际类型;
  • 当程序试图访问带泛型声明的集合元素时,JVM 会遍历每个集合元素自动强制执行转型,如果集合元素的实际类型与集合所带的泛型信息不匹配,运行时将引发 ClassCastException。

原始类型带来的擦除

当把一个具有泛型信息的对象赋给另一个没有泛型信息的变量时,所有在尖括号之间的类型信息都将被丢弃。比如,将一个 List<String> 类型的对象转型为 List,则该 List 对集合元素的类型检查都变成了类型变量的上限(如 Object)。下面示范了这种擦除:

  1. class Apple<T extends Number> {
  2. T size;
  3. public Apple() {
  4. }
  5. public Apple(T size) {
  6. this.size = size;
  7. }
  8. public T getSize() {
  9. return size;
  10. }
  11. public void setSize(T size) {
  12. this.size = size;
  13. }
  14. }
  15. public class ErasureTest {
  16. public static void main(String[] args) {
  17. Apple<Integer> a = new Apple<Integer>(6);
  18. //a的getSize方法返回Integer对象
  19. Integer as = a.getSize();
  20. //把a对象赋给Apple变量,对丢失尖括号的类型信息
  21. Apple b = a;
  22. //b只知道size的类型是 Number
  23. Number size1 = b.getSize();
  24. //下面代码将引起编译错误
  25. Integer size2 = b.getSize();
  26. }
  27. }

上面程序里定义了一个带泛型声明的 Apple 类,其类型形参的上限是 Number,这个类型形参用来定义 Apple 类的 size 属性。程序用 Apple<Integer> a = new Apple<Integer>(6); 代码创建了一个 Apple<Integer> 对象,该 Apple<Integer> 对象传入了 Integer 作为类型实参,所以调用 a 的 getSize() 方法时返回 Integer 类型的值。当把 a 赋给一个不带泛型信息的 b 变量时,编译器就会丢失 a 对象的泛型信息,即所有尖括号里的信息都被丢失;但因为 Apple 的类型形参的上限是 Number 类,所以编译器依然知道 b 的 getSize() 方法返回 Number 类型,但具体是 Number 的哪个子类就不清楚了。

从上面的程序可以看出,当把一个带泛型信息的 Java 对象赋给不带泛型信息的变量时,Java 程序会发生擦除,这种擦除不仅会擦除使用该 Java 类时传入的类型实参,而且会擦除所有的泛型信息,也就是擦除所有尖括号里的信息。示例如下:

  1. class Apple<T extends Number> {
  2. T size;
  3. public Apple() {
  4. }
  5. public Apple(T size) {
  6. this.size = size;
  7. }
  8. public T getSize() {
  9. return size;
  10. }
  11. public void setSize(T size) {
  12. this.size = size;
  13. }
  14. public List<String> getApples() {
  15. List<String> list = new ArrayList<String>();
  16. for (int i = 0;i < 3;i++) {
  17. list.add(new Apple<Integer>(10 * i).toString());
  18. }
  19. return list;
  20. }
  21. public String toString() {
  22. return "Apple[size=" + size + "]";
  23. }
  24. }
  25. public class ErasureTest {
  26. public static void main(String[] args) {
  27. Apple<Integer> a = new Apple<Integer>(6);
  28. for (String apple : a.getApples()) {
  29. System.out.println(apple);
  30. }
  31. //将a变量赋给一个没有泛型声明的变量
  32. //系统将擦除所有泛型信息,也就是擦除所有尖括号里的信息
  33. //也就是说,b对象调用getAppleSizes()方法不再返回List<String>
  34. //而是返回List
  35. Apple b = a;
  36. for (String apple : b.getApples()) {
  37. System.out.println(apple);
  38. }
  39. }
  40. }

上面程序中的 Apple 类也是一个带泛型声明的类。但这个类略有改变,它提供了一个 getApples() 方法,该方法的返回类型是 List<String>,该方法的返回值带有泛型信息。

程序 main 方法中先创建了一个 Apple<integer> 对象,程序调用该对象的 getApples() 方法的返回值肯定是 List<String> 类型的值。程序 Apple b = a; 将 Apple<integer> 对象赋给一个 Apple 变量,此时将发生擦除————该 Apple<integer> 对象将丢失所有的泛型信息,即尖括号里的所有信息,包括 getApples() 方法的返回值类型 List<String> 里的尖括号信息。因此代码 for (String apple : b.getApples()) { 就会提示“不兼容的类型”编译错误。

创建泛型数组的陷阱

JDK 虽然支持泛型,但不允许创建泛型数组。假设 Java 能支持创建 List<String>[10] 这样的泛型数组,则可以产生如下程序:

  1. public class Test {
  2. public static void main(String[] args) {
  3. //下面的代码实际上不被允许的
  4. List<String>[] las = new List<String>[10];
  5. //向上转换为一个 Object 数组
  6. List[] oa = las;
  7. //创建一个List集合
  8. List<Integer> li = new ArrayList<Integer>();
  9. li.add(new Integer(3));
  10. //将List<Integer>对象作为oa的的二个元素
  11. oa[1] = li;
  12. //下面的代码也不会有任何警告,但将引起ClassCastException
  13. String s = las[1].get(0);
  14. }
  15. }

在上面的代码中,如果 List<String>[] las = new List<String>[10]; 代码是合法的,经过中间系列的程序运行,势必会在 String s = las[1].get(0); 代码处引发 ClassCastException 异常。也就违背了 Java 泛型的设计原则:如果一段代码在编译时系统没有产生 [unchecked] 未经检查的的转换警告,则程序在运行时不会引发 ClassCastException 异常。

实际上,编译上面的程序将在 List<String>[] las = new List<String>[10]; 代码处提示:“创建泛型数组”的错误,这正是由于 Java 不支持泛型数组引起的错误。接下来看看如下这个“简单”的程序:

  1. public class GenericArray<T> {
  2. class A {
  3. }
  4. public GenericArray() {
  5. //试图创建内部类A的数组
  6. A[] as = new A[10];
  7. }
  8. }

上面程序看似十分简单,程序只在定义 GenericArray 类时候声明了一个泛型,除此之外没有任何地方使用了这个泛型声明。尝试编译这个程序将看到“创建泛型数组”的错误提示。

看到这个错误,可能会让人感到困扰:粗体字代码只是创建了 A[] 数组,并未创建所谓的泛型数组,为何编译器会提示“创建泛型数组”的错误?这只能说是 JDK 的设计非常谨慎。上面程序虽然没有任何问题,但由于内部类可以直接使用 T 类型形参,所以可以将上面程序改为如下形式:

  1. public class GenericArray<T> {
  2. class A {
  3. T foo;
  4. }
  5. public GenericArray() {
  6. //试图创建内部类A的数组
  7. A[] as = new A[10];
  8. }
  9. }

这样在代码中就会导致创建泛型数组了,这就违背了 Java 不能创建泛型数组的原则。

正则表达式的陷阱

  1. public class StringSplit {
  2. public static void main(String[] args) {
  3. String str = "java.is.funny";
  4. //将这个字符串以点号(.)分割成多个字符
  5. String[] strArr = str.split(".");
  6. for (String s : strArr) {
  7. System.out.println(s);
  8. }
  9. }
  10. }

上面程序非常简单,提供了一个包含多个点号(.)的字符串,接着调用 String 提供的 split() 方法,以点号(.)作为分隔符来分隔这个字符串,希望返回该字符串分隔后得到的字符串数组。运行该程序,结果发现程序什么输出都没有。

从 JDK1.4 开始,Java 的 String 类提供了 split() 方法进行字符串分割。JDK1.0 原来提供的 StringTokenizer 基本上已经属于“历史遗物”了,大部分时候程序没必要使用这个类来进行字符串分隔。

对于上面程序的运行结果,要注意如下两点:

  • String 提供的 split(String regex) 方法需要的参数是正则表达式;
  • 正则表达式中的点号(.)可匹配任意字符。

了解上面这两点规律之后,不难理解运行上面程序后为何没有看到希望的分隔结果:因为正则表达式中的点号(.)可以匹配任意字符,所以上面程序实际上不是以点号(.)作为分隔符,而是以任意字符作为分隔符。为了实现以点号(.)作为分隔符的目的,必须对点号进行转义,将上面的程序改为如下形式即可:

  1. public class StringSplit {
  2. public static void main(String[] args) {
  3. String str = "java.is.funny";
  4. //将这个字符串以点号(.)分割成多个字符
  5. String[] strArr = str.split("\\.");
  6. for (String s : strArr) {
  7. System.out.println(s);
  8. }
  9. }
  10. }

运行上面程序可以看到字符串是以点号(.)分隔的结果,这就是需要的结果。由此可见,这并不是 Java 的 bug,这是对 Java 中某些特性掌握不够精准造成的误解。

从 JDK1.4 开始,Java 加入了对正则表达式的支持,String 类也增加了一些方法用于支持正则表达式,具体有如下方法:

  • matches(String regex):判断该字符串是否匹配指定正则表达式;
  • String replaceAll(String regex, String replacement):将字符串中所有匹配指定正则表达式的子串替换成 replacement 后返回;
  • String replaceFirst(String regex, String replacement):将字符串中第一个匹配指定正则表达式的子串替换成 replacement 后返回;
  • String[] split(String regex):以 regex 正则表达式匹配的子串作为分隔符来分隔字符串。

以上4个方法都需要一个 regex 参数,这个参数就是正则表达式,因此使用这些方法时要特别小心。String 提供了一个与 replaceAll 功能相当的方法,如下所示:

  • replace(CharSequence target, CharSequence replacement):将字符串中所有 target 子串替换成 replacement 后返回。

这个普通的 replace() 方法不支持正则表达式,开发中必须区别对待 replaceAll 和 replace 两个方法。示例如下:

  1. public class StringReplace {
  2. public static void main(String[] args) {
  3. String clazz = "java.is.funny";
  4. //使用replace就比较简单
  5. String path1 = clazz.replace(".", "\\");
  6. System.out.println(path1);
  7. //使用replaceAll复杂多了
  8. String path2 = clazz.replaceAll("\\.", "\\\\");
  9. System.out.println(path2);
  10. }
  11. }

多线程的陷阱

Java 语言提供了非常优秀的多线程支持,使得开发者能以简单的代码来创建、启动多线程,而且 Java 语言内置的多线程支持极好地简化了多线程编程。虽然如此,Java 多线程编程中依然存在一些容易混淆的陷阱。

不要调用 run 方法

从 JDK1.5 开始,Java 提供了 3 种方式来创建、启动多线程:

  • 继承 Thread 类来创建线程类,重写 run() 方法作为线程执行体;
  • 实现 Runnable 接口来创建线程类、重写 run() 方法来作为线程执行体;
  • 实现 Callable 接口来创建线程类,重写 call() 方法作为线程执行体。

其中,第 1 种方式的效果最差,它有 2 点坏处:

  • 线程类继承了 Thread 类,无法再继承其它父类;
  • 因为每条线程都是一个 Thread 子类的实例,因此多个线程之间共享数据比较麻烦。

对于第 2 种和第 3 种方式,它们的本质是一样的,只是 Callable 接口里包含的 call() 方法既可以声明抛出异常,也可以拥有返回值。

除此之外,如果采用继承 Thread 类的方式来创建多线程,程序还有一个潜在的危险。示例如下:

  1. public class InvokeRun extends Thread {
  2. private int i;
  3. //重写run方法,run方法的方法体就是线程执行体
  4. public void run() {
  5. for (;i < 100;i++) {
  6. //直接调用run方法时,Thread的this.getName返回该对象名字
  7. //而不是当前线程的名字
  8. //使用Thread.currentThread().getName()总是获取当前线程的名字
  9. System.out.println(Thread.currentThread().getName() + " " + i);
  10. }
  11. }
  12. public static void main(String[] args) {
  13. for (int i = 0;i < 100;i++) {
  14. //调用Thread的currentThread()方法获取当前线程
  15. System.out.println(Thread.currentThread().getName() + " " + i);
  16. if (i == 20) {
  17. //直接调用线程对象的 run 方法
  18. //系统会把线程对象当成普通对象,把run方法当成普通方法
  19. //所以,下面两行代码并不会启动2条线程,而是依次执行2个run方法
  20. new InvokeRun().run();
  21. new InvokeRun().run();
  22. }
  23. }
  24. }
  25. }

上面程序试图在主线程中 i == 20 时创建并启动 2 条新线程。编译该程序,一切正常;运行该程序,发现该程序只有一个线程————main 线程。程序执行的大致过程如下:

  1. 输出 main 20 之后,又重新开始输出 main 0;
  2. 从 main 0 一直输出到 main 99,再次从 main 0 开始输出;
  3. 再次从 main 0 一直输出到 main 99,再次从 main 22 开始输出,直到 main 99 结束。

上面程序始终只有一条线程,并没有启动任何新线程,关键是因为调用了线程对象的 run() 方法,而不是 start() 方法————启动线程应该使用 star() 方法,而不是 run() 方法。

如果程序从未调用线程对象的 start() 方法来启动它,那么这个线程对象将一直处于“新建”状态,它永远也不会作为线程获得执行的机会,它只是一个普通的 Java 对象。当程序调用线程对象的 run() 方法时,与调用普通 Java 对象的普通方法并无任何区别,因此绝对不会启动一条新线程。

静态的同步方法

Java 提供了 synchronized 关键字用于修饰方法,使用 synchronized 修饰的方法被称为同步方法。当然,synchronized 关键字除了修饰方法之外,还可以修饰普通代码块,使用 synchronzied 修饰的代码块被称为同步代码块。

Java 语法规定,任何线程进入同步方法、同步代码块之前,必须先获取同步方法、同步代码块对应的同步监视器。

对于同步代码块而言,程序必须显式为它指定同步监视器;对于同步非静态方法而言,该方法的同步监视器是 this————即调用该方法的 Java 对象;对于静态的同步方法而言,该方法的同步监视器不是 this,而是该类本身。

下面程序提供了一个静态的同步方法及一个同步代码块。同步代码块使用 this 作为同步监视器,即这两个同步程序单元并没有使用相同的同步监视器,因此它们可以同时并发执行,相互之间不会有任何影响。

  1. public class SynchronizedStatic implements Runnable {
  2. static boolean staticFlag = true;
  3. public static synchronized void test0() {
  4. for (int i = 0;i < 100;i++) {
  5. System.out.println("test0: " + Thread.currentThread().getName() + " " + i);
  6. }
  7. }
  8. public void test1() {
  9. synchronized (this) {
  10. for (int i = 0;i < 100;i++) {
  11. System.out.println("test1: " + Thread.currentThread().getName() + " " + i);
  12. }
  13. }
  14. }
  15. public void run() {
  16. if (staticFlag) {
  17. staticFlag = false;
  18. test0();
  19. } else {
  20. staticFlag = true;
  21. test1();
  22. }
  23. }
  24. public static void main(String[] args) throws Exception {
  25. SynchronizedStatic ss = new SynchronizedStatic();
  26. new Thread(ss).start();
  27. //保证第一条线程开始运行
  28. Thread.sleep(10);
  29. new Thread(ss).start();
  30. }
  31. }

上面程序中定义了一个 Synchronized 类,该类实现了 Runnable 接口,因此可作为线程的 target 来运行。SynchronizedStatic 类通过一个 staticFlag 旗标控制线程使用哪个方法作为线程执行体:

  • 当 staticFlag 为真时,程序使用 test0() 方法作为线程执行体;
  • 当 staticFlag 为假时,程序使用 test1() 方法作为线程执行体。

程序第一次执行 SynchronizedStatic 对象作为 target 的线程时,staticFlag 初始值为 true,因此程序将以 test0() 方法作为线程执行体,而且程序将会把 staticFlag 修改为 false;这使得第二次执行 SynchronizedStatic 对象作为 target 的线程时,程序将以 test1() 方法作为线程执行体。

程序主方法以 SynchronizedStatic 对象作为 target 启动了 2 条线程,一条将以 test0() 方法作为线程执行体,另外一条将以 test1() 方法作为线程执行体。

从上面的程序可以看出,静态同步方法可以和 this 为同步监视器的同步代码块同时执行,当第一条线程(以 test0() 方法作为线程执行体的线程)进入同步代码块执行以后,该线程获得了对同步监视器(SynchronizedStatic 类)的锁定;第二条线程(以 test1() 方法作为线程执行体的线程)尝试进入同步代码块执行,进入代码块之前,该线程必须获得对 this 引用(也就是 ss 变量所引用的对象)的锁定。因为第一条线程锁定的是 SynchronizedStatic 类,而不是 ss 变量所引用的对象,所以第二条线程完全可以获得对 ss 变量所引用的对象的锁定,因此系统可以切换到指定第二条线程。

为了更好地证明静态的同步方法的同步监视器是当前类,可以将上面程序中同步代码块的同步监视器改为 SynchronizedStatic 类。也就是将上面 test1() 方法定义改为如下形式:

  1. public void test1() {
  2. synchronized (SynchronizedStatic.class) {
  3. for (int i = 0;i < 100;i++) {
  4. System.out.println("test1: " + Thread.currentThread().getName() + " " + i);
  5. }
  6. }
  7. }

将 test1() 方法改为上面的形式之后,该同步代码块的同步监视器也是 SynchronizedStatic 类,也就是与同步静态方法 test0() 具有相同的同步监视器。

此时,静态同步方法和以当前类为同步监视器的同步代码块不能同时执行,当第一条线程(以 test0() 方法作为线程执行体的线程)进入同步代码块以后,该线程获得了对同步监视器(SynchronizedStatic)类的锁定;第二条线程(以 test1() 方法作为线程执行体的线程)尝试进入同步代码块执行,进入同步代码块之前,该线程必须获得对 SynchronizedStatic 类的锁定。因为第一条线程已经锁定了 SynchronizedStatic 类,在第一条线程执行结束之前,它将不会释放对 SynchronizedStatic 类的锁定,所以第二条线程无法获得对 SynchronizedStatic 类的锁定,只有等到第一条线程执行结束时才可以切换到执行第二条线程。

静态初始化块启动新线程执行初始化

下面程序代表一种非常极端的情况,主要用于考察线程的 join 方法和类的初始化机制。

  1. public class StaticThreadInit {
  2. static {
  3. //创建匿名内部类
  4. Thread t = new Thread() {
  5. //启动新线程将 website 属性设置为 blog.yeskery.com
  6. public void run() {
  7. System.out.println("进入 run 方法");
  8. System.out.println(website);
  9. website = "blog.yeskery.com";
  10. System.out.println("退出 run 方法");
  11. }
  12. };
  13. t.start();
  14. try {
  15. //加入t线程
  16. t.join();
  17. } catch (Exception e) {
  18. e.printStackTrace();
  19. }
  20. }
  21. //定义一个静态field,设置其初始值为www.yeskery.com
  22. static String website = "www.yeskery.com";
  23. public static void main(String[] args) {
  24. System.out.println(StaticThreadInit.website);
  25. }
  26. }

上面程序定义了一个 StaticThreadInit 类,为该类定义了一个静态的————website,并为其指定初始值 www.yeskery.com;但程序也在静态化块中将 website 赋值为 blog.yeskery.com 且静态初始化块排在前面。如果只是保留这样的程序结构,那程序的结果将非常清晰:静态初始化块先将 website 的初始值初始化为 blog.yeskery.com,然后是初始化机制再将 website 的值赋值为 www.yeskery.com

但上面程序的静态初始化块并不是简单地将 website 赋为 blog.yeskery.com,也是“别出心裁”地启动了一条新线程来执行初始化操作,那么程序会有怎么样的结果?

尝试编译该程序,可以正常编译结束;运行该程序,程序访问 StaticThreadInit.website 时,并没有直接输出 www.yeskery.com,只是简单地打印了“进入 run 方法”之后,即无法继续向下执行。

下面详细分析该程序的执行细节。程序总是从 main 方法开始执行,main 方法只有一行代码,访问 StaticThreadInit 类的 website 静态 field 的值。当某个线程试图访问一个类的静态 field 时,根据该类的状态可能出现如下 4 种情况。

  • 该类尚未被初始化:当前线程开始对其执行初始化;
  • 该类正在被当前线程执行初始化:这是对初始化的递归请求;
  • 该类正在被其它线程执行初始化:当前线程暂停,等待其它线程初始化完成;
  • 这个类已经被初始化:直接得到该静态 field 的值。

main 线程试图访问 StaticThreadInit.website 的值,此时 StaticThreadInit 尚未被初始化,因此 main 线程开始对该类执行初始化。初始化过程主要完成如下两个步骤:

  • 为该类所有静态 field 分配内存;
  • 调用静态初始化块的代码执行初始化。

因此,main 线程首先会为 StaticThreadInit 类的 website field 分配内存空间,此时的 website 的值为 null。接着,main 线程开始执行 StaticThreadInit 类的静态初始化块。该代码块创建并启动了一条新线程,并调用新线程的 join() 方法,这意味着 main 线程必须等待新线程执行结束后才能向下执行。

新线程开始执行之后,首先执行 System.out.println("进入 run 方法"); 代码,这就是运行该程序时看到的第一行输出。接着,程序试图执行 System.out.println(website); 代码,问题出现了:StatucThreadInit 类正由 main 线程执行初始化,因此新线程会等待 main 线程对 StaticThreadInit 类执行初始化结束。

这时候满足了死锁条件:两个线程互相等待对方执行,因此都不能向下执行。因此程序执行到此处就出现了死锁,程序没法向下执行,也就是运行程序时看到的结果。

经过上面分析可以看出,上面程序出现死锁的关键在于程序调用了 t.join(),这导致了 main 线程必须等待新线程执行结束才能向下执行。下面将 t.join() 代码注释掉,也就是将静态初始化代码改为如下形式:

  1. static {
  2. //创建匿名内部类
  3. Thread t = new Thread() {
  4. //启动新线程将 website 属性设置为 blog.yeskery.com
  5. public void run() {
  6. System.out.println("进入 run 方法");
  7. System.out.println(website);
  8. website = "blog.yeskery.com";
  9. System.out.println("退出 run 方法");
  10. }
  11. };
  12. t.start();
  13. }

运行结果:

  1. www.yeskery.com
  2. 进入 run 方法
  3. www.yeskery.com
  4. 退出 run 方法

从上面的结果来看,两次访问 website 的值都是 www.yeskery.com,似乎新线程为 website 指定的初始值没有发生任何作用。

其实不然,main 线程进入 SynchronizedStatic 的静态初始化块之后,同样也是创建并启动了新线程。由于此时并未调用新线程的 join() 方法,因此主线程不会等待新线程,也就说,此时新线程只是处于就绪状态,还没进入运行状态。main 线程继续执行初始化操作,它会将 website 的值初始化为 www.yeskery.com,至此 SynchronizedStatic 类初始化完成。System.out.println(StaticThreadInit.website); 代码也可以执行完成了,程序输出 www.yeskery.com

接下来新线程才进入运行状态,一次执行 run 方法里的每行代码,此时访问到的 website 的值依然是 www.yeskery.com,run() 方法最后将 website 的值改为 blog.yeskery.com,但程序已经不再访问它了。

很明显,产生上面运行结果的原因是调用一条线程 start() 方法后,该线程并不会立即进入运行状态,它将只是保持在就绪状态。

为了改变这种状态,再次改变 StaticThreadInit 类的静态初始化款代码,如下所示:

  1. static {
  2. //创建匿名内部类
  3. Thread t = new Thread() {
  4. //启动新线程将 website 属性设置为 blog.yeskery.com
  5. public void run() {
  6. System.out.println("进入 run 方法");
  7. System.out.println(website);
  8. website = "blog.yeskery.com";
  9. System.out.println("退出 run 方法");
  10. }
  11. };
  12. t.start();
  13. try {
  14. Thread.sleep(1);
  15. } catch (Exception e) {
  16. e.printStackTrace();
  17. }
  18. }

上面程序调用新线程的 start() 方法启动新线程后,立即调用 Thread.sleep(1) 暂停当前线程,使得新线程立即获得执行的机会。

运行结果:

  1. 进入 run 方法
  2. www.yeskery.com
  3. www.yeskery.com
  4. 退出 run 方法

从上面的结果可以看出,即使让新线程立即启动,新线程为 website 指定的值依然没有起作用,这又是为什么呢?

这依然和类初始化机制有关。当 main 线程进入 SynchronizedStatic 类的静态初始化块后,main 线程创建、启动一条新线程,然后主线程调用 Thread.sleep(1); 暂停自己,是的新线程获得执行机会,于是看到运行结果的第一行输出的是“进入 run 方法”。然后,新线程试图执行 System.out.println(website); 来输出 website 的值,但由于 SynchronizedStatic 类还未初始化完成,因此新线程不得不放弃执行。线程调度器再次切换到 main 线程,main 线程于是将 website 初始化为 www.yeskery.com,至此SynchronizedStatic 类初始化完成。

通常 main 线程不会立即切换回来执行新线程,它会执行 main 方法里的第一行代码,也就是输出 website 的值,于是看到输出第一行 www.yeskery.com

main 线程执行完后,系统切换回来执行新线程,新线程访问到 website 时也会输出 www.yeskery.com,于是看到输出第二行 www.yeskery.com。run() 方法最后将 website 的值改为 blog.yeskery.com,但程序已经不再访问它了。

这里实际上有一个问题:静态初始化块里启动多线程对静态 field 所赋的值根本不是初始值,它只是一次普通的赋值。示例如下:

  1. public class StaticThreadInit2 {
  2. static {
  3. //创建匿名内部类来启动新线程
  4. Thread t = new Thread() {
  5. //启动新线程,将website属性设置为blog.yeskery.com
  6. public void run() {
  7. website = "blog.yeskery.com";
  8. }
  9. };
  10. }
  11. //定义一个静态field
  12. final static String website;
  13. public static void main(String[] args) {
  14. System.out.println(StaticThreadInit2.website);
  15. }
  16. }

以上程序定义了一个 final 静态 field————website,没有为它指定初始值,接着试图在静态初始化块中为 website 指定初始值。正常情况下,这个程序没有任何问题,不过当静态初始化块启动了一条新线程为 website 注定初始值,就有问题了。尝试编译上面的程序,会提示“无法为最终变量 website 指定值”。

从上面的错误可以看出,静态初始化块启动的新线程根本不允许为 website 赋值,这表名,新线程为 website 的赋值根本不是初始化操作,只是一次普通的赋值。

这个程序给出的教训是:分析一个程序不能仅仅停留在静态的代码上,而是应该从程序执行过程来把握程序的运行细节。

不要认为所有放在静态初始化块中的代码就一定具有类初始化操作,静态初始化块中启动的新线程的 run() 方法只是新线程的线程执行体,并不是类初始化操作。类似地,不要认为所有放在非静态初始化块中的代码就一定是对象初始化操作,费静态初始化块中启动的新线程的 run() 方法代码只是新线程的线程执行体,并不是对象初始化操作。

注意多线程执行环境

在不考虑多线程环境的情况下,很多代码都是完全正确的。但一旦将它们放在多线程环境下,这个类就变得非常脆弱而易错,这种类都被称为线程不安全,多线程环境下应该使用线程安全的类。

下面程序定义了一个 Account 类。该类代表一个银行帐户,程序可通过该银行帐户进行取钱。

  1. public class Account {
  2. private String accountNo;
  3. private double balance;
  4. public Account(){}
  5. public Account(String accountNo, double balance) {
  6. this.accountNo = accountNo;
  7. this.balance = balance;
  8. }
  9. //访问该帐户的余额
  10. public double getBalance() {
  11. return this.balance;
  12. }
  13. public void draw(double drawAmount) {
  14. //账户余额大于取钱数目
  15. if (balance >= drawAmount) {
  16. //吐出钞票
  17. System.out.println(Thread.currentThread().getName() + "取钱成功!吐出钞票:" + drawAmount);
  18. //修改余额
  19. balance -= drawAmount;
  20. System.out.println("\t余额为:" + balance);
  21. } else {
  22. System.out.println(Thread.currentThread().getName() + "取钱失败!余额不足!");
  23. }
  24. }
  25. //重写hashCode()方法
  26. public int hashCode() {
  27. return accountNo.hashCode();
  28. }
  29. //重写equals()方法
  30. public boolean equals(Object obj) {
  31. if (obj == this) {
  32. return true;
  33. }
  34. if (obj != null && obj.getClass() == Account.class) {
  35. Account target = (Account)obj;
  36. return target.accountNo.equals(accountNo);
  37. }
  38. return false;
  39. }
  40. }

上面程序中定义了 Account 类,实现了一个 draw 方法用于取钱。这个取钱逻辑看上去没有任何问题:系统先判断帐户余额是否大于取款金额,当帐户余额大于取款金额时,取钱成功;否则,系统提示余额不足。这个逻辑完全复合取钱的要求,但由于它只是一个线程不安全的类,因此这个 Account 类不能用于多线程环境。下面程序启用了 2 个线程模拟并发取钱。

  1. class DrawThread extends Thread {
  2. //模拟用户帐户
  3. private Account account;
  4. //当前取钱线程所希望取的钱数
  5. private double drawAmount;
  6. public DrawThread(String name, Account account, double drawAmount) {
  7. super(name);
  8. this.account = account;
  9. this.drawAmount = drawAmount;
  10. }
  11. //当多条线程修改同一个共享数据时,将设计到数据安全问题
  12. public void run() {
  13. account.draw(drawAmount);
  14. }
  15. }
  16. public class DrawTest {
  17. public static void main(String[] args) {
  18. //创建一个帐户
  19. Account acct = new Account("1234567", 1000);
  20. //模拟两个线程对同一个帐户取钱
  21. new DrawThread("甲", acct, 800).start();
  22. new DrawThread("乙", acct, 800).start();
  23. }
  24. }

大部分时候,可以看到以下输出:

  1. 甲取钱成功!吐出钞票:800.0
  2. 余额为:200.0
  3. 乙取钱失败!余额不足!

偶尔可以看到两条线程同时取钱的结果————这是因为多线程调度具有不确定性的结果。

为了让读者更好地看到多线程环境下该程序的危险,修改 Account 的 draw 方法,如下:

  1. public void draw(double drawAmount) {
  2. //账户余额大于取钱数目
  3. if (balance >= drawAmount) {
  4. //暂停当前线程,切换为执行另一条线程
  5. try {
  6. Thread.sleep(1);
  7. } catch (InterruptedException e) {
  8. e.printStackTrace();
  9. }
  10. //吐出钞票
  11. System.out.println(Thread.currentThread().getName() + "取钱成功!吐出钞票:" + drawAmount);
  12. //修改余额
  13. balance -= drawAmount;
  14. System.out.println("\t余额为:" + balance);
  15. } else {
  16. System.out.println(Thread.currentThread().getName() + "取钱失败!余额不足!");
  17. }
  18. }

修改之后,再次运行,可以看到如下结果:

  1. 甲取钱成功!吐出钞票:800.0
  2. 余额为:200.0
  3. 乙取钱成功!吐出钞票:800.0
  4. 余额为:-600.0

从上面的结果可以看出,这个取款程序已经出现了问题,该帐户的余额只有 1000 块,但两条线程各取走了 800 块,这就是由 Account 的线程不安全导致的。虽然上面程序中显式使用 Thread.sleep(1) 来导致线程切换,但实际运行过程中即使没有这行代码,线程也有可能在此处切换,就会出现上面的结果了。

为了让 Account 能更好地使用多线程环境,可以将 Account 类修改为线程安全的形式。线程安全的类具有如下特征:

  • 该类的对象可以被多个线程安全的访问;
  • 每个线程调用该对象的任意方法之后,都将得到正确结果;
  • 每个线程调用带对象的任意方法之后,该对象状态依然保持合理状态。

前面介绍的 Vector、StringBuffer 都是线程安全的类。通过查看这些类的源代码可以发现线程安全的类大量方法都使用了 synchronized 关键字修饰,也就是说,通过同步方法可以得到线程安全类。

对于上面程序来说,Account 类的 balance 实例变量是“竞争资源”,多条线程可能并发访问它,因此应该将访问该“竞争资源”的方法变成同步方法。下面是 Account 类的线程安全版本。

  1. public class AccountSyn {
  2. private String accountNo;
  3. private double balance;
  4. public AccountSyn(){}
  5. public AccountSyn(String accountNo, double balance) {
  6. this.accountNo = accountNo;
  7. this.balance = balance;
  8. }
  9. //访问该帐户的余额,使用synchronized修饰符将它变成同步方法
  10. public synchronized double getBalance() {
  11. return this.balance;
  12. }
  13. //使用synchronized修饰符将它变成同步方法
  14. public synchronized void draw(double drawAmount) {
  15. //账户余额大于取钱数目
  16. if (balance >= drawAmount) {
  17. //暂停当前线程,切换为执行另一条线程
  18. try {
  19. Thread.sleep(1);
  20. } catch (InterruptedException e) {
  21. e.printStackTrace();
  22. }
  23. //吐出钞票
  24. System.out.println(Thread.currentThread().getName() + "取钱成功!吐出钞票:" + drawAmount);
  25. //修改余额
  26. balance -= drawAmount;
  27. System.out.println("\t余额为:" + balance);
  28. } else {
  29. System.out.println(Thread.currentThread().getName() + "取钱失败!余额不足!");
  30. }
  31. }
  32. //重写hashCode()方法
  33. public int hashCode() {
  34. return accountNo.hashCode();
  35. }
  36. //重写equals()方法
  37. public boolean equals(Object obj) {
  38. if (obj == this) {
  39. return true;
  40. }
  41. if (obj != null && obj.getClass() == AccountSyn.class) {
  42. AccountSyn target = (AccountSyn)obj;
  43. return target.accountNo.equals(accountNo);
  44. }
  45. return false;
  46. }
  47. }

Account 类中 getBalance() 方法和 draw(double drawAmount) 方法可以访问共享资源 blance,因此程序使用了 synchronized 关键字修饰这两个方法,这使得该类变成一个线程安全类。

本文转载自:《疯狂Java 突破程序员基本功的16课》第五章 Java 表达式中的陷阱

评论

发表评论 点击刷新验证码

提示

该功能暂未开放