一、Java异常的处理

程序处理异常,需要解决以下三个问题:

  1. 哪里发生异常?
  2. 谁来处理异常?
  3. 如何处理异常?

带着这三个问题我们来看一下Java中的异常处理机制。

  1. 首先需要明确在哪里发生异常。在代码块中通过try-catch来发现异常。在使用try-catch时需要分清稳定代码和非稳定代码,不能大包大揽,将大段代码定义在try-catch块内,非常不利于问题的定位。稳定代码是如何都不会出错的代码,如 int a =0。异常捕获是针对非稳定代码的,捕获时要区分异常类型并做好相应的处理。
  2. 其次,判断谁来处理异常。先明确下throw和throws的区别。throw是方法内部抛出具体异常类对象的关键字,而throws则是方法签名上,表示方法定义者可以通过此方法声明向上抛出异常对象。如果异常在当前方法的处理能力范围之内且没有必要对外透出,那么就直接捕获并处理即可。否则向上抛出,由上层方法或者框架来处理。
  3. 最后,无论采用那种方式处理异常,都不应该捕获异常之后什么都不做或者打印一行日志了事。如果在方法内部处理异常,需要根据不同的业务场景进行定制处理,如重试、回滚等操作。如果向上抛出异常,需要在异常对象中添加上下文参数、局部变量、运行环境等信息,这样有利于以后的排查。

二、异常的分类

异常分类结构图:

java查看异常信息 java异常日志_slf4j


JDK中定义了一套完整的异常机制,所有的异常都是Throwable的子类,分为Error(致命异常)和Exception(非致命异常)。

  • Error是一种非常特殊的异常类型,它的出现标识着系统发生了不可控的错误,例如StackOverFlowError或者OutOfMemoryError。针对此类错误,程序无法处理,只能人工介入。
  • Exception又分为checked异常(受检异常)和unchecked异常()。
  1. checked异常时需要在代码中显式处理的异常,否则会编译报错。如果能自行处理则可以在当前方法中捕获异常,如果无法处理则继续向调用方抛出异常对象。常见的checked异常包括JDK中定义的SQLException、ClassNotFoundException等。checked异常可以进一步细分为两类:
  • 无能为力、引起注意型。针对此类异常,程序无法处理,如字段超长导致的SQLException,即使做再多的重试也无法解决异常,一般处理此类异常是完成的保存异常现场,供开发工程师介入解决。
  • 力所能及、坦然处置型。如发生未授权异常(UnAuthorizedException),程序可跳转至权限申请页面。
  1. 在Exception中,unchecked异常时运行时异常,他们都继承自RuntimeException,不需要程序显式的捕捉和处理,unchecked异常可以进一步细分为3类:
  • 可预测异常(Predicted Exception):常见的可预测异常包括IndexOutOfBoundsException、NullPointerException等,基于对代码的性能和稳定性要求,此类异常不应该被产生或者抛出,而应该提前做好边界检查、空指针判断等处理。显式的声明或者捕获此类异常会对程序的可读性和运行效率产生很大影响。
  • 需捕获异常(Caution Exception),例如在使用Dubbo框架进行RPC调用时产生的远程服务超时异常DubboTimeoutException,此类异常是客户端必须显式处理的异常,不能因服务端的异常导致客户端不可用,此时的处理方案可以是重试,或者降级处理等。
  • 可透出异常(Ignored Exception),主要是指框架或者系统产生的且会自行处理的异常,而程序无需关心。例如针对Spring 框架中抛出的NoSuchRequestHandingMethodException异常,Spring框架会自己完成异常的处理,默认将自身抛出的异常自动映射到合适的状态码,比如启动防护机制跳转到404页面。

三、try代码块

try-catch-finally是处理程序异常的三部曲。当存在try时,可以只有catch代码块,也可以只有finally代码块,但try就是不能单独使用。

  • try代码块:监视代码执行过程,一旦发现异常则直接跳转至catch,如果没有catch,则直接跳转至finally。
  • catch代码块:可选执行的代码块,如果没有任何异常则不会执行,如果发生异常则进行处理,或者向上抛出。这些都在catch块里进行处理。
  • finally代码块:必选执行的代码块,不管是否有异常发生,即使发生OutOfMemoryError也会执行,通常用于处理善后清理工作。如果finally代码块没有执行,则有这三种可能:1,没有进入try代码块。2,进入try代码块,但是代码运行中出现了死循环或者死锁状态。3,进入try代码块,但是执行了System.exit()操作。

注意:finally是在return表达式运行后执行的,此时将要return的结果已被暂存起来,待finally代码块执行结束后再将之前暂存的结果返回。

public class Test {
	public static void main(String[] args) {
     int num = finallyNotWork();
     System.out.println(num);
	}

	public static int finallyNotWork() {
		int temp = 1000;
		try {
			throw new Exception();
		} catch (Exception e) {
			return ++temp;
		} finally {
			temp = 9999;
		}
	}
}

此处方法执行的最终结果输出的是1001,而不是9999。finally代码块的职责不在于对变量进行赋值等操作,而是清理资源、释放链接、关闭管道等操作,此时如果有异常也要使用try-catch。

相对于在finally中赋值,更危险的是在finally中使用return操作,这样的代码会使返回值变得非常不可控:

public class Test {
	static int x = 1;
	static int y = 10;
	static int z = 100;

	public static void main(String[] args) {
		int num = finallyreturn();
		System.out.println("value="+num);
		System.out.println("x="+x);
		System.out.println("y="+y);
		System.out.println("z="+z);
	}

	public static int finallyreturn() {
		try {
            //....
			return ++x;
		} catch (Exception e) {
			return ++y;
		} finally {
			return ++z;
		}
	}
}

执行结果如下:

value=101
x=2
y=10
z=101

执行结果说明:

  • 最后return的动作是由finally代码块中的return ++z完成的,所以方法返回值为101。
  • 语句return++x被执行成功,所以运行结果是x=2。
  • 如果在try块return之前的逻辑中有异常抛出,那么运行结果将会是y=11,而x=1。

finally代码块中使用return语句,使返回值的判断变得复杂,所以为了避免返回值不可控,不要在finally块中使用return语句。

最后,看一下try代码块和锁的关系,lock方法可能会抛出unchecked异常,如果放在try代码块中,必然会触发finally中的unlock方法执行。对未加锁的对象解锁会抛出unchecked异常,如IllegalMonitorStateException,虽然是因为加锁失败而造成程序中断的,但真正加锁失败的原因可能会被后者(解锁)覆盖。所以在try代码块之前调用lock()方法,避免由于加锁失败导致finally中unlock()抛出异常。

Lock、ThreadLocal、InputStream等这些需要进行强制释放和清除的对象都得在finally代码块中和进行显式的清理,避免产生内存泄漏,或者资源消耗。

四、异常的抛与接

异常的抛与接需要严格的对等传递异常信息机制。我们要使捕获的异常与被抛出的异常时完全匹配的,或者捕获的异常是被抛出的异常的父类。传递异常信息的方式是通过抛出异常对象,还是把异常信息转换成信号量封装在特定对象中,这需要方法提供者和方法调用者之间达成契约。推荐对外提供的开放接口使用错误码,公司内部跨应用远程服务调用优先考虑使用Result对象来封装错误码、错误描述信息,而应用内部则直接抛出异常对象。

五、日志

记录应用日志的原因主要有三个:

  • 记录操作轨迹。可以数据化地分析用户偏好,有助于优化业务逻辑,为用户提供个性化的服务。
  • 监控系统运行状况。可以实时监控系统运行状况:如内存、CPU等使用情况。应用运行情况:如响应时间,QPS等交互状态。应用错误信息:如空指针、SQL异常等的监控。例如在CPU使用率大于60%,四核服务器中load大于4时发出报警,提醒工程师及时处理,避免发生故障。
  • 回溯系统故障。完整的现场日志能有助于快速定位和分析问题。例如当系统内存溢出时,如果日志系统记录了问题发生现场的堆信息,就可以通过这个日志分析是什么对象大量产生并且没有释放内存,回溯系统故障,从而定位解决问题。
5.1 日志规范

日志命名规范。推荐的日志文件命名方式为:appName_logType_logName.log。其中logType为日志类型,推荐分类有stats、monitor、visit等,logName为日志描述。这种命名方式通过文件名就能知道日志文件属于什么应用,什么类型、什么目的,也有利于归类查找。

日志的级别。针对不同的场景,日志被分为以下五种不同的级别,按照重要程度有低到高排序:

  • DEBUG级别日志。记录对调试程序有帮助的信息。
  • INFO级别的日志。用来记录程序运行现场,虽然此处并未发生错误,但对于排查其它错误具有指导意义。
  • WARN级别日志。也可以用来记录程序运行现场,但更偏向于表明此处有出现潜在错误的可能。
  • ERROR级别日志。表明当前程序运行发生了错误,需要被关注。但是当前发生的错误,没有影响系统的继续运行。
  • FATAL级别日志。表明当前程序运行出现了严重的错误事件,并且将导致应用程序中断。

可以看出,以上不同级别的日志优先级和重要性不同,因此在打印日志时针对日志时针对不同的日志级别要有不同的处理方式。

1.预先判断日志级别

对于DEBUG、INFO级别的日志,必须使用条件输出或者占位符的方式打印。该约定综合考虑了程序的运行效率和日志打印的需求。例如在某个配置了打印日志级别为WARN的应用中,如果针对DEBUG日志,仅仅在程序中写出logger.debug(“Processing trade with id:”+id+“and symbol:”+symbol);,那么该日志不会被打印,但是会执行字符串拼接操作,如果symbol是对象,还会执行toString方法,白白浪费了系统资源,下面是正确的打印日志方式:

//使用条件判断日志级别
if(logger.isDebugEnabled()){
    logger.debug("Processing trade with id:"+id+"and symbol:" +symbol);
}
//使用占位符形式
logger.debug("Processing trade with id:{} and symbol:{}",id,symbol);
2.避免无效日志打印

生产环境禁止输出DEBUG日志且有选择的输出INFO日志。

使用INFO、WARN级别来记录业务行为信息时,一定要控制日志输出量,以避免磁盘空间不足。同时腰围日志文件设置合理的生命周期,及时清理过期的日志。

避免重复打印,务必在日志配置文件中设置additivity=false。

3.区别对待错误日志

WARN、ERROR都是与错误相关的日志级别,但是不要一发生错误就笼统的输出ERROR级别日志。一些业务异常是可以通过引导重试就能恢复正常的,例如用户输入参数错误。在这种情况下,记录日志是为了在用户咨询时可以还原现场,如果输出为ERROR级别就表示一旦出现,需要人工关注,显然不合理。所以ERROR级别只记录系统逻辑错误、异常或者违反重要的业务规则,其它错误都可以归为WARN级别。

4.保证记录内容完整

日志记录的内容包括现场上下文信息与异常堆栈信息,所以打印时需要注意以下两点:

  • 记录异常时一定要输出异常堆栈,例如logger.error(“xxx”+e.getMessage(),e);。
  • 日志中如果输出对象实例,确保对象实例重写了toString方法,否则只会打印对象的hashCode值,没有实际意义。

综上所述,日志是一个系统必不可少的一部分,但日志并不是多多益善,过多的日志会降低系统性能、占用大量磁盘,也不利于查找定位问题,所以日志记录时请多考虑这三个问题:1.日志是否有人看。2.看到这条日志能做什么。3.能不能提升问题排查效率。

5.2 日志框架

日志框架分为三个部分,包括日志门面、日志适配器、日志库。利用门面设计模式,即Facade进行解耦,是日志使用变得更加简单,如下图所示:

java查看异常信息 java异常日志_代码块_02

1.日志门面

门面设计模式是面向对象设计模式中的一种,日志框架采用的就是这种模式,类似于JDBC的设计理念。他只提供一套接口规范,自身不负责日志功能的实现,目的是让使用者不需要关注底层具体是哪个日志库来负责日志打印及具体的使用细节等。目前使用最广泛的门面有两种:slf4j和commons-logging。

2.日志库

他实现了日志的相干功能,主流的日志库有三个,分别是log4j、log-jdk、logback。最早Java想记录日志只能通过System.out或者System.err来完成,非常不方便。最早出现了log4j来解决这个问题。JDK也在1.4版本引入了一个日志库java.util.logging.Logger,简称log-jdk。logback是最晚出现的,他和log4j出自同一作者,是log4j的升级版,本身就实现了slf4j的接口。

3.日志适配器

日志适配器分两种场景:

  • 日志门面适配器,因为slf4j规范是后来提出的,在此之前的日志库是没有实现slf4j接口的,例如log4j;所以在工程里想要使用slf4j+log4j的模式就额外需要一个适配器(slf4j-log4j12)来解决接口不兼容的问题。
  • 日志库适配器,在一些老的工程里,一开始为了开发简单而直接使用了日志库API来完成日志打印,随着时间的推移想将原来的直接调用日志库的模式改为业界标准的门面模式(例如slf4j+logback组合,但老工程代码里打印日志的地方太多,难以改动,所以需要一个适配器来完成从旧日志库的API到slf4j的路由,这样在不改动原代码的情况下也能使用slf4j来统一管理日志,而且后续自由替换具体日志库也不成问题。
4.以Maven工程为例在工程里进行日志集成

4.1 如果是新工程,推荐使用slf4j+logback的模式。因为logback自身实现了slf4j的接口,无需额外引入适配器,另外logback是log4j的升级版,具备比log4j更多的优点,可以通过如下方式进行集成:

<dependency>
			<groupId>org.slf4j</groupId>
			<artifactId>slf4j-api</artifactId>
			<version>${slf4j-api.version}</version>
		</dependency>
		<dependency>
			<groupId>ch.qos.logback</groupId>
			<artifactId>logback-classic</artifactId>
			<version>${logback-classic.version}</version>
		</dependency>
		<dependency>
			<groupId>ch.qos.logback</groupId>
			<artifactId>logback-core</artifactId>
			<version>${logback-core.version}</version>
		</dependency>

4.2如果是老工程,则需要根据所使用的日志库来确定门面适配器,通常情况下老工程使用的都是log4j,因此以log4j日志库为例,可通过如下配置进行 集成:

<dependency>
			<groupId>org.slf4j</groupId>
			<artifactId>slf4j-api</artifactId>
			<version>${slf4j-api.version}</version>
		</dependency>
        <dependency>
			<groupId>org.slf4j</groupId>
			<artifactId>slf4j-log4j12</artifactId>
			<version>${slf4j-log4j12.version}</version>
		</dependency>
		<dependency>
			<groupId>log4j</groupId>
			<artifactId>log4j</artifactId>
			<version>${log4j.version}</version>
		</dependency>

如果老代码中直接使用了log4j日志库提供的接口来打印日志,则还需要引入日志库适配器,配置实例如下所示:

<dependency>
			<groupId>log4j</groupId>
			<artifactId>log4j-over-slf4j</artifactId>
			<version>${log4j-over-slf4j.version}</version>
		</dependency>

至此,我们的工程就完成了日志框架的集成,再加上一个日志配置文件(如log4j.xml、logback.xml等),并在工程启动的时候加载,就可以进行日志打印了,示例如下:

private static final Logger logger=LoggerFactory.getLogger(Test.class);

注意Logger被定义为static变量,是因为这个Logger与当前类绑定,避免每次都new一个新对象造成资源浪费,甚至OutOfMemoryError问题。

另外在使用slf4j+日志库模式时,要防止日志库冲突,一旦发生则可能出现日志打印失效的问题。例如,在工程中配置的日志库为log4j,但是工程依赖中的一个jar包间接地引入了logback日志库,导致打印日志的Logger引用实际指向ch.qos.logback.classic.Logger对象,二者的冲突将会引发日志打印失效的问题。