《java解惑》读书笔记5——异常谜题

1.finally语句块中的return:

问题:

下面的小程序运行结果是什么:

public class Test {
    public static void main(String[] args) {
        System.out.println(decision());
    }
    
    static boolean decision(){
        try{
            return true;
        }finally{
            return false;
        }
    }
}
有人可能觉得应该打印出true,因为在try中首先已经return了;也有人觉得程序应该编译不过,因为不应该有两个返回值。

程序的真实运行结果是false。


原因:

在一个try-finally语句块中,finally语句块总是在控制权离开try语句块时执行的,无论try语句块是正常结束还是意外结束,finally语句块总是要执行的。

程序中抛出一个异常;循环中执行了break或continue;或者程序中执行了一个return时,程序都将发生意外结束。

当try语句块和finally语句块都意外结束时,try语句块中引发意外结束的原因将被丢弃,而整个try-finally语句意外结束的原因将采用finally语句块意外结束的原因。


结论:

如果在try和finally中都意外结束时,try语句块的意外结束原因将被丢弃,因此除非抛出UncheckedException,否则finally语句块都应该正常结束,千万不要用return、break、continue或者throw来退出一个finally语句块,并且千万不要允许将一个CheckedException传播到一个finally语句块之外去。

2.异常抛出与捕获基本原则:

问题和原因:

下面3个小程序运行结果分别是什么:

程序1:

public class Test1 {
    public static void main(String[] args) {
        try{
            System.out.println("Hello world!");
        }catch(IOException e){
            System.out.println("I've never seen println fail!");
        }
    }
}

这个程序乍一看没什么问题,实际上根本不能通过编译。

该程序演示了CheckedException的一个基本原则:如果在catch语句块中捕获一个CheckedException,则try语句块必须要抛出相应的CheckException才行,上述代码中IOException是一个CheckedException,但是try语句中没有抛出任何IOException,因此无法通过编译。


程序2:

public class Test2 {
    public static void main(String[] args) {
        try{
            
        }catch(Exception e){
            System.out.println("This can't happen!");
        }
    }
}

有了第一个小程序的经验之后,很多人认为改程序不会通过编译,但是它却是可以通过编译的,只是没有任何输出结果。

该程序的catch语句块捕获一个Exception,java中异常的基类是Throwable,其对应两个子类:Error和Exception,Exception又分为CheckedException和UncheckedException。java语言规范规定,不管与其对应的try子句的内容为何,catch语句捕获Exception/Throwable是合法的,因此该程序可以通过编译,只是catch语句永远不会执行。


程序3:

interface Type1{
    void f() throws CloneNotSupportedException;
}

interface Type2{
    void f() throws InterruptedException;
}

interface Type3 extends Type1, Type2{}

public class Test3 implements Type3{
    public void f(){
        System.out.println("Hello world!");
    }
    
    public static void main(String[] args) {
        Type3 t3 = new Test3();
        t3.f();
    }
}
有了前两个小程序做基础,第3个程序更加复杂,初看起来也觉得不能通过编译,因为方法f在Type1接口中声明要抛出CheckedException——CloneNotSupportedException,并且在Type2接口中声明要抛出CheckedException——InterruptedException,Type3接口继承了Type1和Type2,因此看起来在Type3类型对象上调用方法f时,有潜在可能会抛出Type1和Type2异常的并集。一个方法必须要捕获其方法体可以抛出的所有CheckedException,因此在Type3中方法f应该声明抛出Type1和Type2方法f的所有CheckedException才能正常编译,但是Type3方法f没有声明抛出任何异常却仍然可以通过编译。

java语言规范规定:一个方法可以抛出的CheckedException是它所适用的所有CheckedException的交集,而并非并集,因此Type3方法f根本不抛出任何异常,因此可以正常编译,该程序可以正常打印输出Hello world!

结论:

对于catch语句捕获CheckedException时,try语句必须抛出相应的CheckedException,多继承而来的方法抛出异常的交集。


3.常量赋值抛出异常:

问题:

下面的程序运行结果应该是什么:

public class Test{
    
    private static final long TEST_USER_ID = -1;
    private static final long USER_ID;
    static{
        try{
            USER_ID = getUserId();
        }catch(IdUnavailableException e){
            USER_ID = TEST_USER_ID;
            System.out.println("Logging in as guest");
        }
    }
    
    private static long getUserId() throws IdUnavailableException{
        throw new IdUnavailableException();
    }
    
    public static void main(String[] args) {
        System.out.println("User ID:" + USER_ID);
    }
    
}
class IdUnavailableException extends Exception{}

该程序很多人认为应该打印输出User Id:-1,因为在调用getUserId时候产生异常,因此常量USER_ID在catch语句块中被赋值。

真实情况是:上述程序根本不能通过编译,catch语句块中USER_ID赋值会报编译错误:The final field USER_ID may already have been assigned。


原因:

常量USER_ID是一个空final,它是一个在声明时没有进行初始化操作的final域,上述示例代码只有在对USER_ID赋值失败时,才会在try语句块中抛出异常,因此在catch语句块中赋值是安全的,因为只会对USER_ID赋值一次。
计算机判断程序是否会对一个空final域进行超过一次赋值是一件很困难的事情,事实上这是不可能,这等价于经典的停机问题,通常被认为是不可能解决的,为了能够编写出一个编译器,语言规范在空final域赋值问题上采用了保守方式——一个空final域只有在它是明确未赋过值的地方才可以被赋值。

上述程序中虽然只会对空final域USER_ID赋值一次,但是编译器为了保证保守的final赋值方式会拒绝编译通过。


结论:

解决上述问题的最好方式是将烦人的空final域改变为普通的final类型,代码如下:

public class Test{
    
    private static final long TEST_USER_ID = -1;
    private static final long USER_ID = getUserIdFromEnv();
    private static long getUserIdFromEnv(){
        try{
            return getUserId();
        }catch(IdUnavailableException e){
            System.out.println("Logging in as guest");
            return TEST_USER_ID;
        }
    }
    
    private static long getUserId() throws IdUnavailableException{
        throw new IdUnavailableException();
    }
    
    public static void main(String[] args) {
        System.out.println("User ID:" + USER_ID);
    }
    
}
class IdUnavailableException extends Exception{}


4.使用关闭钩子完成退出java虚拟机前工作:

问题:

下面的程序打印输出结果是什么:

public class Test{
    
    public static void main(String[] args) {
        try{
            System.out.println("Hello world!");
            System.exit(0);
        }finally{
            System.out.println("Goodbye!");
        }
    }
}
很多人认为应该把Hello world!和Goodbye!都打印输出,因为无论try语句块正常或者异常结束,控制权都会交回finally语句块,因此finally语句块总有机会执行,但是程序的真实运行结果是只会打印输出Hello world!,永远不会打印输出Goodbye!.


原因:

System.exit方法在退出java虚拟机前将停止当前线程和所有其他当场死亡的线程,因此finally子句没有存活的线程去执行,因此无法打印输出Goodbye!.

当System.exit被调用时,虚拟机在关闭前要执行两项清理工作:

(1).执行所有的关闭钩子操作,代码如下:

public class Test{
    
    public static void main(String[] args) {
        System.out.println("Hello world!");
        Runtime.getRuntime().addShutdownHook(new Thread(){
            public void run() {
                System.out.println("Goodbye!");
            }
            
        });
        System.exit(0);
    }
}
通过Runtime.getRuntime().addShutdownHook方法向java虚拟机注册关闭钩子,在java虚拟机退出时就会执行钩子线程的操作,通常用来释放java虚拟机的外部资源等等。

(2).任务终结器:

当java虚拟机退出时执行了System.runFinalizerOnExit()或Runtime.runFinalizersOnExit()方法,虚拟机将在所有还为清理对象上调用终结器,但是这两个方法已经过时并且不推荐使用,因为他们会在那些被其他线程正在并发操作的对象上运行,从而导致不确定的行为或导致死锁。


结论:

在调用System,exit方法关闭java虚拟机时,不会执行finally语句块,因此有如下两种选择:

(1).使用关闭钩子在关闭虚拟机的时候释放外部资源。

(2).使用System.halt可以在不执行关闭钩子的情况下停止java虚拟机。


5.构造函数中抛出异常:

问题:

下面的程序打印输出什么:

public class Test{
    private Test test1 = new Test();
    public Test() throws Exception{
        throw new Exception("I'm not coming out");
    }
    public static void main(String[] args) {
        try{
            Test test2 = new Test();
            System.out.println("Surprise!");
        }catch(Exception e){
            System.out.println("I told you so!");
        }
    }
}

很多人认为应该打印输出“I told you so!”,因为在main函数中创建test2实例对象时抛出异常被捕获。

程序真实运行时没有任何输出,而是报错:Exception in thread "main" java.lang.StackOverflowError


原因:

和绝大多数抛出栈溢出错误的程序一样,上述程序中包含了一个无限递归:当调用构造方法时,实例变量的初始化操作将先于构造器的程序体而运行,因此test1实例变量的初始化操作递归调用了构造方法,而构造方法通过再次调用Test构造方法而初始化test1变量的域,如此无限递归下去,这些递归调用在构造程序体获得执行机会之前就会抛出StackOverflowError,因为StackOverflowError是Error的子类而不是Exception的子类型,所以catch子句无法捕获它。


结论:

如果想要让上述程序打印输出“I told you so!”,修改很简单,只需把Exception修改为Throwable即可。

实例初始化操作是先于构造方法的程序体而运行的,实例初始化操作抛出的任何异常都会传播给构造方法,因此对于声明将抛出异常的构造方法,构造方法必须声明其实例化操作会抛出的所以CheckedException,但是应该避免这样做,因为很有可能会造成无限递归。


6.资源关闭异常:

问题:

下面的小程序展示对于I/O流的关闭操作:

public static void copy(String src, String dest) throws IOException{
        InputStream in = null;
        OutputStream out = null;
        try{
            in = new FileInputStream(src);
            out = new FileOutputStream(dest);
            byte[] buf = new byte[1024];
            int n;
            while((n = in.read(buf)) != -1){
                out.write(buf);
            }
        }finally{
            if(in != null){
                in.close();
            }
            if(out != null){
                out.close();
            }
        }
    }
相信很多人都这样写程序,看起来似乎没有什么问题,文件流都关闭了,释放了系统资源,但是上述程序有文件流不能关闭的风险,因此在流的close方法中也可能会抛出IOException,上述程序并没有对其做任何处理,如果恰好在流close的时候产生了I/O异常,就会导致流关闭失败,从而引起系统资源释放失败。


原因:

对close方法的调用可能会导致finally语句块意外结束,但是上述程序中编译器并不能发现该潜在问题,因为close方法抛出的I异常同read和write方法抛出的异常相同,都是IOException,而外围的copy方法声明抛出传播该异常,因此编译器无法发现此类潜在的问题。


结论:

解决上述问题有3中方法:

方法1:

显式在finally语句块中使用try-catch语句块包裹close方法,代码如下:

public static void copy(String src, String dest) throws IOException{
        InputStream in = null;
        OutputStream out = null;
        try{
            in = new FileInputStream(src);
            out = new FileOutputStream(dest);
            byte[] buf = new byte[1024];
            int n;
            while((n = in.read(buf)) != -1){
                out.write(buf);
            }
        }finally{
            if(in != null){
                try{
                    in.close();
                }catch(IOException e){
                    //There is nothing we can do if close fail.
                }
            }
            if(out != null){
                try{
                    out.close();
                }catch(IOException e){
                  //There is nothing we can do if close fail.
                }
            }
        }
    }
方法2:

在JDK1.5之后,可以重构代码使用Closeable接口,代码如下:

public static void copy(String src, String dest) throws IOException{
        InputStream in = null;
        OutputStream out = null;
        try{
            in = new FileInputStream(src);
            out = new FileOutputStream(dest);
            byte[] buf = new byte[1024];
            int n;
            while((n = in.read(buf)) != -1){
                out.write(buf);
            }
        }finally{
            closeIgnoringException(in);
            closeIgnoringException(out);
        }
    }
    
    private static void closeIgnoringException(Closeable c){
        if(c != null){
            try{
                c.close();
            }catch(IOException e){
              //There is nothing we can do if close fail.
            }
        }
    }
方法3:

使用JDK1.7中的try-with-resources语句块对资源进行自动化管理,代码如下:

public static void copy(String src, String dest) throws IOException{
        try(InputStream in = new FileInputStream(src);
                OutputStream out = new FileOutputStream(dest)){
            byte[] buf = new byte[1024];
            int n;
            while((n = in.read(buf)) != -1){
                out.write(buf);
            }
        }
    }
通过try-with-resources语句块,java会自动对实现了java.lang.AutoCloseable接口的资源进行自动管理,不需要在finally语句块中进行手动释放。

在JDK1.7中,与I/O操作相关的java.io.Closeable接口已经继承了java.lang.AutoCloseable接口,与数据库相关的java.sql.Connection,java.sql.ResultSet和java.sql.Statement也都已经继承了java.lang.AutoCloseable接口,因此在JDK1.7中对于I/O和数据库相关的资源就可以使用try-with-resources语句块对资源进行自动化管理了。


7.非短路逻辑运算引起的异常:

问题:

下面小程序用于打印输出第三个元素是3的数组个数,代码如下:

public class Test{
    
    public static void main(String[] args) {
        int[][] tests = {{6, 5, 4, 3, 2, 1}, {1, 2},
                {1, 2, 3}, {1, 2, 3, 4}, {1}};
        int successCount = 0;
        for(int[] test : tests){
            if(thirdElementIsThree(test)){
                successCount++;
            }
        }
        System.out.println(successCount);
    }

    private static boolean thirdElementIsThree(int[] test) {
        return test.length >= 3 & test[2] == 3;
    }
}
原本期望上述程序打印输出3,但是程序真实运行报如下数组越界异常:

Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: 2
at com.javapuzzlers.Test.thirdElementIsThree(Test.java:19)
at com.javapuzzlers.Test.main(Test.java:11)


原因:

正如谜题题目所示,上述程序出现数组越界异常的原因是错误使用了非短路的逻辑与运算'&',在java中逻辑与和或运算如下:

&:无论左边boolean值是什么,都会执行右边的boolean判定。

&&:短路逻辑与运算,如果左边boolean值是false,则不再对右边的boolean值进行判定,整个表达式直接返回false。

|:无论左边boolean值是什么,都会执行右边的boolean判定。

||:短路逻辑或运算,如果左边boolean值是true,则不再对右边的boolean值进行判定,整个表达式直接返回true。

上述代码中,无论数组元素是否有三个,总要判断数组的第三个元素是否等于3,从而导致没有3个元素的数组发生索引越界异常。


结论:

解决上述程序的数组越界异常很简单,只需要将非短路的逻辑与运算符‘&’替换为短路逻辑与运算符'&&'即可。

在使用逻辑运算符的时候一定要千万小心,如果使用不当很有可能引起意想不到的异常。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值