编写高质量Java代码的151个建议(111-124)

序言

声明

因为简书篇幅限制,151个建议只能分开.这里是 [111-124]
本书来源 @Linux公社 的 <<编写高质量Java代码的151个习惯>> 的电子书
作者:秦小波 出版社:北京 机械工业出版社 2011.11
如有侵权请联系 @小猪童鞋QQ聊天链接 删除


@编写高质量Java代码的151个建议(1-40)
@编写高质量Java代码的151个建议(41-70)
@编写高质量Java代码的151个建议(71-90)
@编写高质量Java代码的151个建议(91-110)
@编写高质量Java代码的151个建议(111-124)
@编写高质量Java代码的151个建议(125-135)
@编写高质量Java代码的151个建议(136-151)


致本文读者:

如果小伙伴发现有地方有错误,请联系我 @小猪童鞋QQ聊天链接
欢迎小伙伴和各位大佬们一起学习,可私信也可通过上方QQ链接

我的环境:

eclipse version: 2019-03 (4.11.0) Build id: 20190314-1200
jdk1.8
Lombok.jar 插件 安装指南看这里 @简单粗暴节省JavaBean代码插件 Lombok.jar

建议111:采用异常链传递异常

设计模式中有一个模式叫做责任链模式(Chain of Responsibility) ,它的目的是将多个对象连成一条链,并沿着这条链传递该请求,直到有对象处理它为止,异常的传递处理也应该采用责任链模式。

上一建议中我们提出了异常需要封装,但仅仅封装还是不够的,还需要传递异常。我们知道,一个系统友好性的标志是用户对该系统的" 粘性",粘性越高,系统越友好,粘性越低系统友好性越差,那问题是怎么提高系统的“粘性”呢?友好的界面和功能是一个方面,另外一个方面就是系统出现非预期情况的处理方式了。

比如我们的JavaEE项目一般都有三层结构:持久层,逻辑层,展现层,持久层负责与数据库交互,逻辑层负责业务逻辑的实现,展现层负责UI数据库的处理,有这样一个模块:用户第一次访问的时候,需要从持久层user.xml中读取信息,如果该文件不存在则提示用户创建之,那问题来了:如果我们直接把持久层的异常FileNotFoundException抛弃掉,逻辑层根本无法得知发生了何事,也就不能为展现层提供一个友好的处理结果了,最终倒霉的就是发展层:没有办法提供异常信息,只能告诉用户说“出错了,我也不知道出什么错了”___毫无友好性可言。

正确的做法是先封装,然后传递,过程如下:

(1)、把FIleNotFoundException封装为MyException。

(2)、抛出到逻辑层,逻辑层根据异常代码(或者自定义的异常类型)确定后续处理逻辑,然后抛出到展现层。

(3)、展现层自行决定要展现什么,如果是管理员则可以展现低层级的异常,如果是普通用户则展示封装后的异常。

明白了异常为什么要传递,那接着的问题就是如何传递了。很简单,使用异常链进行异常的传递,我们以IOException为例来看看是如何传递的,代码如下:

public class IOException extends Exception {

    public IOException() {
        super();
    }
    //定义异常原因
    public IOException(String message) {
        super(message);
    }
    //定义异常原因,并携带原始异常
    public IOException(String message, Throwable cause) {
        super(message, cause);
    }
    //保留原始异常信息
    public IOException(Throwable cause) {
        super(cause);
    }
}

在IOException的构造函数中,上一个层级的异常可以通过异常链进行传递,链中传递异常的代码如下所示:

   try{
            //doSomething
        }catch(Exception e){
            throw new IOException(e);
        }

捕捉到Exception异常,然后把它转化为IOException异常并抛出(此种方式也叫作异常转译),调用者获得该异常后再调用getCause方法即可获得Exception的异常信息,如此即可方便地查找到产生异常的基本信息,便于解决问题。

结合上一建议来说,异常需要封装和传递,我们在进行系统开发时不要" 吞噬 " 异常,也不要赤裸裸的抛出异常,封装后再抛出,或者通过异常链传递,可以达到系统更健壮,更友好的目的。

建议112:受检异常尽可能转化为非受检异常

为什么说是" 尽可能"的转化呢?因为" 把所有的受检异常(Checked Exception)"都转化为非受检异常(Unchecked Exception)" 这一想法是不现实的:受检异常是正常逻辑的一种补偿手段,特别是对可靠性要求比较高的系统来说,在某些条件下必须抛出受检异常以便由程序进行补偿处理,也就是说受检异常有合理存在的理由,那为什么要把受检异常转化为非受检异常呢?难道说受检异常有什么缺陷或者不足吗?是的,受检异常确实有不足的地方:

(1)、受检异常使接口声明脆弱

OOP(Object Oriented Programming,面向对象程序设计) 要求我们尽量多地面向接口编程,可以提高代码的扩展性、稳定性等,但是涉及异常问题就不一样了,例如系统初期是这样设计一个接口的:

interface User{
    //修改用户密码,抛出安全异常
    public void changePassword() throws MySecurityException;
}

随着系统的开发,User接口有了多个实现者,比如普通的用户UserImpl、模拟用户MockUserImpl(用作测试或系统管理)、非实体用户NonUserImpl(如自动执行机,逻辑处理器等),此时如果发现changePassword方法可能还需要抛出RejectChangeException(拒绝修改异常,如自动执行正在处理的任务时不能修改其代码),那就需要修改User接口了:changePassword方法增加抛出RejectChangeException异常,这会导致所有的User调用者都要追加了对RejectChangeException异常问题的处理。

这里产生了两个问题:一、 异常是主逻辑的补充逻辑,修改一个补充逻辑,就会导致主逻辑也被修改,也就是出现了实现类 " 逆影响 " 接口的情景,我们知道实现类是不稳定的,而接口是稳定的,一旦定义了异常,则增加了接口的不稳定性,这是面向对象设计的严重亵渎;二、实现的变更最终会影响到调用者,破坏了封装性,这也是迪米特法则所不能容忍的。

(2)、受检异常使代码的可读性降低

一个方法增加可受检异常,则必须有一个调用者对异常进行处理,比如无受检异常方法doStuff是这样调用的:

public static void main(String[] args) {
        doStuff();
    }

doStuff方法一旦增加受检异常就不一样了,代码如下:

public static void main(String[] args) {
        try{
            doStuff();
        }catch(Exception e){
            e.printStackTrace();
        }
    }

doStuff方法增加了throws Exception,调用者就必须至少增加4条语句来处理该异常,代码膨胀许多,可读性也降低了,特别是在多个异常需要捕捉的情况下,多个catch块多个异常处理,而且还可能在catch块中再次抛出异常,这大大降低了代码的可读性。

(3)、受检异常增加了开发工作量

我们知道,异常需要封装和传递,只有封装才能让异常更容易理解,上层模块才能更好的处理,可这会导致低层级的异常没玩没了的封装,无端加重了开发的工作量。比如FileNotFoundException进行封装,并抛出到上一个层级,于是增加了开发工作量。

受检异常有这么多的缺点,那有没有什么方法可以避免或减少这些缺点呢?有,很简单的一个规则:将受检异常转化为非受检异常即可,但是我们也不能把所有的受检异常转化为非受检异常,原因是在编码期上层模块不知道下层模块会抛出何种非受检异常,只有通过规则或文档来描述,可以这样说:

受检异常提出的是" 法律下的自由 ",必须遵守异常的约定才能自由编写代码。
非受检异常则是“ 协约性质的自由 ”,你必须告诉我你要抛什么异常,否则不会处理。
  以User接口为例,我们在声明接口时不再声明异常,而是在具体实现时根据不同的情况产生不同的非受检异常,这样持久层和逻辑层抛出的异常将会由展现自行决定如何展示,不再受异常的规则约束了,大大简化开发工作,提高了代码的可读性。

那问题又来了,在开发和设计时什么样的受检异常有必要化为非受检异常呢?" 尽可能 " 是以什么作为判断依据呢?受检异常转换为非受检异常是需要根据项目的场景来决定的,例如同样是刷卡,员工拿着自己的工卡到考勤机上打考勤,此时如果附近有磁性物质干扰,则考勤机可以把这种受检异常转化为非受检异常,黄灯闪烁后不做任何记录登记,因为考勤失败这种情景不是" 致命 "的业务逻辑,出错了,重新刷一下即可。但是到银行网点取钱就不一样了,拿着银行卡到银行取钱,同样有磁性物质干扰,刷不出来,那这种异常就必须登记处理,否则会成为威胁银行卡安全的事件。汇总成一句话:当受检异常威胁到了系统的安全性,稳定性,可靠性、正确性时,则必须处理,不能转化为非受检异常,其它情况则可以转化为非受检异常。

注意:受检异常威胁到系统的安全性,稳定性、可靠性、正确性时,不能转换为非受检异常。

建议113:不要在finally块中处理返回值

在finally代码块中处理返回值,这是考试和面试中经常出现的题目。虽然可以以此来出考试题,但在项目中绝对不能再finally代码块中出现return语句,这是因为这种处理方式非常容易产生" 误解 ",会误导开发者。例如如下代码:

public class Test113 {

    public static void main(String[] args) {
        try {
            System.out.println(doStuff(-1));
            System.out.println(doStuff(100));
        } catch (Exception e) {
            System.out.println("这里是永远不会到达的");
        }
    }

    // 该方法抛出受检异常
    public static int doStuff(int _p) throws Exception {
        try {
            if (_p < 0) {
                throw new DataFormatException(" 数据格式错误 ");
            } else {
                return _p;
            }

        } catch (Exception e) {
            // 异常处理
            throw e;
        } finally {
            return -1;
        }
    }
}

对于这段代码,有两个问题:main方法中的doStuff方法的返回值是什么?doStuff方法永远都不会抛出异常吗?

答案是:doStuff(-1)的值是-1,doStuff(100)的值也是-1,调用doStuff方法永远都不会抛出异常,有这么神奇?原因就是我们在finally代码块中加入了return语句,而这会导致出现以下两个问题:

(1)、覆盖了try代码块中的return返回值

当执行doStuff(-1)时,doStuff方法产生了DataFormatException异常,catch块在捕捉此异常后直接抛出,之后代码执行到finally代码块,就会重置返回值,结果就是-1了。也就是出现先返回,再重置返回的情况。

有人可能会思考,是不是可以定义变量,在finally中修改后return呢?代码如下:

public static int doStuff() {
        int a = 1;
        try {
            return a;
        } catch (Exception e) {

        } finally {
            // 重新修改一下返回值
            a = -1;
        }
        return 0;
    }

该方法的返回值永远是1,不会是-1或0(为什么不会执行到" return 0 " 呢?原因是finally执行完毕后该方法已经有返回值了,后续代码就不会再执行了),这都是源于异常代码块的处理方式,在代码中try代码块就标志着运行时会有一个Throwale线程监视着该方法的运行,若出现异常,则交由异常逻辑处理。

我们知道方法是在栈内存中运行的,并且会按照“ 先进后出 ”的原则执行,main方法调用了doStuff方法,则main方法在下层,doStuff方法在上层,当doStuff方法执行完" return a " 时,此方法的返回值已经确定int类型1(a变量的值,注意基本类型都是拷贝值,而不是引用),此时finally代码块再修改a的值已经与doStuff返回者没有任何关系了,因此该方法永远都会返回1.

继续追问,那是不是可以在finally代码块中修改引用类型的属性以达到修改返回值的效果呢?代码如下:

class Person {
    private String name;

    public String getName() {
        return name;
    }
    public void setName(String name) {
        this.name = name;
    }
}
public static Person doStuffw() {
        Person person = new Person();
        person.setName("张三");
        try {
            return person;
        } catch (Exception e) {    

        } finally {
            // 重新修改一下值
            person.setName("李四");
        }
        person.setName("王五");
        return person;
    }

此方法的返回值永远都是name为李四的Person对象,原因是Person是一个引用对象,在try代码块中的返回值是Person对象的地址,finally中再修改那当然会是李四了。

(2)、屏蔽异常

为什么明明把异常throw出去了,但main方法却捕捉不到呢?这是因为异常线程在监视到有异常发生时,就会登记当前的异常类型为DataFormatException,但是当执行器执行finally代码块时,则会重新为doStuff方法赋值,也就是告诉调用者" 该方法执行正确,没有产生异常,返回值为1 ",于是乎,异常神奇的消失了,其简化代码如下所示:

public static void doSomeThing(){
        try{
            //正常抛出异常
            throw new RuntimeException();
        }finally{
            //告诉JVM:该方法正常返回
            return;
        }
    }
public static void main(String[] args) {
        try {
            doSomeThing();
        } catch (RuntimeException e) {
            System.out.println("这里是永远不会到达的");
        }
    }

上面finally代码块中的return已经告诉JVM:doSomething方法正常执行结束,没有异常,所以main方法就不可能获得任何异常信息了。这样的代码会使可读性大大降低,读者很难理解作者的意图,增加了修改的难度。

在finally中处理return返回值,代码看上去很完美,都符合逻辑,但是执行起来就会产生逻辑错误,最重要的一点是finally是用来做异常的收尾处理的,一旦加上了return语句就会让程序的复杂度徒然上升,而且会产生一些隐蔽性非常高的错误。

与return语句相似,System.exit(0)或RunTime.getRunTime().exit(0)出现在异常代码块中也会产生非常多的错误假象,增加代码的复杂性,大家有兴趣可以自行研究一下。

注意:不要在finally代码块中出现return语句。

建议114:不要在构造函数中抛出异常

Java异常的机制有三种:

Error类及其子类表示的是错误,它是不需要程序员处理也不能处理的异常,比如VirtualMachineError虚拟机错误,ThreadDeath线程僵死等。
RunTimeException类及其子类表示的是非受检异常,是系统可能会抛出的异常,程序员可以去处理,也可以不处理,最经典的就是NullPointException空指针异常和IndexOutOfBoundsException越界异常。
Exception类及其子类(不包含非受检异常),表示的是受检异常,这是程序员必须处理的异常,不处理则程序不能通过编译,比如IOException表示的是I/O异常,SQLException表示的数据库访问异常。  
我们知道,一个对象的创建过程经过内存分配,静态代码初始化、构造函数执行等过程,对象生成的关键步骤是构造函数,那是不是也允许在构造函数中抛出异常呢?从Java语法上来说,完全可以在构造函数中抛出异常,三类异常都可以,但是从系统设计和开发的角度来分析,则尽量不要在构造函数中抛出异常,我们以三种不同类型的异常来说明之。

(1)、构造函数中抛出错误是程序员无法处理的

在构造函数执行时,若发生了VirtualMachineError虚拟机错误,那就没招了,只能抛出,程序员不能预知此类错误的发生,也就不能捕捉处理。

(2)、构造函数不应该抛出非受检异常

我们来看这样一个例子,代码如下:

class Person {
    public Person(int _age) {
        // 不满18岁的用户对象不能建立
        if (_age < 18) {
            throw new RuntimeException("年龄必须大于18岁.");
        }
    }

    public void doSomething() {
        System.out.println("doSomething......");
    }
}

这段代码的意图很明显,年龄不满18岁的用户不会生成一个Person实例对象,没有对象,类行为doSomething方法就不可执行,想法很好,但这会导致不可预测的结果,比如我们这样引用Person类:

public static void main(String[] args) {
        Person p =  new Person(17);
        p.doSomething();
        /*其它的业务逻辑*/
    }

很显然,p对象不能建立,因为是一个RunTimeException异常,开发人员可以捕捉也可以不捕捉,代码看上去逻辑很正确,没有任何瑕疵,但是事实上,这段程序会抛出异常,无法执行。这段代码给了我们两个警示:

加重了上层代码编写者的负担:捕捉这个RuntimeException异常吧,那谁来告诉我有这个异常呢?只有通过文档约束了,一旦Person类的构造函数经过重构后再抛出其它非受检异常,那main方法不用修改也是可以测试通过的,但是这里就可能会产生隐藏的缺陷,而写还是很难重现的缺陷。不捕捉这个RuntimeException异常,这个是我们通常的想法,既然已经写成了非受检异常,main方法的编码者完全可以不处理这个异常嘛,大不了不执行Person的方法!这是非常危险的,一旦产生异常,整个线程都不再继续执行,或者链接没有关闭,或者数据没有写入数据库,或者产生内存异常,这些都是会对整个系统产生影响。
后续代码不会执行:main方法的实现者原本是想把p对象的建立作为其代码逻辑的一部分,执行完doSomething方法后还需要完成其它逻辑,但是因为没有对非受检异常进行捕捉,异常最终会抛出到JVM中,这会导致整个线程执行结束后,后面所有的代码都不会继续执行了,这就对业务逻辑产生了致命的影响。
(3)、构造函数尽可能不要抛出受检异常

我们来看下面的例子,代码如下:

//父类
class Base {
    // 父类抛出IOException
    public Base() throws IOException {
        throw new IOException();
    }
}
//子类
class Sub extends Base {
    // 子类抛出Exception异常
    public Sub() throws Exception {

    }
}

就这么一段简单的代码,展示了在构造函数中抛出受检异常的三个不利方面:

导致子类膨胀:在我们的例子中子类的无参构造函数不能省略,原因是父类的无参构造函数抛出了IOException异常,子类的无参构造函数默认调用的是父类的构造函数,所以子类无参构造函数也必须抛出IOException或其父类。
违背了里氏替换原则:"里氏替换原则" 是说父类能出现的地方子类就可以出现,而且将父类替换为子类也不会产生任何异常。那我们回头看看Sub类是否可以替换Base类,比如我们的上层代码是这样写的:

public static void main(String[] args) {
        try {
            Base base = new Base();
        } catch (Exception e) {    
            e.printStackTrace();
        }
    }

然后,我们期望把new Base()替换成new Sub(),而且代码能够正常编译和运行。非常可惜,编译不通过,原因是Sub的构造函数抛出了Exception异常,它比父类的构造函数抛出更多的异常范围要宽,必须增加新的catch块才能解决。

可能大家要问了,为什么Java的构造函数允许子类的构造函数抛出更广泛的异常类呢?这正好与类方法的异常机制相反,类方法的异常是这样要求的:

// 父类
class Base {
    // 父类方法抛出Exception
    public void testMethod() throws Exception {

    }
}

// 子类
class Sub extends Base {
    // 父类方法抛出Exception
    @Override
    public void testMethod() throws IOException {

    }
}

子类的方法可以抛出多个异常,但都必须是覆写方法的子类型,对我们的例子来说,Sub类的testMethod方法抛出的异常必须是Exception的子类或Exception类,这是Java覆写的要求。构造函数之所以于此相反,是因为构造函数没有覆写的概念,只是构造函数间的引用调用而已,所以在构造函数中抛出受检异常会违背里氏替换原则原则,使我们的程序缺乏灵活性。

3.子类构造函数扩展受限:子类存在的原因就是期望实现扩展父类的逻辑,但父类构造函数抛出异常却会让子类构造函数的灵活性大大降低,例如我们期望这样的构造函数。

// 父类
class Base {
    public Base() throws IOException{
        
    }
}
// 子类
class Sub extends Base {
    public Sub() throws Exception{
        try{
            super();
        }catch(IOException e){
            //异常处理后再抛出
            throw e;
        }finally{
            //收尾处理
        }
    }
}

很不幸,这段代码编译不通过,原因是构造函数Sub没有把super()放在第一句话中,想把父类的异常重新包装再抛出是不可行的(当然,这里有很多种 “曲线” 的实现手段,比如重新定义一个方法,然后父子类的构造函数都调用该方法,那么子类构造函数就可以自由处理异常了),这是Java语法机制。

将以上三种异常类型汇总起来,对于构造函数,错误只能抛出,这是程序人员无能为力的事情;非受检异常不要抛出,抛出了 " 对己对人 " 都是有害的;受检异常尽量不抛出,能用曲线的方式实现就用曲线方式实现,总之一句话:在构造函数中尽可能不出现异常。

注意 :在构造函数中不要抛出异常,尽量曲线实现。

建议115:使用Throwable获得栈信息

AOP编程可以很轻松的控制一个方法调用哪些类,也能够控制哪些方法允许被调用,一般来说切面编程(比如AspectJ),只能控制到方法级别,不能实现代码级别的植入(Weave),比如一个方法被类A的m1方法调用时返回1,在类B的m2方法调用时返回0(同参数情况下),这就要求被调用者具有识别调用者的能力。在这种情况下,可以使用Throwable获得栈信息,然后鉴别调用者并分别输出,代码如下:

class Foo {
    public static boolean method() {
        // 取得当前栈信息
        StackTraceElement[] sts = new Throwable().getStackTrace();
        // 检查是否是methodA方法调用
        for (StackTraceElement st : sts) {
            if (st.getMethodName().equals("methodA")) {
                return true;
            }
        }
        return false;
    }
}
//调用者
class Invoker{
    //该方法打印出true
    public static void methodA(){
        System.out.println(Foo.method());
    }
    //该方法打印出false
    public static void methodB(){
        System.out.println(Foo.method());
    }
}

注意看Invoker类,两个方法methodA和methodB都调用了Foo的method方法,都是无参调用,返回值却不同,这是我们的Throwable类发挥效能了。JVM在创建一本Throwable类及其子类时会把当前线程的栈信息记录下来,以便在输出异常时准确定位异常原因,我们来看Throwable源代码。

public class Throwable implements Serializable {
    private static final StackTraceElement[] UNASSIGNED_STACK = new StackTraceElement[0];
    //出现异常记录的栈帧
    private StackTraceElement[] stackTrace = UNASSIGNED_STACK;
    //默认构造函数
    public Throwable() {
        //记录栈帧
        fillInStackTrace();
    }
    //本地方法,抓取执行时的栈信息
    private native Throwable fillInStackTrace(int dummy);

    public synchronized Throwable fillInStackTrace() {
        if (stackTrace != null || backtrace != null /* Out of protocol state */) {
            fillInStackTrace(0);
            stackTrace = UNASSIGNED_STACK;
        }
        return this;
    }

}

在出现异常时(或主动声明一个Throwable对象时),JVM会通过fillInStackTrace方法记录下栈帧信息,然后生成一个Throwable对象,这样我们就可以知道类间的调用顺序,方法名称及当前行号等了。

获得栈信息可以对调用者进行判断,然后决定不同的输出,比如我们的methodA和methodB方法,同样地输入参数,同样的调用方法,但是输出却不同,这看起来很想是一个bug:方法methodA调用method方法正常显示,而方法methodB调用却会返回错误数据,因此我们虽然可以根据调用者的不同产生不同的逻辑,但这仅局限在对此方法的广泛认知上,更多的时候我们使用method方法的变形体,代码如下:

class Foo {
    public static boolean method() {
        // 取得当前栈信息
        StackTraceElement[] sts = new Throwable().getStackTrace();
        // 检查是否是methodA方法调用
        for (StackTraceElement st : sts) {
            if (st.getMethodName().equals("methodA")) {
                return true;
            }
        }
        throw new RuntimeException("除了methodA方法外,该方法不允许其它方法调用");
    }
}

只是把“return false” 替换成了一个运行期异常,除了methodA方法外,其它方法调用都会产生异常,该方法常用作离线注册码校验,让破解者视图暴力破解时,由于执行者不是期望的值,因此会返回一个经过包装和混淆的异常信息,大大增加了破解难度。

建议116:异常只为异常服务

异常只为异常服务,这是何解?难道异常还能为其它服务不成?确实能,异常原本是正常逻辑的一个补充,但是有时候会被当做主逻辑使用,看如下代码:

//判断一个枚举是否包含String枚举项
    public static <T extends Enum<T>> boolean Contain(Class<T> clz,String name){
        boolean result = false;
        try{
            Enum.valueOf(clz, name);
            result = true;
        }catch(RuntimeException e){
            //只要是抛出异常,则认为不包含
        }
        return result;
    }

判断一个枚举是否包含指定的枚举项,这里会根据valueOf方法是否抛出异常来进行判断,如果抛出异常(一般是IllegalArgumentException异常),则认为是不包含,若不抛出异常则可以认为包含该枚举项,看上去这段代码很正常,但是其中有是哪个错误:

异常判断降低了系统的性能
降低了代码的可读性,只有详细了解valueOf方法的人才能读懂这样的代码,因为valueOf抛出的是一个非受检异常
隐藏了运行期可能产生的错误,catch到异常,但没有做任何处理。
  我们这段代码是用一段异常实现了一个正常的业务逻辑,这导致代码产生了坏味道。要解决从问题也很容易,即不在主逻辑中实使用异常,代码如下:

// 判断一个枚举是否包含String枚举项
    public static <T extends Enum<T>> boolean Contain(Class<T> clz, String name) {
        // 遍历枚举项
        for (T t : clz.getEnumConstants()) {
            // 枚举项名称是否相等
            if (t.name().equals(name)) {
                return true;
            }
        }
        return false;
    }

异常只能用在非正常的情况下,不能成为正常情况下的主逻辑,也就是说,异常是是主逻辑的辅助场景,不能喧宾夺主。

而且,异常虽然是描述例外事件的,但能避免则避免之,除非是确实无法避免的异常,例如:

public static void main(String[] args) {
        File file = new File("a.txt");
        try {
            FileInputStream fis = new FileInputStream(file);
            // 其它业务处理
        } catch (FileNotFoundException e) {
            e.printStackTrace();
            // 异常处理
        }
    }

这样一段代码经常在我们的项目中出现,但经常写并不代表不可优化,这里的异常类FileNotFoundException完全可以在它诞生前就消除掉:先判断文件是否存在,然后再生成FileInputStream对象,这也是项目中常见的代码:

public static void main(String[] args) {
        File file = new File("a.txt");
        // 经常出现的异常,可以先做判断
        if (file.exists() && !file.isDirectory()) {
            try {
                FileInputStream fis = new FileInputStream(file);
                // 其它业务处理
            } catch (FileNotFoundException e) {
                e.printStackTrace();
                // 异常处理
            }
        }
    }

虽然增加了if判断语句,增加了代码量,但是却减少了FileNotFoundException异常出现的几率,提高了程序的性能和稳定性。

建议117:多使用异常,把性能问题放一边

我们知道异常是主逻辑的例外逻辑,举个简单的例子来说,比如我在马路上走(这是主逻辑),突然开过一辆车,我要避让(这是受检异常,必须处理),继续走着,突然一架飞机从我头顶飞过(非受检异常),我们可以选在继续行走(不捕捉),也可以选择指责其噪音污染(捕捉,主逻辑的补充处理),再继续走着,突然一颗流星砸下来,这没有选择,属于错误,不能做任何处理。这样具备完整例外场景的逻辑就具备了OO的味道,任何一个事务的处理都可能产生非预期的效果,问题是需要以何种手段来处理,如果不使用异常就需要依靠返回值的不同来进行处理了,这严重失去了面向对象的风格。

我们在编写用例文档(User case Specification)时,其中有一项叫做 " 例外事件 ",是用来描述主场景外的例外场景的,例如用户登录的用例,就会在" 例外事件 "中说明" 连续3此登录失败即锁定用户账号 ",这就是登录事件的一个异常处理,具体到我们的程序中就是:

public void login(){
        try{
            //正常登陆
        }catch(InvalidLoginException lie){
            //    用户名无效
        }catch(InvalidPasswordException pe){
            //密码错误的异常
        }catch(TooMuchLoginException){
            //多次登陆失败的异常
        }
    }

如此设计则可以让我们的login方法更符合实际的处理逻辑,同时使主逻辑(正常登录,try代码块)更加清晰。当然了,使用异常还有很多优点,可以让正常代码和异常代码分离、能快速查找问题(栈信息快照)等,但是异常有一个缺点:性能比较慢。

Java的异常机制确实比较慢,这个"比较慢"是相对于诸如String、Integer等对象来说的,单单从对象的创建上来说,new一个IOException会比String慢5倍,这从异常的处理机制上也可以解释:因为它要执行fillInStackTrace方法,要记录当前栈的快照,而String类则是直接申请一个内存创建对象,异常类慢一筹也就在所难免了。

而且,异常类是不能缓存的,期望先建立大量的异常对象以提高异常性能也是不现实的。

难道异常的性能问题就没有任何可以提高的办法了?确实没有,但是我们不能因为性能问题而放弃使用异常,而且经过测试,在JDK1.6下,一个异常对象的创建时间只需1.4毫秒左右(注意是毫秒,通常一个交易是在100毫秒左右),难道我们的系统连如此微小的性能消耗都不予许吗?

注意:性能问题不是拒绝异常的借口。

第九章 多线程和并发

多线程技术可以更好地利用系统资源,减少用户的响应时间,提高系统的性能和效率,但同时也增加了系统的复杂性和运维难度,特别是在高并发、大压力、高可靠性的项目中。线程资源的同步、抢占、互斥都需要慎重考虑,以避免产生性能损耗和线程死锁。

建议118:不推荐覆写start方法

多线程比较简单的实现方式是继承Thread类,然后覆写run方法,在客户端程序中通过调用对象的start方法即可启动一个线程,这是多线程程序的标准写法。不知道大家能够还能回想起自己写的第一个多线程的demo呢?估计一般是这样写的:

class MultiThread extends Thread{
    @Override
    public synchronized void start() {
        //调用线程体
        run();

    }
    @Override
    public void run() {
        //MultiThread do someThing
    }
}

覆写run方法,这好办,写上自己的业务逻辑即可,但为什么要覆写start方法呢?最常见的理由是:要在客户端调用start方法启动线程,不覆写start方法怎么启动run方法呢?于是乎就覆写了start方法,在方法内调用run方法。客户端代码是一个标准程序,代码如下

public static void main(String[] args) {
        //多线程对象
        MultiThread m = new MultiThread();
        //启动多线程
        m.start();
    }

相信大家都能看出,这是一个错误的多线程应用,main方法根本就没有启动一个子线程,整个应用程序中只有一个主线程在运行,并不会创建任何其它的线程。对此,有很简单的解决办法。只要删除MultiThread类的start方法即可。

然后呢?就结束了吗?是的,很多时候确实到此结束了。那为什么不必而且不能覆写start方法,仅仅就是因为" 多线程应用就是这样写的 " 这个原因吗?

要说明这个问题,就需要看一下Thread类的源码了。Thread类的start方法的代码(这个是JDK7版本的)如下:

public synchronized void start() {
        // 判断线程状态,必须是为启动状态
        /**
         * This method is not invoked for the main method thread or "system"
         * group threads created/set up by the VM. Any new functionality added
         * to this method in the future may have to also be added to the VM.
         *
         * A zero status value corresponds to state "NEW".
         */
        if (threadStatus != 0)
            throw new IllegalThreadStateException();
        // 加入线程组中
        /*
         * Notify the group that this thread is about to be started so that it
         * can be added to the group's list of threads and the group's unstarted
         * count can be decremented.
         */
        group.add(this);
        boolean started = false;
        try {
            // 分配栈内存,启动线程,运行run方法
            start0();
            started = true;
        } finally {
            try {
                if (!started) {
                    group.threadStartFailed(this);
                }
            } catch (Throwable ignore) {
                /*
                 * do nothing. If start0 threw a Throwable then it will be
                 * passed up the call stack
                 */
            }
        }
    }
   // 本地方法
     private native void start0();

这里的关键是本地方法start0,它实现了启动线程、申请栈内存、运行run方法、修改线程状态等职责,线程管理和栈内存管理都是由JVM负责的,如果覆盖了start方法,也就是撤销了线程管理和栈内存管理的能力,这样如何启动一个线程呢?事实上,不需要关注线程和栈内存的管理,主需要编码者实现多线程的逻辑即可(即run方法体),这也是JVM比较聪明的地方,简化多线程应用。

那可能有人要问了:如果确实有必要覆写start方法,那该如何处理呢?这确实是一个罕见的要求,不过覆写也容易,只要在start方法中加上super.start()即可,代码如下:

class MultiThread extends Thread {
    @Override
    public synchronized void start() {
        /* 线程启动前的业务处理 */
        super.start();
        /* 线程启动后的业务处理 */
    }

    @Override
    public void run() {
        // MultiThread do someThing
    }

}

注意看start方法,调用了父类的start方法,没有主动调用run方法,这是由JVM自行调用的,不用我们显示实现,而且是一定不能实现。此方式虽然解决了" 覆写start方法 "的问题,但是基本上无用武之地,到目前为止还没有发现一定要覆写start方法的多线程应用,所以要求覆写start的场景。都可以使用其他的方式实现,例如类变量、事件机制、监听等方式。

注意:继承自Thread类的多线程类不必覆写start方法。

建议119:启动线程前stop方法是不可靠的

有这样一个案例,我们需要一个高效率的垃圾邮件制造机,也就是有尽可能多的线程来尽可能多的制造垃圾邮件,垃圾邮件重要的信息保存在数据库中,如收件地址、混淆后的标题、反应垃圾处理后的内容等,垃圾制造机的作用就是从数据库中读取这些信息,判断是否符合条件(如收件地址必须包含@符号、标题不能为空等),然后转换成一份真实的邮件发出去。

整个应用逻辑很简单,这必然是一个多线程应用,垃圾邮件制造机需要继承Thread类,代码如下:

//垃圾邮件制造机
class SpamMachine extends Thread{
    @Override
    public void run() {
        //制造垃圾邮件
        System.out.println("制造大量垃圾邮件......");
    }
}

在客户端代码中需要发挥计算机的最大潜能来制造邮件,也就是说开尽可能多的线程,这里我们使用一个while循环来处理,代码如下:

public static void main(String[] args) {
        //不分昼夜的制造垃圾邮件
        while(true){
            //多线程多个垃圾邮件制造机
            SpamMachine sm = new SpamMachine();
            //xx条件判断,不符合提交就设置该线程不可执行
            if(!false){
                sm.stop();
            }
            //如果线程是stop状态,则不会启动
            sm.start();
        }
    }

在此段代码中,设置了一个极端条件:所有的线程在启动前都执行stop方法,虽然它是一个过时的方法,但它的运行逻辑还是正常的,况且stop方法在此处的目的并不是停止一个线程,而是设置线程为不可启用状态。想来这应该是没有问题的,但是运行结果却出现了奇怪的现象:部分线程还是启动了,也就是在某些线程(没有规律)中的start方法正常执行了。在不符合判断规则的情况下,不可启用状态的线程还是启用了。这是为什么呢?

这是线程启动start方法的一个缺陷。Thread类的stop方法会根据线程状态来判断是终结线程还是设置线程为不可运行状态,对于未启动的线程(线程状态为NEW)来说,会设置其标志位为不可启动,而其他的状态则是直接停止。stop方法的JDK1.6源代码(JDk1.6以上源码于此可能有变化,需要重新观察源码)如下:

@Deprecated
    public final void stop() {
        // If the thread is already dead, return.
    // A zero status value corresponds to "NEW".
    if ((threadStatus != 0) && !isAlive()) {
        return;
    }
    stop1(new ThreadDeath());
    }
private final synchronized void stop1(Throwable th) {
    SecurityManager security = System.getSecurityManager();
    if (security != null) {
        checkAccess();
        if ((this != Thread.currentThread()) ||
        (!(th instanceof ThreadDeath))) {
        security.checkPermission(SecurityConstants.STOP_THREAD_PERMISSION);
        }
    }
        // A zero status value corresponds to "NEW"
    if (threadStatus != 0) {
        resume(); // Wake up thread if it was suspended; no-op otherwise
        stop0(th);
    } else {

            // Must do the null arg check that the VM would do with stop0
        if (th == null) {
         throw new NullPointerException();
        }

            // Remember this stop attempt for if/when start is used
        stopBeforeStart = true;
        throwableFromStop = th;
        }
    }

这里设置了stopBeforeStart变量,标志着是在启动前设置了停止标志,在start方法中(JDK6源码)是这样校验的:

public synchronized void start() {
        /**
     * This method is not invoked for the main method thread or "system"
     * group threads created/set up by the VM. Any new functionality added 
     * to this method in the future may have to also be added to the VM.
     *
     * A zero status value corresponds to state "NEW".
         */
        if (threadStatus != 0)
            throw new IllegalThreadStateException();
        group.add(this);
        start0();
         // 在启动前设置了停止状态
        if (stopBeforeStart) {
        stop0(throwableFromStop);
    }
    }
    private native void start0();

注意看start0方法和stop0方法的顺序,start0方法在前,也就说既是stopBeforeStart为true(不可启动),也会启动一个线程,然后再stop0结束这个线程,而罪魁祸首就在这里!

明白了原因,我们的情景代码就很容易修改了,代码如下:

public static void main(String[] args) {
        // 不分昼夜的制造垃圾邮件
        while (true) {
            // 多线程多个垃圾邮件制造机
            SpamMachine sm = new SpamMachine();
            // xx条件判断,不符合提交就设置该线程不可执行
            if (!false) {
                new SpamMachine().start();
            }
        }
    }

不再使用stop方法进行状态的设置,直接通过判断条件来决定线程是否可启用。对于start方法的缺陷,一般不会引起太大的问题,只是增加了线程启动和停止的精度而已。

建议120:不使用stop方法停止线程

线程启动完毕后,在运行时可能需要中止,Java提供的终止方法只有一个stop,但是我不建议使用这个方法,因为它有以下三个问题:

(1)、stop方法是过时的:从Java编码规则来说,已经过时的方法不建议采用。

(2)、stop方法会导致代码逻辑不完整:stop方法是一种" 恶意 " 的中断,一旦执行stop方法,即终止当前正在运行的线程,不管线程逻辑是否完整,这是非常危险的。看如下的代码:

public static void main(String[] args) {
        Thread thread = new Thread() {
            @Override
            public void run() {
                try {
                    // 子线程休眠1秒
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    // 异常处理
                }
                System.out.println("此处是业务逻辑,永远不会执行");
            }
        };
        // 启动线程
        thread.start();
        // 主线程休眠0.1秒
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 子线程停止
        thread.stop();
    }

这段代码的逻辑是这样的:子线程是一个匿名内部类,它的run方法在执行时会休眠一秒,然后执行后续的逻辑,而主线程则是休眠0.1秒后终止子线程的运行,也就说JVM在执行tread.stop()时,子线程还在执行sleep(1000),此时stop方法会清除栈内信息,结束该线程,这也就导致了run方法的逻辑不完整,输出语句println代表的是一段逻辑,可能非常重要,比如子线程的主逻辑、资源回收、情景初始化等,但是因为stop线程了,这些都不再执行,于是就产生了业务逻辑不完整的情况。

这是极度危险的,因为我们不知道子线程会在什么时候被终止,stop连基本的逻辑完整性都无法保证。而且此种操作也是非常隐蔽的,子线程执行到何处会被关闭很难定位,这位以后的维护带来了很多麻烦。

(3)、stop方法会破坏原子逻辑

多线程为了解决共享资源抢占的问题,使用了锁概念,避免资源不同步,但是正因为此,stop方法却会带来更大的麻烦,它会丢弃所有的锁,导致原子逻辑受损。例如有这样一段程序:

class MultiThread implements Runnable {
    int a = 0;
    @Override
    public void run() {
        // 同步代码块,保证原子操作
        synchronized ("") {
            // 自增
            a++;
            try {
                //线程休眠0.1秒
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            // 自减
            a--;
            String tn = Thread.currentThread().getName();
            System.out.println(tn + ":a = " + a);
        }
    }

}

MultiThread实现了Runnable接口,具备多线程能力,其中run方法中加上了synchronized代码块,表示内部是原子逻辑,它会先自增然后自减,按照synchronized同步代码块的规则来处理,此时无论启动多少线程,打印出来的结果应该是a=0,但是如果有一个正在执行的线程被stop,就会破坏这种原子逻辑,代码如下:

public static void main(String[] args) {
        MultiThread t = new MultiThread();
        Thread t1 = new Thread(t);
        // 启动t1线程
        t1.start();
        for (int i = 0; i < 5; i++) {
            new Thread(t).start();
        }
        //停止t1线程
        t1.stop();
    }

首先说明的是所有线程共享了一个MultiThread的实例变量t,其次由于在run方法中加入了同步代码块,所以只能有一个线程进入到synchronized块中。这段代码的执行顺序如下:

线程t1启动,并执行run方法,由于没有其它线程同步代码块的锁,所以t1线程执行后自加后执行到sleep方法即开始休眠,此时a=1
JVM又启动了5个线程,也同时运行run方法,由于synchronized关键字的阻塞作用,这5个线程不能执行自增和自减操作,等待t1线程锁释放。
主线程执行了t1.stop方法,终止了t1线程,注意,由于a变量是所有线程共享的,所以其它5个线程获得的a变量也是1
其它5个线程依次获得CPU执行机会,打印出a值
  分析了这么多,相信大家也明白了输出结果,结果如下:

Thread-5:a = 1
    Thread-4:a = 1
    Thread-3:a = 1
    Thread-2:a = 1
    Thread-1:a = 1

原本期望synchronized同步代码块中的逻辑都是原子逻辑,不受外界线程的干扰,但是结果却出现原子逻辑被破坏的情况,这也是stop方法被废弃的一个重要原因:破坏了原子逻辑。

既然终止一个线程不能使用stop方法,那怎样才能终止一个正在运行的线程呢?答案也简单,使用自定义的标志位决定线程的执行情况,代码如下:

class SafeStopThread extends Thread {
    // 此变量必须加上volatile
    /*
     * volatile: 1.作为指令关键字,确保本条指令不会因编译器的优化而省略,且要求每次直接读值.
     * 2.被设计用来修饰被不同线程访问和修改的变量。如果不加入volatile
     * ,基本上会导致这样的结果:要么无法编写多线程程序,要么编译器失去大量优化的机会。
     */
    private volatile boolean stop = false;

    @Override
    public void run() {
        // 判断线程体是否运行
        while (stop) {
            // doSomething
        }
    }

    public void terminate() {
        stop = true;
    }
}

这是很简单的办法,在线程体中判断是否需要停止运行,即可保证线程体的逻辑完整性,而且也不会破坏原子逻辑。可能大家对JavaAPI比较熟悉,于是提出疑问:Thread不是还提供了interrupt中断线程的方法吗?这个方法可不是过时方法,那可以使用吗?它可以终止一个线程吗?

interrupt,名字看上去很像是终止一个线程的方法,但它不能终止一个正在执行着的线程,它只是修改中断标志而已,例如下面一段代码:

public static void main(String[] args) {
        Thread thread = new Thread() {
            @Override
            public void run() {
                // 线程一直运行
                while (true) {
                    System.out.println("Running......");
                }
            }
        };
        // 启动线程
        thread.start();
        // 中断线程
        thread.interrupt();
    }

执行这段代码,你会发现一直有Running在输出,永远不会停止,似乎执行了interrupt没有任何变化,那是因为interrupt方法不能终止一个线程状态,它只会改变中断标志位(如果在thread.interrupt()前后输出thread.isInterrupted()则会发现分别输出了false和true),如果需要终止该线程,还需要自己进行判断,例如我们可以使用interrupt编写出更简洁、安全的终止线程代码:

class SafeStopThread extends Thread {
    @Override
    public void run() {
        //判断线程体是否运行
        while (!isInterrupted()) {
            // do SomeThing
        }
    }
}

总之,如果期望终止一个正在运行的线程,则不能使用已过时的stop方法。需要自行编码实现,如此即可保证原子逻辑不被破坏,代码逻辑不会出现异常。当然,如果我们使用的是线程池(比如ThreadPoolExecutor类),那么可以通过shutdown方法逐步关闭池中的线程,它采用的是比较温和、安全的关闭线程方法,完全不会产生类似stop方法的弊端。

建议121:线程优先级只使用三个等级

线程的优先级(Priority)决定了线程获得CPU运行的机会,优先级越高获得的运行机会越大,优先级越低获得的机会越小。Java的线程有10个级别(准确的说是11个级别,级别为0的线程是JVM的,应用程序不能设置该级别),那是不是说级别是10的线程肯定比级别是9的线程先运行呢?我们来看如下一个多线程类:

class TestThread implements Runnable {
    public void start(int _priority) {
        Thread t = new Thread(this);
        // 设置优先级别
        t.setPriority(_priority);
        t.start();
    }
    @Override
    public void run() {
        // 消耗CPU的计算
        for (int i = 0; i < 100000; i++) {
            Math.hypot(924526789, Math.cos(i));
        }
        // 输出线程优先级
        System.out.println("Priority:" + Thread.currentThread().getPriority());
    }
}

该多线程实现了Runnable接口,实现了run方法,注意在run方法中有一个比较占用CPU的计算,该计算毫无意义,

public static void main(String[] args) {
        //启动20个不同优先级的线程
        for (int i = 0; i < 20; i++) {
            new TestThread().start(i % 10 + 1);
        }
    }

这里创建了20个线程,每个线程在运行时都耗尽了CPU的资源,因为优先级不同,线程调度应该是先处理优先级高的,然后处理优先级低的,也就是先执行2个优先级为10的线程,然后执行2个优先级为9的线程,2个优先级为8的线程......但是结果却并不是这样的。

Priority:5
  Priority:7
  Priority:10
  Priority:6
  Priority:9
  Priority:6
  Priority:5
  Priority:7
  Priority:10
  Priority:3
  Priority:4
  Priority:8
  Priority:8
  Priority:9
  Priority:4
  Priority:1
  Priority:3
  Priority:1
  Priority:2
  Priority:2

println方法虽然有输出损耗,可能会影响到输出结果,但是不管运行多少次,你都会发现两个不争的事实:

(1)、并不是严格按照线程优先级来执行的

比如线程优先级为5的线程比优先级为7的线程先执行,优先级为1的线程比优先级为2的线程先执行,很少出现优先级为2的线程比优先级为10的线程先执行(注意,这里是" 很少 ",是说确实有可能出现,只是几率低,因为优先级只是表示线程获得CPU运行的机会,并不代表强制的排序号)。

(2)、优先级差别越大,运行机会差别越明显

比如优先级为10的线程通常会比优先级为2的线程先执行,但是优先级为6的线程和优先级为5的线程差别就不太明显了,执行多次,你会发现有不同的顺序。

这两个现象是线程优先级的一个重要表现,之所以会出现这种情况,是因为线程运行是需要获得CPU资源的,那谁能决定哪个线程先获得哪个线程后获得呢?这是依照操作系统设置的线程优先级来分配的,也就是说,每个线程要运行,需要操作系统分配优先级和CPU资源,对于JAVA来说,JVM调用操作系统的接口设置优先级,比如windows操作系统优先级都相同吗?

事实上,不同的操作系统线程优先级是不同的,Windows有7个优先级,Linux有140个优先级,Freebsd则由255个(此处指的优先级个数,不同操作系统有不同的分类,如中断级线程,操作系统级等,各个操作系统具体用户可用的线程数量也不相同)。Java是跨平台的系统,需要把这10个优先级映射成不同的操作系统的优先级,于是界定了Java的优先级只是代表抢占CPU的机会大小,优先级越高,抢占CPU的机会越大,被优先执行的可能性越高,优先级相差不大,则抢占CPU的机会差别也不大,这就是导致了优先级为9的线程可能比优先级为10的线程先运行。

Java的缔造者们也觉察到了线程优先问题,于是Thread类中设置了三个优先级,此意就是告诉开发者,建议使用优先级常量,而不是1到10的随机数字。常量代码如下:

public class Thread implements Runnable {
    /**
     * The minimum priority that a thread can have. 
     */
    public final static int MIN_PRIORITY = 1;
    /**
     * The default priority that is assigned to a thread. 
     */
    public final static int NORM_PRIORITY = 5;
    /**
     * The maximum priority that a thread can have. 
     */
    public final static int MAX_PRIORITY = 10;
}

在编码时直接使用这些优先级常量,可以说在大部分情况下MAX_PRIORITY的线程回比MIN_PRIORITY的线程优先运行,但是不能认为是必然会先运行,不能把这个优先级做为核心业务的必然条件,Java无法保证优先级高肯定会先执行,只能保证高优先级有更多的执行机会。因此,建议在开发时只使用此三类优先级,没有必要使用其他7个数字,这样也可以保证在不同的操作系统上优先级的表现基本相同。

大家也许会问,如果优先级相同呢?这很好办,也是由操作系统决定的。基本上是按照FIFO原则(先入先出,First Input First Output),但也是不能完全保证。

建议122:使用线程异常处理器提升系统可靠性

我们要编写一个Socket应用,监听指定端口,实现数据包的接收和发送逻辑,这在早期系统间进行数据交互是经常使用的,这类接口通常需要考虑两个问题:一个是避免线程阻塞,保证接收的数据尽快处理;二是:接口的稳定性和可靠性问题,数据包很复杂,接口服务的系统也很多,一旦守候线程出现异常就会导致Socket停止,这是非常危险的,那我们有什么办法避免吗?

Java1.5版本以后在Thread类中增加了setUncaughtExceptionHandler方法,实现了线程异常的捕捉和处理。可能大家会有一个疑问:如果Socket应用出现了不可预测的异常是否可以自动重启呢?其实使用线程异常处理器很容易解决,我们来看一个异常处理器应用实例,代码如下:

class TcpServer implements Runnable {
    // 创建后即运行
    public TcpServer() {
        Thread t = new Thread(this);
        t.setUncaughtExceptionHandler(new TcpServerExceptionHandler());
        t.start();
    }
    @Override
    public void run() {
        for (int i = 0; i < 3; i++) {
            try {
                Thread.sleep(1000);
                System.out.println("系统正常运行:" + i);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        // 抛出异常
        throw new RuntimeException();
    }

    // 异常处理器
    private static class TcpServerExceptionHandler implements
            Thread.UncaughtExceptionHandler {

        @Override
        public void uncaughtException(Thread t, Throwable e) {
            // 记录线程异常信息
            System.out.println("线程" + t.getName() + " 出现异常,自行重启,请分析原因。");
            e.printStackTrace();
            // 重启线程,保证业务不中断
            new TcpServer();
        }

    }
}

这段代码的逻辑比较简单,在TcpServer类创建时即启动一个线程,提供TCP服务,例如接收和发送文件,具体逻辑在run方法中实现。同时,设置了该线程出现运行期异常(也就是Uncaught Exception)时,由TcpServerExceptionHandler异常处理器来处理异常。那么TcpServerExceptionHandler做什么事呢?两件事:

记录异常信息,以便查找问题
重新启动一个新线程,提供不间断的服务
  有了这两点,TcpServer就可以稳定的运行了,即使出现异常也能自动重启,客户端代码比较简单,只需要new TcpServer()即可,运行结果如下:


异常结果

 从运行结果上可以看出,当Thread-0出现异常时,系统自动重启了Thread-1线程,继续提供服务,大大提高了系统的性能。

这段程序只是一个示例程序,若要在实际环境中应用,则需要注意以下三个方面:

共享资源锁定:如果线程产生异常的原因是资源被锁定,自动重启应用知会增加系统的负担,无法提供不间断服务。例如一个即时通信服务(XMPP Server)出现信息不能写入的情况,即使再怎么启动服务,也是无法解决问题的。在此情况下最好的办法是停止所有的线程,释放资源。
脏数据引起系统逻辑混乱:异常的产生中断了正在执行的业务逻辑,特别是如果正在处理一个原子操作(像即时通讯服务器的用户验证和签到这两个事件应该在一个操作中处理,不允许出现验证成功,但签到不成功的情况),但如果此时抛出了运行期异常就有可能会破坏正常的业务逻辑,例如出现用户认证通过了,但签到不成功的情况,在这种情境下重启应用服务器,虽然可以提供服务,但对部分用户却产生了逻辑异常。
内存溢出:线程异常了,但由该线程创建的对象并不会马上回收,如果再重亲启动新线程,再创建一批对象,特别是加入了场景接管,就非常危险了,例如即时通信服务,重新启动一个新线程必须保证原在线用户的透明性,即用户不会察觉服务重启,在此种情况下,就需要在线程初始化时加载大量对象以保证用户的状态信息,但是如果线程反复重启,很可能会引起OutOfMemory内存泄露问题。

建议123:volatile不能保证数据同步

volatile关键字比较少用,原因无外乎两点,一是在Java1.5之前该关键字在不同的操作系统上有不同的表现,所带来的问题就是移植性较差;而且比较难设计,而且误用较多,这也导致它的"名誉" 受损。

我们知道,每个线程都运行在栈内存中,每个线程都有自己的工作内存(Working Memory,比如寄存器Register、高速缓冲存储器Cache等),线程的计算一般是通过工作内存进行交互的,其示意图如下图所示:


线程读取变量的示意图

从示意图上我们可以看到,线程在初始化时从主内存中加载所需的变量值到工作内存中,然后在线程运行时,如果是读取,则直接从工作内存中读取,若是写入则先写到工作内存中,之后刷新到主内存中,这是JVM的一个简答的内存模型,但是这样的结构在多线程的情况下有可能会出现问题,比如:A线程修改变量的值,也刷新到了主内存,但B、C线程在此时间内读取的还是本线程的工作内存,也就是说它们读取的不是最"新鲜"的值,此时就出现了不同线程持有的公共资源不同步的情况。

对于此类问题有很多解决办法,比如使用synchronized同步代码块,或者使用Lock锁来解决该问题,不过,Java可以使用volatile更简单地解决此类问题,比如在一个变量前加上volatile关键字,可以确保每个线程对本地变量的访问和修改都是直接与内存交互的,而不是与本线程的工作内存交互的,保证每个线程都能获得最"新鲜"的变量值,其示意图如下:


volatile 变量操作示意图

明白了volatile变量的原理,那我们思考一下:volatile变量是否能够保证数据的同步性呢?两个线程同时修改一个volatile是否会产生脏数据呢?我们看看下面代码:

class UnsafeThread implements Runnable {
    // 共享资源
    private volatile int count = 0;

    @Override
    public void run() {
        // 增加CPU的繁忙程度,不必关心其逻辑含义
        for (int i = 0; i < 1000; i++) {
            Math.hypot(Math.pow(92456789, i), Math.cos(i));
        }
        count++;
    }

    public int getCount() {
        return count;
    }
}

上面的代码定义了一个多线程类,run方法的主要逻辑是共享资源count的自加运算,而且我们还为count变量加上了volatile关键字,确保是从内存中读取和写入的,如果有多个线程运行,也就是多个线程执行count变量的自加操作,count变量会产生脏数据吗?想想看,我们已经为count加上了volatile关键字呀!模拟多线程的代码如下:

public static void main(String[] args) throws InterruptedException {
        // 理想值,并作为最大循环次数
        int value = 1000;
        // 循环次数,防止造成无限循环或者死循环
        int loops = 0;
        // 主线程组,用于估计活动线程数
        ThreadGroup tg = Thread.currentThread().getThreadGroup();
        while (loops++ < value) {
            // 共享资源清零
            UnsafeThread ut = new UnsafeThread();
            for (int i = 0; i < value; i++) {
                new Thread(ut).start();
            }
            // 先等15毫秒,等待活动线程为1
            do {
                Thread.sleep(15);
            } while (tg.activeCount() != 1);
            // 检查实际值与理论值是否一致
            if (ut.getCount() != value) {
                // 出现线程不安全的情况
                System.out.println("循环到:" + loops + " 遍,出现线程不安全的情况");
                System.out.println("此时,count= " + ut.getCount());
                System.exit(0);
            }
        }

    }

想让volatite变量"出点丑",还是需要花点功夫的。此段程序的运行逻辑如下:

启动100个线程,修改共享资源count的值
暂停15秒,观察活动线程数是否为1(即只剩下主线程再运行),若不为1,则再等待15秒。
判断共享资源是否是不安全的,即实际值与理想值是否相同,若不相同,则发现目标,此时count的值为脏数据。
如果没有找到,继续循环,直到达到最大循环为止。
运行结果如下:

循环到:40 遍,出现线程不安全的情况
    此时,count= 999
  这只是一种可能的结果,每次执行都有可能产生不同的结果。这也说明我们的count变量没有实现数据同步,在多个线程修改的情况下,count的实际值与理论值产生了偏差,直接说明了volatile关键字并不能保证线程的安全。
  在解释原因之前,我们先说一下自加操作。count++表示的是先取出count的值然后再加1,也就是count=count+1,所以,在某个紧邻时间片段内会发生如下神奇的事情:

(1)、第一个时间片段

A线程获得执行机会,因为有关键字volatile修饰,所以它从主内存中获得count的最新值为998,接下来的事情又分为两种类型:

如果是单CPU,此时调度器暂停A线程执行,让出执行机会给B线程,于是B线程也获得了count的最新值998.
如果是多CPU,此时线程A继续执行,而线程B也同时获得了count的最新值998.
(2)、第二个片段

如果是单CPU,B线程执行完+1操作(这是一个原子处理),count的值为999,由于是volatile类型的变量,所以直接写入主内存,然后A线程继续执行,计算的结果也是999,重新写入主内存中。
如果是多CPU,A线程执行完加1动作后修改主内存的变量count为999,线程B执行完毕后也修改主内存中的变量为999
这两个时间片段执行完毕后,原本期望的结果为1000,单运行后的值为999,这表示出现了线程不安全的情况。这也是我们要说明的:volatile关键字并不能保证线程安全,它只能保证当前线程需要该变量的值时能够获得最新的值,而不能保证线程修改的安全性。

顺便说一下,在上面的代码中,UnsafeThread类的消耗CPU计算是必须的,其目的是加重线程的负荷,以便出现单个线程抢占整个CPU资源的情景,否则很难模拟出volatile线程不安全的情况,大家可以自行模拟测试。

建议124:异步运算考虑使用Callable接口

多线程应用有两种实现方式,一种是实现Runnable接口,另一种是继承Thread类,这两个方法都有缺点:run方法没有返回值,不能抛出异常(这两个缺点归根到底是Runnable接口的缺陷,Thread类也实现了Runnable接口),如果需要知道一个线程的运行结果就需要用户自行设计,线程类本身也不能提供返回值和异常。但是从Java1.5开始引入了一个新的接口Callable,它类似于Runnable接口,实现它就可以实现多线程任务,Callable的接口定义如下:

public interface Callable<V> {
    /**
     * Computes a result, or throws an exception if unable to do so.
     *
     * @return computed result
     * @throws Exception if unable to compute a result
     */
    V call() throws Exception;
}

实现Callable接口的类,只是表明它是一个可调用的任务,并不表示它具有多线程运算能力,还是需要执行器来执行的,我们先编写一个任务类,代码如下:

//税款计算器
class TaxCalculator implements Callable<Integer> {
    // 本金
    private int seedMoney;

    // 接收主线程提供的参数
    public TaxCalculator(int _seedMoney) {
        seedMoney = _seedMoney;
    }

    @Override
    public Integer call() throws Exception {
        // 复杂计算,运行一次需要2秒
        TimeUnit.MILLISECONDS.sleep(2000);
        return seedMoney / 10;
    }
}

这里模拟了一个复杂运算:税款计算器,该运算可能要花费10秒钟的时间,此时不能让用户一直等着吧,需要给用户输出点什么,让用户知道系统还在运行,这也是系统友好性的体现:用户输入即有输出,若耗时较长,则显示运算进度。如果我们直接计算,就只有一个main线程,是不可能有友好提示的,如果税金不计算完毕,也不会执行后续动作,所以此时最好的办法就是重启一个线程来运算,让main线程做进度提示,代码如下:

public static void main(String[] args) throws InterruptedException,
            ExecutionException {
        // 生成一个单线程的异步执行器
        ExecutorService es = Executors.newSingleThreadExecutor();
        // 线程执行后的期望值
        Future<Integer> future = es.submit(new TaxCalculator(100));
        while (!future.isDone()) {
            // 还没有运算完成,等待200毫秒
            TimeUnit.MICROSECONDS.sleep(200);
            // 输出进度符号
            System.out.print("*");
        }
        System.out.println("\n计算完成,税金是:" + future.get() + "  元 ");
        es.shutdown();
    }

在这段代码中,Executors是一个静态工具类,提供了异步执行器的创建能力,如单线程异步执行器newSingleThreadExecutor、固定线程数量的执行器newFixedThreadPool等,一般它是异步计算的入口类。future关注的是线程执行后的结果,比如没有运行完毕,执行结果是多少等。此段代码的运行结果如下所示:

**********************************************......

计算完成,税金是:10 元

执行时,"*"会依次递增,表示系统正在运算,为用户提供了运算进度,此类异步计算的好处是:

尽可能多的占用系统资源,提供快速运算
可以监控线程的执行情况,比如是否执行完毕、是否有返回值、是否有异常等。
可以为用户提供更好的支持,比如例子中的运算进度等。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,080评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,422评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,630评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,554评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,662评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,856评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,014评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,752评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,212评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,541评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,687评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,347评论 4 331
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,973评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,777评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,006评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,406评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,576评论 2 349

推荐阅读更多精彩内容