访问量
访客数

Java异常

2021.01.13 阅读量

异常类型

Java异常分类

Throwable可以用来表示任何可以作为异常抛出的类,分为两种:ErrorException。 其中Error用来表示Java程序无法处理的错误,这类错误一般与硬件有关,与程序本身无关,通常由系统进行处理,程序本身无法捕获和处理,是不可控制的。

Exception分为两种:运行时异常和检查型异常。

  • 受检异常:需要用try...catch... 语句捕获并进行处理,并且可以从异常中恢复;
    public void test() throws MyException{}
    
    Java编译器对检查性异常会要求我们进行catch,必须得进行捕获,否则编译不过去。Java认为检查性异常都可以被处理,所以必须显示的处理checked异常。 常见的检查性异常有IOExceptionSqlException。当我们希望我们的⽅法调⽤者,明确的处理⼀些特殊情况的时候,就应该使⽤受检异常。
  • 非受检异常:是程序运行时错误。例如:除0会引发 ArithmeticException,此时程序崩溃并且无法恢复。
    public void test() {
        int a = 1;
        int b = a/0;
    }
    
    这种异常⼀般可以理解为是代码原因导致的。⽐如发⽣空指针、数组越界等。所以,只要代码写的没问题,这些异常都是可以避免的。也就不需要我们显⽰的进⾏处理。

Exception表⽰程序需要捕捉、需要处理的常,是由与程序设计的不完善⽽出现的问题,程序必须处理的问题。 异常和错误的区别是,异常能被程序本身可以处理,错误是无法处理。

自定义异常

在Java中,自定义异常类可以帮助你创建特定于你的应用程序的异常类型,这些异常类型可以提供更清晰的错误描述,并使错误处理更加灵活和精确。 在Java中自定义异常,但所有自定义异常都必须是Throwable的子类。 如果希望写一个检查性异常类,则需要继承Exception类。如果你想写一个运行时异常类,那么需要继承RuntimeException类。

自定义受检查异常:

// 创建一个自定义的受检查异常类
public class MyCheckedException extends Exception {
    public MyCheckedException(String message) {
        super(message);
    }
    
    public MyCheckedException(String message, Throwable cause) {
        super(message, cause);
    }
}

// 使用自定义异常
public class Test {
    public void myMethod() throws MyCheckedException {
        // 条件判断或业务逻辑
        if (true) {
            throw new MyCheckedException("This is a custom checked exception.");
        }
    }
}

自定义运行时异常:

// 创建一个自定义的非受检查异常类
public class MyUncheckedException extends RuntimeException {
    public MyUncheckedException(String message) {
        super(message);
    }
    
    public MyUncheckedException(String message, Throwable cause) {
        super(message, cause);
    }
}

// 使用自定义异常
public class Test {
    public void myMethod() {
        // 条件判断或业务逻辑
        if (true) {
            throw new MyUncheckedException("This is a custom unchecked exception.");
        }
    }
}

异常的传播

在Java中,异常传播指的是当一个方法内部抛出异常时,异常如何在调用栈中向上传播的过程。 理解异常传播是异常处理机制中的重要部分,对于编写健壮的代码和良好的异常处理策略至关重要。

当一个方法内部抛出异常时,异常会沿着调用链向上传播给调用者。这个过程描述了异常如何从方法内部向外部传递,并最终在调用栈中找到合适的异常处理器来处理异常。

public class ExceptionPropagationExample {

    public static void main(String[] args) {
        try {
            method1();
        } catch (Exception e) {
            System.out.println("Exception caught in main method: " + e.getMessage());
        }
    }

    public static void method1() throws Exception {
        try {
            method2();
        } catch (RuntimeException e) {
            System.out.println("RuntimeException caught in method1: " + e.getMessage());
        }
    }

    public static void method2() {
        method3();
    }

    public static void method3() {
        // 人为地抛出一个异常
        throw new RuntimeException("Exception occurred in method3");
    }
}

异常传播的基本过程:

  1. 当方法内部抛出一个异常(使用throw关键字),异常对象被创建并包含了当前异常的堆栈信息,包括方法调用路径。
  2. 异常从抛出它的方法开始,沿着方法调用链向上传播。每当方法内部抛出异常时,JVM将控制权传递给调用该方法的上级方法。
  3. 异常会继续沿着调用链向上移动,直到找到合适的异常处理器来处理异常。处理器可以是try-catch块、异常处理方法或者不处理异常而继续向上传播。
  4. 如果找到了与抛出的异常匹配的catch块或者方法签名中声明的throws子句,异常被捕获并在这里进行处理。处理可以包括记录日志、恢复操作或者向上抛出另一个异常。
  5. 如果异常没有在当前方法中被捕获处理,它将继续向上传播,控制权交给调用当前方法的方法。这个过程继续直到异常被捕获或者传播到了调用栈的顶部。
  6. 如果异常传播到了调用栈的顶部仍然没有被处理,通常情况下会导致程序终止,并打印异常的堆栈跟踪信息,这有助于开发人员定位和解决问题。

处理异常

异常的处理⽅式有两种,⾃⼰处理,向上抛出交给调⽤者处理。

需要注意的是,一般情况下不要丢弃异常,捕获异常后需要进行相关处理。 如果用户觉得不能很好地处理该异常,就让它继续传播,传到别的地方去处理,或者把一个低级的异常转换成应用级的异常,重新抛出。 千万不能捕获了之后什么也不做,或者只是使⽤e.printStacktrace。如果是练习这样写也就算了,但是在正式的环境上千万不能这样做。实际开发中应该使用日志记录。

写完代码后请一定要检查下,代码中千万不要有printStackTrace。 不能直接使用printStackTrace打印异常的原因在于它可能泄露敏感信息、未将异常信息记录到日志系统中、使得错误难以追踪和管理、影响系统性能,并且通常缺乏必要的上下文信息。 因此,在正式开发中,应使用日志框架记录异常信息,以便更好地监控和调试应用程序。

public class ExceptionHandlingExample {
    private static final Logger logger = LoggerFactory.getLogger(ExceptionHandlingExample.class);

    public static void main(String[] args) {
        try {
            int result = divide(10, 0);
            System.out.println("Result: " + result);
        } catch (ArithmeticException e) {
            // 使用日志记录异常信息
            logger.error("Division by zero error", e);
            // 或者根据业务逻辑重新抛出应用级异常
            throw new CustomApplicationException("Failed to divide numbers", e);
        }
    }

    public static int divide(int a, int b) {
        return a / b;
    }
}

// 自定义应用级异常
public class CustomApplicationException extends RuntimeException {
    public CustomApplicationException(String message, Throwable cause) {
        super(message, cause);
    }
}

try-catch

自己处理异常指在当前方法内部通过try-catch语句捕获并处理异常,而不是将异常传播给调用者处理。 这种方式适合于可以预见并且能够恢复的异常情况。处理异常的目的是在发生异常时执行某些逻辑,例如提供默认值、记录日志、给用户友好的错误提示等。

public class ExceptionHandlingExample {
    public static void main(String[] args) {
        try {
            int result = divide(10, 0);
            System.out.println("Result: " + result);
        } catch (ArithmeticException e) {
            System.out.println("Error: Division by zero is not allowed. Defaulting result to 0.");
            // 给出一个默认值
            int result = 0;
            System.out.println("Result: " + result);
        }
    }

    public static int divide(int a, int b) {
        return a / b;
    }
}

在Java中,一个try语句可以对应多个catch块来进行多重捕获。 如果在try语句块中发生异常,异常会被依次传递给每个catch块,直到找到匹配的catch块进行处理。如果没有匹配的catch块,异常会继续向上抛出。

public class MultiCatchExample {
    public static void main(String[] args) {
        try {
            int[] numbers = {1, 2, 3};
            System.out.println(numbers[5]); // ArrayIndexOutOfBoundsException
            int result = 10 / 0; // ArithmeticException
        } catch (ArrayIndexOutOfBoundsException e) {
            System.out.println("Array index is out of bounds: " + e.getMessage());
        } catch (ArithmeticException e) {
            System.out.println("Arithmetic error occurred: " + e.getMessage());
        } catch (Exception e) {
            System.out.println("An unexpected error occurred: " + e.getMessage());
        }
    }
}

从JDK7开始,Java引入了多个异常类型的合并处理,这使得可以将多个不同类型的异常在一个catch块中捕获和处理。 这种特性可以减少重复的代码,并提高异常处理的简洁性和可读性。

public class MultiCatchExample {
    public static void main(String[] args) {
        try {
            int[] numbers = {1, 2, 3};
            System.out.println(numbers[5]); // ArrayIndexOutOfBoundsException
            int result = 10 / 0; // ArithmeticException
        } catch (ArrayIndexOutOfBoundsException | ArithmeticException e) {
            System.out.println("An error occurred: " + e.getMessage());
        } catch (Exception e) {
            System.out.println("An unexpected error occurred: " + e.getMessage());
        }
    }
}

try-catch-finally

try 语句块不止可以与 catch 连用,也可以与finally连用,但是 catch 不能与 finally 连用。 try-catch-finally结构与异常密切相关,它是Java中用于处理异常的基本结构之一,用于确保资源的正确关闭和异常的正确处理。

public class TryCatchFinallyExample {
    public static void main(String[] args) {
        BufferedReader reader = null;
        try {
            reader = new BufferedReader(new FileReader("file.txt"));
            String line;
            while ((line = reader.readLine()) != null) {
                System.out.println(line);
            }
        } catch (IOException e) {
            System.out.println("Error reading file: " + e.getMessage());
        } finally {
            try {
                if (reader != null) {
                    reader.close(); // 确保关闭资源
                }
            } catch (IOException e) {
                System.out.println("Error closing reader: " + e.getMessage());
            }
        }
    }
}

根据JVM规范,如果try语句块里边有返回值则返回try语句块里边的; 如果try语句块和finally语句块都有return,则忽略try语句块里边的使用finally语句块里边的returnfinally语句块是在try语句块或者catch语句块中的return语句之前执行的,无论是否发生异常,finally代码块中的代码总会被执行。 一般情况下如果方法有返回值,切忌不要在finally中写return,这样会使得程序结构变得混乱。

finally语句块什么时候不执行? 如果当一个线程在执行try语句块或者catch语句块时被打断(interrupted)或者被终止(killed)或退出虚拟机(System.exit(0)),与其相对应的finally语句块可能不会执行。 还有更极端的情况,就是在线程运行try语句块或者catch语句块时,突然死机或者断电,finally语句块肯定不会执行了。

无论是否发生异常,finally代码块中的代码总会被执行。根据这个特性,finally块确保在代码执行过程中发生异常时,资源得到正确释放,所以经常用来关闭资源。 自从JDK7之后,支持try-with-resources的写法,这种写法对比之前更清晰、明了:

public class MainTest {
    public static void main(String[] args) {
        try (InputStream in = new FileInputStream("awsl")) {
            in.read();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

JVM先会把try或者catch代码块中的返回值保留,再来执行finally代码块中的语句,等到finally代码块执行完毕之后,在把之前保留的返回值给返回出去。 这条规则只适用于returnthrow语句,不适用于breakcontinue语句,因为它们根本就没有返回值。

public class MyTest {
 
	public static void main(String[] args) {
        // main 代码块中的执行结果为:1
		System.out.println("main 代码块中的执行结果为:" + myMethod());
	}
 
	public static int myMethod() {
		int i = 1;
		try {
			System.out.println("try 代码块被执行!");
			return i;
		} finally {
			++i;
			System.out.println("finally 代码块被执行!");
			System.out.println("finally 代码块中的i = " + i);
		}
 
	}
 
}

throws、throw

向上抛出交给调用者处理,这种方式通过在方法签名中使用throws关键字将异常向上抛出,交由调用该方法的代码处理。适用于方法本身无法或者不适合处理异常的情况。

public class ExceptionHandlingExample {
    public static void main(String[] args) {
        try {
            process();
        } catch (Exception e) {
            System.out.println("Error: " + e.getMessage());
        }
    }

    public static void process() throws Exception {
        try {
            int result = divide(10, 0);
            System.out.println("Result: " + result);
        } catch (ArithmeticException e) {
            System.out.println("Caught ArithmeticException: " + e.getMessage());
            // 处理部分异常逻辑
        }

        // 可能抛出其他类型的异常
        if (true) {
            throw new Exception("A different kind of exception occurred.");
        }
    }

    public static int divide(int a, int b) throws ArithmeticException {
        return a / b;
    }
}

throws用在方法上声明异常,子类继承的时候要继承该异常或者该异常的子类,不处理异常,谁调用该方法谁处理异常。 throws抛出异常时,它的调用者也要申明抛出异常或者捕获,不然编译报错。

    public static int divide(int a, int b) throws ArithmeticException {
    return a / b;
}

throw用于方法内部,抛出的是异常对象。调用者可以不申明或不捕获但编译器不会报错。

public static void main(String[] args) {
    try {
        process();
    } catch (Exception e) {
        System.out.println("Error: " + e.getMessage());
    }
}

throws表示出现异常的一种可能性,告诉调用者这个方法是危险的,并不一定会发生这些异常。throw则是抛出了异常,执行throw则一定抛出了某种异常对象。 两者都是消极处理异常的方式(这里的消极并不是说这种方式不好),只是抛出或者可能抛出异常,但是不会由方法去处理异常,真正的处理异常由此方法的上层调用处理。

异常链

异常链则是一种特定的异常处理技术,通过在捕获异常时将原始异常作为参数传递给新的异常,这样来保留原始异常的详细信息。 如果因为一个异常你决定抛出⼀个新的异常,⼀定要包含原有的异常,这样处理程序才可以通过getCause方法和initCause⽅法来访问异常最终的根源。 这样做有助于调试和了解错误的根本原因。

// 定义一个自定义异常
public class MyCustomException extends Exception {
    public MyCustomException(String message) {
        super(message);
    }
    
    public MyCustomException(String message, Throwable cause) {
        super(message, cause);
    }
}

// 一个可能抛出异常的方法
public class SomeService {
    public void doSomething() throws IOException {
        // 模拟抛出一个IOException
        throw new IOException("IO error occurred");
    }
}

// 使用异常链来包装原始异常
public class Main {
    public static void main(String[] args) {
        SomeService service = new SomeService();
        
        try {
            service.doSomething();
        } catch (IOException e) {
            // 包装原始异常并抛出新的自定义异常
            throw new MyCustomException("Failed to do something due to IO error", e);
        }
    }
}

经验总结

处理异常是软件开发中至关重要的一部分,有效的异常处理可以提高系统的健壮性、可靠性和用户体验。以下是处理异常的一些经验总结。

不要滥用异常

要谨慎地使用异常,异常捕获的代价非常高昂,异常使用过多会严重影响程序的性能。 如果在程序中能够用if语句和boolean变量来进行逻辑判断,那么尽量减少异常的使用,从而避免不必要的异常捕获和处理。

空catch

千万不要使用空的catch块:

try{
 // ...
}catch(IOException e){
  // ...
}

在捕获了异常之后什么都不做,相当于忽略了这个异常。空的catch块意味着你在程序中隐藏了错误和异常,并且很可能导致程序出现不可控的执行结果。 如果你非常肯定捕获到的异常不会以任何方式对程序造成影响,最好用日志将该异常进行记录,以便日后方便更新和维护。

吞掉异常

请不要在catch块中吞掉异常:

catch (NoSuchMethodException e) {
   return null;
}

不要不处理异常,而返回null,这样异常就会被吞掉,无法获取到任何失败信息,会给日后的问题排查带来巨大困难。

精确处理异常

public void foo() throws Exception { //错误做法
}

一定要尽量避免上面的代码,因为他的调用者完全不知道错误的原因到底是什么。 在方法声明中,可以由方法抛出一些特定受检异常。如果有多个,那就分别抛出多个,这样这个方法的使用者才会分别针对每个异常做特定的处理,从而避免发生故障。

public void foo() throws SpecificException1, SpecificException2 { 
//正确做法
}

同样的在捕获异常时,也要注意,尽量捕获特定的子类,而不是直接捕获Exception类。

try {
    someMethod();
} 
catch (Exception e) {
    log.error("method has failed", e);
}

上面代码,最大的问题就是,如果someMethod()的开发者在里面新增了一个特定的异常,并且预期是调用方能够特殊的对他进行处理。 但是调用者直接catch了Exception类,就会导致永远无法知道someMethod的具体变化细节。这久可能导致在运行的过程中在某一个时间点程序崩溃。 更不要去捕获Throwable类,因为Java中的Error也可以是Throwable的子类。但是Error是Java虚拟机本身无法控制的。Java虚拟机甚至可能不会在出现任何错误时请求用户的catch子句。

try {
    someMethod();
} 
catch (Throwable e) {
    log.error("method has failed", e);
}

OutOfMemoryErrorStackOverflowError便是典型的例子,它们都是由于一些超出应用处理范围的情况导致的。

抛出异常

通常情况下,在捕获异常的时候抛出异常,需要注意的是,要始终在自定义异常中,覆盖原有的异常,从而构成一条异常链,这样堆栈跟踪就不会丢失:

catch (NoSuchMethodException e) {
    throw new MyServiceException("Some information: " + e.getMessage());  //错误做法
}

上面的命令可能会丢失掉主异常的堆栈跟踪。正确的方法是:

catch (NoSuchMethodException e) {
     throw new MyServiceException("Some information: " , e);  //正确做法
}

需要注意的是,可以记录异常或抛出异常,但不要同时做:

catch (NoSuchMethodException e) {
   log.error("Some information", e);
   throw e;
}

抛出和日志记录可能会在日志文件中产生多个日志消息,这就会导致同一个问题,却在日志中有很多不同的错误信息,使得开发人员陷入混乱。

选择异常

一旦你决定抛出异常,你就要决定抛出抛出检查异常还是非检查异常。

检查异常导致了太多的try…catch代码,可能有很多检查异常对开发人员来说是无法合理地进行处理的,比如:SQLException,而开发人员却不得不去进行try…catch,这样就会导致经常出现这样一种情况:逻辑代码只有很少的几行,而进行异常捕获和处理的代码却有很多行。 这样不仅导致逻辑代码阅读起来晦涩难懂,而且降低了程序的性能。

建议尽量避免检查异常的使用,如果确实该异常情况出现很的普遍,需要提醒调用者注意处理的话,就使用检查异常;否则使用非检查异常。 因此,在一般情况下,尽量将检查异常转变为非检查异常交给上层处理。

不要在finally中抛异常

try {
  someMethod();  //抛出 exceptionOne
}finally{
  cleanUp();    //如果在这里再抛出一个异常,那么try中的exception将会丢失
}

在上面的例子中,如果someMethod()抛出一个异常,并且在finally块中,cleanUp()也抛出一个异常,那么初始的exception将永远丢失。 但是如果你不想处理someMethod()中的异常,但是仍然需要做一些清理工作,那么在finally块中进行清理。不要使用catch块。

参考文章

发表评论