标签归档:java基础

Java Unchecked Exception

Java中java.lang.Throwable类是一切异常的基类,它有两种类型(子类):Error类和Exception类。Error类通常表示系统级别异常,如IOError,OutOfMemeryError等。Exception类则表示应用级别中出现的异常,如IOException,NullPointerException等。这种分类是简单的按异常来源进行划分的。

然而,要知道Java支持异常机制的根本原因是为了提高程序的健壮性,因为程序受运行环境等因素影响,在运行过程中出现异常不可避免,如果编程人员能事先采取措施,当异常情况出现时让程序能继续运行下去,或者能从异常中恢复,那么对软件的鲁棒性提升很大。所以,关于异常一个更好的分类方式是分为检查型异常(Checked Exception)和非检查型异常(Unchecked Exception)。

所谓检查型异常,就是编译器要求此类异常必需被try…catch…语句包围进行异常处理,它所隐含的意思是编程人员必需知道此次方法调用过程中可能出现的异常,并提前对其进行处理,所以一般检查型异常用在一些能使程序从异常中恢复的场景,比如IOException,可能重试就能解决问题。检查型异常一般都继承自java.lang.Exception,当然Exception本身也是检查型异常。另一类是非检查型异常,这类异常的使用场景是程序无法从该异常中恢复,前文提到的Error类及其子类都属于此类异常,因为出现系统(底层)级别的错误后,程序不大可能还能恢复正常,例如OutOfMemeryError堆内存满。还有一类非检查型异常是RuntimeException类及其子类,它们所代表的是应用级别中出现的无法恢复的运行时异常,例如NullPointerException空指针异常,ArrayIndexOutOfBoundsException数组越界异常等。对应到Java的异常体系中,两种异常分别为:

1. 非检查型异常:java.lang.Error类及其子类,java.lang.RuntimeException类及其子类。
2. 检查型异常:java.lang.Exception类及其所有除java.lang.RuntimeException类(包括其子类)外的所有子类。

从编程角度来看,两者的最大区别是非检查型异常无需进行try…catch…语句包围,而检查型异常一定需要try…catch…语句包围。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public void unchecked() throws RuntimeException {
}
 
public void checked() throws Exception {
}
 
public void test() {
    unchecked();
    try {
        checked();
    } catch (Exception e) {
    }
 
}

通常来说,检查型异常和非检查型异常的使用场景为:如果经过处理后程序能从异常中恢复,那么使用检查型异常,给编程人员一个机会进行异常处理;否则,使用非检查型异常。

这里还有两个新手存疑的问题:

1. 为什么Java编译器只要求检查型异常有try…catch…语句包围处理,而对非检查型异常则无此约束?
这是从程序的优雅性上考虑的,Java程序中充满了各种各样潜在的非检查型异常,如果都要用try…catch…语句处理异常,那么写出来的程序是庞大而冗余的。要知道即使连最简单的除法程序:

1
2
3
int foo(Integer a, Integer b) {
    return a / b;
}

可能也会存在NullPointerException,ArithmeticException,以及其他潜在的系统级Error异常。

当然,也不能为了使程序简洁而偷懒将一些本该是抛出检查型异常的方法改为抛出非检查型异常,这种编程行为完全浪费了Java设计人员的心血,影响程序健壮性。

另外,编译器虽然没有强制要求程序中捕捉非检查型异常,但是有时候对非检查型异常进行捕捉也有好处,例如Web项目中对数据库驱动抛出的SQLException进行捕捉能避免用户看到恼人的异常堆栈信息。

2. 是否所有异常都能捕捉?
可以。前文说过,java.lang.Throwable类是Java中所有异常的基类,要想程序万无一失(通常无此必要),只需捕捉Throwable异常即可:

1
2
3
4
5
6
void bar() {
    try {
        doAnyThing();
    } catch (Throwable t) {
  }
}

--EOF--

Java自动装箱/拆箱中的坑

Java的装箱/拆箱发生在包装类与其对应的基本类型之间,比如:

1
2
3
int int1 = 1;
Integer integer2 = new Integer(int1); //装箱
int int3 = integer2.intValue(); //拆箱

自JavaSE 5.0之后, Java支持了自动装箱/拆箱,所谓自动装箱/拆箱是指:

1
2
3
int int1 = 1;
Integer integer2 = int1; //自动装箱
int int3 = integer2;  //自动拆箱

以上来自维基。要说明的是,自动装箱/拆箱中的“自动”是编译器帮忙做的,以Integer类和int类型的转化为例,编译器会在int类型转Integer包装类时调用Integer的构造函数来进行实例化,会在Integer包装类转int类型时调用intValue()方法进行拆箱。

当我开始学习Java的时候早已是Java6的时代了,没有经历过装箱/拆箱的变迁,对自动装箱/拆箱的理解停留在理论上,所以实际中踩了坑也就不足为怪了。以下是我亲身经历的两次踩坑过程:

1. if(!null)表达式。
有次我写了段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public Boolean check(){
    try{
        if(...){
            return true;
        }else{
            return false;
        }
    catch(Exception e){
    }
    return null;
}
 
public void doSth(){
    if(!check()){
        logger.error("error");
    }else{
         ...
    }
}

正常流程下check()方法返回true和false,但是偏偏我又想处理出现异常的情况,所以机智地在程序异常时返回null,试图用一个Boolean包装类来表示true,false和null三种状态。可能受C语言系语法影响吧,在外层doSth()方法的if语句中写下了!check()表达式,结果某天程序抛出一个运行时异常NullPointerException。因为编译器的自动拆箱机制,if语句处理后的代码变成if(!check().booleanValue())。如果check()方法返回null,if语句就会执行!null.booleanValue(),对一个null值调用任何方法显然都会得到一个NPE。

2. 包装类与基本类型进行关系运算。
不久前我大概写了以下代码:

1
2
3
4
5
6
JSONObject json = getHttpResponse(); // HTTP response text。
if (json.getInteger("code") != 112) {
    // error
    return;
}
...

getHttpResponse()方法调用一个HTTP restful接口,返回值是个JSONObject类型(fastjson),如果返回的json中有code属性,并且code值为112,则表示出现错误,否则,可以认为此次接口调用成功。实际上,上述程序在接口返回正确的情况下根本执行不下去,if语句中会抛出NullPointerException运行时异常。原因也是与编译器的自动拆箱有关,编译器拆箱后的if语句变为if (json.getInteger("code").intValue() != 112),显然,当HTTP接口返回值中不包含code属性时,json.getInteger("code")返回null,对null值调用intValue()会抛NPE异常。所以正确的做法是先判断json.getInteger("code")的返回值是否为null,如果不为null再去与112进行比较。

再细究下去,Java装箱/拆箱中还有一个坑,如果不知觉中写出了以下代码,那么实际上已经掉坑里了:

1
2
3
4
5
6
7
Integer i1 = getInteger1();
Integer i2 = getInteger2();
if(i1 == i2){
    System.out.println("equals");
}else{
    System.out.println("not equals");
}

其一,当==运算符的两个操作数都是对象时,它比较的是两个对象在内存中的地址。通常来说,两个对象的地址不会相同,所以要比较两个对象的值需要调用equals()方法。上述程序中编译器不会调用intValue()方法将i1和i2分别拆箱后再进行比较。其二,Java对包装类进行过一定优化,默认情况下,值为[-128,128)之间的Integer对象会缓存在内存中,也就是说,每个[-128,128)之间的Integer对象,如果它们的值相同,那么它们都是同一个对象(地址相同)。所以,上述程序中打印equals还是not equals完全视getInteger1()和getInteger2()方法的返回值而定。

--EOF--

基类构造方法的异常处理问题

『Thinking In Java(Third Edition)』中有个习题,要求证明派生类的构造方法不能捕获它的基类构造方法所抛出的异常。咋一看有点糊涂,其实原因很简单,也很有趣。

要解释原因,先要了解Java编译器要求派生类的构造方法中构造基类的super()语句必需放在其他语句前面,因为派生类继承了基类的属性和方法,如果基类的属性没有初始化过,则派生类中无法使用,所以要求super()方法必需最先执行。回到基类构造方法的异常处理问题,如果派生类的构造方法试着捕获它的基类构造方法抛出的异常,必然会有代码块在super()方法之前执行,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class FooException extends Exception {}
class BarException extends Exception {}
 
class Father {
    Father() throws FooException {
        ... ...
    }
}
 
class Son extends Father {
    public Son() throws BarException {
        try { // complie error
            super();
        } catch (FooException e) {}
        init();
    }
 
    public void init() throws BarException {
        ... ...
    }
}
 
public class Test {
    public static void main(String[] args) {
        try {
            Son s = new Son();
        } catch (BarException e) {
            System.out.println("BarException caught");
        }
    }
}

上面程序可以进行如下修正:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Son extends Father {
    public Son() throws FooException, BarException {
        super();
        init();
    }
 
    public void init() throws BarException {
        ... ...
    }
}
 
public class Test {
    public static void main(String[] args) {
        try {
            Son s = new Son();
        } catch (BarException e) {
            System.out.println("BarException caught");
        } catch (FooException e) {
            System.out.println("FooException caught");
        }
    }
}

如果基类构造方法有异常抛出,那么子类的构造方法只能声明基类构造方法声明的异常,而不能将其捕获。此外,子类构造方法还可以声明任何其他异常,不受基类构造方法声明的异常的限制,如上例中的FooException和BarException之间并无继承关系,派生类构造方法可以抛出超出基类构造方法异常范围的异常。

--EOF--

为什么要覆盖hashCode()?

hashCode()是Object类自带的11个方法之一,当需要将一个对象存进HashMap的时候,特别是当这个对象已经重写了equals()方法时,就需要在对象中重写hashCode()方法,否则,很容易造成严重问题。

现在考虑最简单的情况,假如要将没有重写equals()和hashCode()方法的对象加入一个HashMap中。由于Object类中equals()方法默认采用"=="操作符来比对两个对象是否相同,而"=="操作符是用于比较两个对象的内存地址是否相同的,因此,默认情况下,任何对象都是不同的,因为new出它们的时候,JVM在堆中会为它们分配不同的内存地址。因此,对于下面这样一个Car类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//Program 1
public class Car {
    private String name;
 
    public Car(String name) {
        this.name = name;
    }
 
    public static void main(String[] args) {
        Car c1 = new Car("Benz");
        Car c2 = new Car("Benz");
 
        Map<Car, Integer> m = new HashMap<Car, Integer>();
        m.put(c1, 1);
        m.put(c2, 1);
        System.out.println(m.size()); //print 2
    }
}

上述程序中,虽然c1和c2拥有相同的name值(Benz),但对JVM来讲,它们都是不同的对象,如果要加入到HashMap里,它们会被当成不同的对象哈希到不同的位置,所以line16输出map的size是2。

现在假如Car重写了equals()方法,所有相同name值的对象都表示同一个对象,这个时候如果不同时重写Car类的hashCode()方法,就会出现如下现象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//Program 2
public class Car {
    private final String name;
 
    public Car(String name) {
        this.name = name;
    }
 
    @Override
    public boolean equals(Object o) {
        Car c = (Car) o;
        return this.name.equals(c.name);
    }
 
    public static void main(String[] args) {
        Car c1 = new Car("Benz");
        Car c2 = new Car("Benz");
 
        Map<Car, Integer> m = new HashMap<Car, Integer>();
        m.put(c1, 1);
        System.out.println(m.get(c2)); //print null
    }
}

按理说,覆盖equals()方法后,c1和c2表示的时同一个对象,那么往HashMap中put了c1后,理应通过get(c2)能返回c1的value值1。但实际上line21输出的是null,表示HashMap中不存在key为c2的对象。这样就在程序中埋下了一个bug:两个相同的对象A和B,把A放进HashMap,用B去取取不出来。要修正这样的错误只需在覆盖Car类equals()对象的同时再覆盖hashCode()方法,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
//Program 3
public class Car {
    private final String name;
 
    public Car(String name) {
        this.name = name;
    }
 
    @Override
    public boolean equals(Object o) {
        Car c = (Car) o;
        return this.name.equals(c.name);
    }
 
    @Override
    public int hashCode() {
        return name.hashCode();
    }
 
    public static void main(String[] args) {
        Car c1 = new Car("Benz");
        Car c2 = new Car("Benz");
 
        Map<Car, Integer> m = new HashMap<Car, Integer>();
        m.put(c1, 1);
        System.out.println(m.get(c2)); //print 1
    }
}

这个时候line26就能输出正确值1了。

要理解出现上述现象的原因需了解HashMap的实现原理,HashMap put()和get()的实现中,要调用对象hashCode()得到的int值作为间接索引,定位到该对象在数组中所在的下标位置。因为HashMap采用的是拉链法解决哈希冲突,所以当两个对象拥有相同的hashCode()值时就表示它们冲突了,然后HashMap调用当前对象的equals()方法与链表中的所有对象进行判定,如果equals()返回true,表示该对象已存在,如果当前是put操作就更新其value值,如果是get操作就返回其value值。如果遍历完冲突链表上挂着的所有对象,发现equals()均返回false,表示该对象不存在,如果当前是put操作就将该key-value键值对挂在链表上,如果是get操作就返回null。

再分析Program 2出现的bug,因为Car只是重写了equals()而没有重写hashCode(),于是HashMap在get(c2)时根本没有将c2定位到c1所在的冲突链表的位置中去,所以,根据c2默认hashCode()值找到的冲突链表中自然找不到相应的key和value,所以返回null。

Thinking In Java: Third Edition』关于hashCode()的覆盖原则有个总结[1]:
1、无论何时,对同一个对象调用hashCode()都应该生成同样的值。(这里"同一个对象"是指用equals()判定返回为true的对象)
2、不应该使hashCode()依赖于具有唯一性的对象信息。(默认hashCode()方法就是依赖于具有唯一性特点的内存地址)

此外,设计一个好的hashCode()函数是程序员的责任。hashCode()函数设计的好坏会对HashMap的性能产生重大影响。一个好的hashCode()方法应该能产生分布均匀的哈希值。举一个极端的例子:

1
2
3
public int hashCode() {
    return 1;
}

这个hashCode()方法表示,对于任何对象,其哈希值总为1,这就导致所有put进HashMap的Car对象都会被哈希到数组中的同一个位置,挂在同一个冲突链表上,虽然这样的设计不会出现如Program 2这样的bug,但是这确实是一个最糟糕的设计,它会造成HashMap退化为一个LinkedList(不是特别准确,因为LinkedList底层采用双向链表,而HashMap退化后是个单向链表),其put和get操作的时间复杂度降为O(n),完全失去了HashMap本来的作用。

Reference:
[1] Bruce Eckel. Java编程思想(第3版). 机械工业出版社. 2005.5. P353-356.

--EOF--

生产者-消费者模型(In Java)

笔面试Java开发岗位时常被问到生产者-消费者模型的问题,现来个简单的实现,加深记忆。

生产者-消费者模型(wiki:Producer-consumer problem)是个经典的多线程同步问题。它可抽象为三个部分:生产者、消费者和缓冲区。作为生产者,主要职责就是生产资源,将资源放入缓冲区,当缓冲区为满时,停止生产。作为消费者,主要职责就是消费资源,当缓冲区不为空时,取出缓冲区中的资源,如果为空,则停止消费。生产者和消费者进程(线程)可以并发运行,这就要求缓冲区必须保证被同步访问,否则会有系列非线程安全问题。

本文实现的生产者-消费者模型由5个部分组成:
1、资源类(Order.class)
2、缓冲区类(Queue.class)
3、生产者类(Producer.class)
4、消费者类(Consumer.class)
5、主线程类(ListSynTest.class)

1、资源类(Order.class)
资源类主要用于标识一个资源类型,这里假设是一个订单类型,该订单只包含一个id号。

1
2
3
4
5
6
7
8
9
10
11
12
class Order {
    private static int id = 0;
 
    public Order() {
        id++;
    }
 
    @Override
    public String toString() {
        return "" + id;
    }
}

2、缓冲区类(Queue.class)
缓冲区使用一个LinkedList来模拟,生产资源用addLast(),消费资源用removeFirst()。缓冲区类的所有方法都要加synchronized关键字进行同步,必须保证对LinkedList实例变量的同步存取。

生产者可以调用prodece方法生产资源,如果当前缓冲区容量小于最大缓冲区容量(MAX_QUEUE_SIZE),则将当前资源放入缓冲区,并且发出notifyAll()的通知,假如有消费者线程在等待池中,可以唤醒它们进行消费。如果当前缓冲区容量已经到达了最大值,即表示生产者不能再生产资源了,那么它只好将自己阻塞。它要想重新进行生产,需等到消费者消费掉资源后发出notifyAll通知才可。

消费者可以调用consume方法进行资源消费,如果当前缓冲区中容量不为0,表示有资源可以消费,消费完一个资源后,可以发出notifyAll通知,告诉等待池中的生产者(如果有)可以开始生产。如果当前缓冲区容量为0,表示已经没有资源可以消费,那么消费者线程只能调用wait阻塞自己,等待生产者生产资源后发出notifyAll通知进行唤醒。程序代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
class Queue {
    /* 缓冲区 */
    private final LinkedList<Order> list           = new LinkedList<Order>();
 
    /* 缓冲区长度 */
    private final static int        MAX_QUEUE_SIZE = 5;
 
    /* 生产资源 */
    public synchronized void produce(Order o) {
        if (list.size() < MAX_QUEUE_SIZE) {
            list.addLast(o);
            System.out.println(Thread.currentThread().getName() 
                        + " 生产第" + o + "个订单!");
            this.notifyAll();
            for (int i = 0; i < 20000000; i++)
                ; //延时程序,使得结果明显一点。
        } else {
            try {
                System.out.println("生产队列满!等待消费。。。");
                this.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
 
    /* 消费资源 */
    public synchronized void consume() {
        if (list.size() > 0) {
            Order o = list.removeFirst();
            System.out.println(Thread.currentThread().getName() 
                        + " 消费第" + o + "个订单!");
            this.notifyAll();
            for (int i = 0; i < 20000000; i++)
                ; //延时程序,使得结果明显一点。
        } else {
            try {
                System.out.println("消费队列空!等待生产。。。");
                this.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

3、生产者类(Producer.class)
生产者线程持有缓冲区的引用,它的任务就是不停地生产资源,然后将资源放入缓冲区(Queue)中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Producer extends Thread {
    Queue queue;
 
    Producer(Queue queue) {
        this.queue = queue;
    }
 
    @Override
    public void run() {
        while (true) {
            Order order = new Order();
            queue.produce(order);
        }
    }
}

4、消费者类(Consumer.class)
消费者线程也持有缓冲区的引用,它的任务就是不停地消费资源。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Consumer extends Thread {
    Queue queue;
 
    Consumer(Queue queue) {
        this.queue = queue;
    }
 
    @Override
    public void run() {
        while (true) {
            queue.consume();
        }
    }
}

5、主线程类(ListSynTest.class)
主线程负责缓冲区类的初始化,生产者线程消费者线程的初始化和启动工作。

1
2
3
4
5
6
7
8
public class ListSynTest {
 
    public static void main(String[] args) {
        Queue Q = new Queue();
        new Producer(Q).start();
        new Consumer(Q).start();
    }
}

生产者-消费者的实现方式有多种,比如可以对消费者加锁,当生产者生产了一个资源以后唤醒消费者进行处理,详见『Thinking in Java: 3rd Edition』相关章节。本文只是其中一种实现方式,通过对缓冲区进行同步来实现生产者-消费者模型。

--EOF--