Effective Java读书笔记十八(Java Tips.Day.18)(长文预警)

TIP 49 基本类型优于装箱基本类型

Java类型系统由两部分组成:基本类型和引用类型。每个基本类型都有一个对应的引用类型,称作装箱基本类型。

基本类型与装箱基本类型之间的区别:

  1. 基本类型只有值,而装箱基本类型则具有与他们的值不同的同一性。比如,两个装箱基本类型的值可以相同,而同一性不同,使用 == 表达式会返回false。
  2. 基本类型只有功能完备的值,而装箱基本类型还有个非功能值:null。
  3. 基本类型通常比装箱基本类型更节省时间和空间

基于以上三点区别,在使用装箱基本类型时必须谨慎,否则会有麻烦:

public class Tip49 {
    Comparator<Integer> naturalOrder = new Comparator<Integer>() {
        @Override
        public int compare(Integer o1, Integer o2) {
            return o1 < o2 ? -1 : (o1 == o2  ? 0 :1);
        }
    };
}

这个比较器看起来没有问题,但还没等运行,编译器已经在警告我了

Number objects are compared using ‘==’ , not ‘equals’

尝试运行下面的代码:

public static void main(String args[]) {
    Integer num1 = new Integer(1);
    Integer num2 = new Integer(1);
    System.out.println(num1 == num2);  //print false
    System.out.println(num1.equals(num2)); // print true
}

显然,使用 == 来比较两个装箱基本类型显然得到了错误的结果,或者说,这个结果不是我们想要的。

在上面的比较器代码中, o1 < o2 会导致o1 和 o2这两个引用被自动拆箱 , 也就是说,比较运算采用了它们的的基本类型值 , 表达式会检查第一个int值是否小于第二个的int值。

而在 o1 == o2 中,表达式执行的是同一性比较,如果它们是相同实例的引用,才会返回true,否则总会返回false。这个计算过程中,并没有发生自动拆箱的过程。

因此,上面的比较器有时会产生错误的结果。对装箱基本类型使用 == 几乎总是错误的。
来看看修正的比较器:

Comparator<Integer> naturalOrderV2 = new Comparator<Integer>() {
    @Override
    public int compare(Integer o1, Integer o2) {
        int n1 = o1;
        int n2 = o2;
        return n1 < n2 ? -1 : (n1 == n2  ? 0 :1);
    }
};

如果在一项操作中混合使用基本类型和装箱类型时,装箱类型就必然会自动拆箱。

所以运行 System.out.println(num1 == 1); 会得到正确的结果。

另外,如果null对象引用被自动拆箱,会得到一个NullPointException。所以对装箱基本类型的使用,一定要初始化。

自动装箱和拆箱是有额外的性能开销的,因为相比基本类型,需要额外创建对象。看看下面的程序:

    public static void testBoxSum(){
        Long sum = 0L;
        for (int i = 0; i < Integer.MAX_VALUE; i++) {
            sum += i;
        }
        System.out.println("sum:"+sum);
    }

它不小心将sum声明为Long,所以在循环中,会不断的发生装箱-拆箱,非常影响效率。我们可以简单的测试一下运行时间:

public class Test2 {
    public static void main(String args[]) {

        long time1 = System.currentTimeMillis();
        testBoxSum();
        long time2 = System.currentTimeMillis();
        testSum();
        long time3 = System.currentTimeMillis();

        System.out.println("testBoxSum cost:"+(time2-time1));
        System.out.println("testSum    cost:"+(time3-time2));

    }

    public static void testBoxSum(){
        Long sum = 0L;
        for (int i = 0; i < Integer.MAX_VALUE; i++) {
            sum += i;
        }
        System.out.println("sum:"+sum);
    }
    public static void testSum(){
        long sum = 0L;
        for (int i = 0; i < Integer.MAX_VALUE; i++) {
            sum += i;
        }
        System.out.println("sum:"+sum);
    }
}

运行之:

sum:2305843005992468481
sum:2305843005992468481
testBoxSum cost:6682
testSum cost:708

运算结果当然一样,然而,性能差了好几倍。testBoxSum 耗时6682毫秒,而testSum则耗时708。

程序没有任何错误和警告,但是性能差别如此明显。


在上面的例子中,程序员都忽略了基本类型和装箱基本类型之间的区别,尝到了苦头。在前两个例子中,程序发生了错误,在第三个程序中,则有严重的性能问题。

那么什么时候应该使用装箱基本类型呢?

  1. 作为集合中的元素、键、值。基本类型无法胜任这些需求。
  2. 接上条,泛型参数都无法使用基本类型。
  3. 在进行反射的方法调用时,必须使用装箱基本类型。

总之,在大部分场合,基本类型总是优先于装箱基本类型。


TIP 50 如果其它类型更合适,则尽量避免使用字符串

  • 字符串不适合代替其它的值类型,比如数值、布尔类型。
  • 字符串不适合代替枚举类型。可以参考TIP 30,枚举类型更适合用来表示一系列相关的常量。
  • 字符串不适合代替聚集类型。如果一个实体有多个组件,用一个字符串来表示这个实体通常是不恰当的。此时应当编写一个类来描述这个数据集,通常是一个私有的静态类。
  • 字符串不适合代替能力表。

下面说一下最后一个小条目的案例。
长文预警
长文预警
长文预警




在多线程编程中,线程的成员变量可能会被意外改变:

public class UnsafeTask implements Runnable {

    private String startDate;

    @Override
    public void run() {
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        startDate = sdf.format(new Date());
        System.out.println("开始线程:" + Thread.currentThread().getId() + ",开始的时间:" + startDate);
        try {
            TimeUnit.SECONDS.sleep((int) Math.rint(Math.random() * 10));
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("结束线程:" + Thread.currentThread().getId() + ",开始的时间:" + startDate);

    }
} 

public class Core {  

    public static void main(String[] args) {  
        UnsafeTask task=new UnsafeTask();  
        for(int i=0;i<10;i++){  
            Thread thread=new Thread(task);  
            thread.start();  
            try {  
                TimeUnit.SECONDS.sleep(2);  
            } catch (InterruptedException e) {  
                e.printStackTrace();  
            }  
        }  

    }
}  

显然,每隔两秒就会启动一个新的任务,直到循环结束,每个任务会在随机一段时间后结束,并打印它们的开始时间,下面是运行结果:

开始线程:11,开始的时间:2017-05-20 23:52:27
开始线程:12,开始的时间:2017-05-20 23:52:29
开始线程:13,开始的时间:2017-05-20 23:52:31
开始线程:14,开始的时间:2017-05-20 23:52:33
结束线程:13,开始的时间:2017-05-20 23:52:33
结束线程:14,开始的时间:2017-05-20 23:52:33
开始线程:15,开始的时间:2017-05-20 23:52:35
结束线程:11,开始的时间:2017-05-20 23:52:35
结束线程:12,开始的时间:2017-05-20 23:52:35
开始线程:16,开始的时间:2017-05-20 23:52:37
开始线程:17,开始的时间:2017-05-20 23:52:39
开始线程:18,开始的时间:2017-05-20 23:52:41
结束线程:18,开始的时间:2017-05-20 23:52:41
结束线程:15,开始的时间:2017-05-20 23:52:41
开始线程:19,开始的时间:2017-05-20 23:52:43
开始线程:20,开始的时间:2017-05-20 23:52:45
结束线程:16,开始的时间:2017-05-20 23:52:45
结束线程:17,开始的时间:2017-05-20 23:52:45
结束线程:20,开始的时间:2017-05-20 23:52:45
结束线程:19,开始的时间:2017-05-20 23:52:45

发现问题了吗?任务19的开始时间本来是 23:52:43 ,然而在任务结束时,打印的开始时间是23:52:45,显然,startDate 被其它线程意外篡改了。

自从Java 1.2之后,Java类库就有提供线程局部变量的机制。但在此之前,有的程序员已经提出同样的方案:

public class OldThreadLocal {
    private OldThreadLocal(){};
    private static  Map<String ,Object> localValues = new HashMap<>();

    public static void set(String key,Object value){
        localValues.put(key,value);
    };
    public static Object get(String key){
        return localValues.get(key);
    };
}
public class OldTask implements Runnable{
    private String startDate;

    @Override
    public void run() {
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        startDate = sdf.format(new Date());
        String threadId = String.valueOf(Thread.currentThread().getId());
        System.out.println("开始线程:" + threadId + ",开始的时间:" + startDate);
        OldThreadLocal.set(threadId,startDate);
        try {
            TimeUnit.SECONDS.sleep((int) Math.rint(Math.random() * 10));
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        startDate = (String) OldThreadLocal.get(threadId);
        System.out.println("结束线程:" + threadId + ",开始的时间:" +startDate);

    }
}

运行Core.main,结果显示上述的问题已被解决。

这个OldThreadLocal的问题在于,每个线程提供的String键必须是唯一的。如果两个线程各自决定为它们的局部变量使用同样的名称,它们实际上就无意中共享了变量——-localValues是个全局的map,使用相同的String键,必然会得到相同的值。

要修正这个问题并不难,只要用一个不可伪造的键(unforgettable key,也被成为能力表 capability) 来代替String即可:

public class OThreadLocal {
    private OThreadLocal(){};
    private static Map<Object ,Object> localValues = new HashMap<>();
    public static class Key{
        Key(){};
    }
    public static Key getKey(){
        return new Key();
    }
    public static void set(Key key,Object value){
        localValues.put(key,value);
    }
    public static Object get(Key key){
        return localValues.get(key);
    }
}

在每次需要set时,先调用getKey方法来创建新的Key对象。这样当多个线程需要set时,就永远不会发生String键雷同的错误。


实际上,这个类还可以做的更好。你可以不需要静态方法,然后在Key静态类中的实例方法来替代。这样这个键就不再是键,而是线程局部变量了。此时,这个顶层类OThreadLocal也不再做任何实质性的工作,因此可以删除OThreadLocal,直接将Key类命名为NThreadLocal:

public final class NThreadLocal {
    public NThreadLocal( ){
    };
    private Map<Object ,Object> valueMap = Collections.synchronizedMap(new HashMap<>());
    public void set(Object value){
        valueMap.put(Thread.currentThread(), value);
    }
    public Object get(){
        Thread currentThread = Thread.currentThread();
        Object o = valueMap.get(currentThread);
        if (o == null && !valueMap.containsKey(currentThread)) {
            o = initialValue();
            valueMap.put(currentThread, o);

        }
        return o;
    }
    public void remove() {
        valueMap.remove(Thread.currentThread());
    }
    public Object initialValue() {
        return null;
    }
}

//新的Task类
public class NTask implements Runnable{
    private NThreadLocal nThreadLocal = new NThreadLocal();
    private String startDate;

    @Override
    public void run() {
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        startDate = sdf.format(new Date());
        String threadId = String.valueOf(Thread.currentThread().getId());
        System.out.println("开始线程:" + threadId + ",开始的时间:" + startDate);
        nThreadLocal.set(startDate);
        try {
            TimeUnit.SECONDS.sleep((int) Math.rint(Math.random() * 10));
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        startDate = (String) nThreadLocal.get();
        System.out.println("结束线程:" + threadId + ",开始的时间:" + startDate);
    }
}

但仍有问题

在获取局部变量时, startDate = (String) OldThreadLocal.get(threadId); 这句代码就意味着类型不安全。参考TIP 26 ,我们可以把它泛型化:

public final class MyThreadLocal<T> {

    private Map<Thread ,T> valueMap = Collections.synchronizedMap(new HashMap<>());
    public void set(T value){
        valueMap.put(Thread.currentThread(), value);
    }
    public T get(){
        Thread currentThread = Thread.currentThread();
        T o = valueMap.get(currentThread);
        if (o == null && !valueMap.containsKey(currentThread)) {
            o = initialValue();
            valueMap.put(currentThread, o);

        }

        return o;
    }
    public void remove() {
        valueMap.remove(Thread.currentThread());
    }
    public T initialValue() {
        return null;
    }
}

事实上,粗略的说,这已经接近了java.util.ThreadLocal提供的API了。与前两个版本相比,它更优雅,更快速,同时也不会有String键的问题。


总之,如果可以使用更加合适的数据类型,或者可以编写更加适当的数据类型,就应该避免使用字符串来表示对象。

若使用不当,字符串相比其它对象,更加笨拙,更不灵活,速度更慢,也更容易出错。经常被错误地用字符串来表示的类型包括:基本类型、枚举类型和聚集类型。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值