TIP 49 基本类型优于装箱基本类型
Java类型系统由两部分组成:基本类型和引用类型。每个基本类型都有一个对应的引用类型,称作装箱基本类型。
基本类型与装箱基本类型之间的区别:
- 基本类型只有值,而装箱基本类型则具有与他们的值不同的同一性。比如,两个装箱基本类型的值可以相同,而同一性不同,使用 == 表达式会返回false。
- 基本类型只有功能完备的值,而装箱基本类型还有个非功能值:null。
- 基本类型通常比装箱基本类型更节省时间和空间
基于以上三点区别,在使用装箱基本类型时必须谨慎,否则会有麻烦:
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。
程序没有任何错误和警告,但是性能差别如此明显。
在上面的例子中,程序员都忽略了基本类型和装箱基本类型之间的区别,尝到了苦头。在前两个例子中,程序发生了错误,在第三个程序中,则有严重的性能问题。
那么什么时候应该使用装箱基本类型呢?
- 作为集合中的元素、键、值。基本类型无法胜任这些需求。
- 接上条,泛型参数都无法使用基本类型。
- 在进行反射的方法调用时,必须使用装箱基本类型。
总之,在大部分场合,基本类型总是优先于装箱基本类型。
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键的问题。
总之,如果可以使用更加合适的数据类型,或者可以编写更加适当的数据类型,就应该避免使用字符串来表示对象。
若使用不当,字符串相比其它对象,更加笨拙,更不灵活,速度更慢,也更容易出错。经常被错误地用字符串来表示的类型包括:基本类型、枚举类型和聚集类型。