第1条:考虑用静态工厂方法代替构造器
通常我们会使用 构造方法 来实例化一个对象,例如:
// 对象定义 public class Student{ // 姓名 private String name; // 性别 private String sex; public Student(String name,String sex){ this.name = name; this.sex = sex; } }
// 实例化对象 Student student = neew Student("MarkLogZhu","男");
然后我们采用 静态工厂方法 实例化对象:
// 对象定义 public class Student{ // 姓名 private String name; // 性别 private String sex; private Student(String name,String sex){ this.name = name; this.sex = sex; } public static Student getMaleStudent(String name){ return new Student(name,"男"); } }
// 实例化对象 Student student = Student.getMaleStudent("MarkLogZhu");
静态工厂方法优点
从上面就可以很简单的看出 静态工厂方法 实例化对象比起用 构造方法 实例化对象至少有两个好处:
* 实例化对象有名称,可以让调用方更好的了解方法作用 * 减少参数,但是这个优点需要看使用场景,有些场景下可能体现不出这个优点
除了以上的优点外,静态工厂方法 还可以不用重复创建一个对象:
public class Student{ // 姓名 private String name; // 性别 private String sex; private static Student student = null; private Student(String name,String sex){ this.name = name; this.sex = sex; } public static Student getMaleStudent(String name){ if(student == null){ student = new Student(name,"男"); } return student; } }
注:这里只是简单的演示优点,实际使用时请参考单例模式。
静态工厂方法 的第三个优点,可以返回原返回类型的任何子类型的:
List list = Collections.synchronizedList(new ArrayList());
List 是接口,而 ArrayList 是它的实现子类。
静态工厂方法缺点
1.类如果不含有公有的或者受保护的构造器,就不能被子类化
怎么理解这句话呢?一个类如果选择 静态工厂方法 实例化对象,那么一般就会把 构造方法 标志成私有的,而用 静态工厂方法 去调用(比如,getInstance)。这会影响其子类的继承,因为子类的构造函数需要首先调用父类的构造函数,因为父类的构造函数是 private 的,所以即使我们假设继承成功的话,那么子类也根本没有权限去调用父类的私有构造函数,所以是无法被继承的。
2. 查找API比较麻烦
静态工厂方法 不像构造器可以在 API 中直接标识出来,而是和其他普通静态方法一样。为了解决这个问题,可以通过遵守标准的命名习惯来弥补,惯用名称有:
- valeuOf
- of
- getInstance
- getType
- newType
- ......
第2条:遇到多个构造器参数时要考虑用构建器
当一个对象有多个参数,其中部分参数是可选的情况下,一般情况下会怎么实例化对象呢?
一般有两种方式:
- 使用重叠构造函数
- 采用 javabean
重叠构造函数
// 注: 姓名和性别必填,其他参数选填 public class Student { // 姓名 private String name; // 性别 private String sex; // 家庭住址 private String address; // 电话 private String phone; public Student(String name, String sex) { this(name, sex, "", ""); } public Student(String name, String sex, String address) { this(name, sex, address, ""); } public Student(String name, String sex, String address, String phone) { this.name = name; this.sex = sex; this.address = address; this.phone = phone; } }
javabean
// 注: 姓名和性别必填,其他参数选填 public class Student { // 姓名 private String name; // 性别 private String sex; // 家庭住址 private String address; // 电话 private String phone; public Student(String name, String sex) { this.name = name; this.sex = sex; } // set/get ...... } Student student = new Student("MarkLogZhu","男"); student.setAddress("XXXXX"); student.setPhone("XXXXX");
缺点
重叠构造函数 的缺点在于参数越多,写的越复杂,可读性越差。
javabean 的缺点在于在对象构造过程中对象可能处于不一致的状态,javabean 模式阻止了把类做成不可变的可能(需要额外的努力来保证线程安全)。
解决方法就是使用 Builder模式。
Builder模式
public class Student { // 姓名 private String name; // 性别 private String sex; // 家庭住址 private String address; // 电话 private String phone; public Student(Builder builder) { this.name = builder.name; this.sex = builder.sex; this.address = builder.address; this.phone = builder.phone; } public static class Builder { private String name; private String sex; private String address = ""; private String phone = ""; public Builder(String name, String sex) { this.name = name; this.sex = sex; } public Builder address(String address) { this.address = address; return this; } public Builder phone(String phone) { this.phone = phone; return this; } public Student build() { return new Student(this); } } }
实例化对象:
Student student = new Builder("MarkLogZhu", "男") .address("XXXX").phone("XXXX").build();
可以看到如果可选参数毕竟多的情况下,使用 Builder模式 是最好的方式。
第3条:用私有构造器或者枚举类型强化Singleton属性
单例模式几乎人人会写,这里给出一个饿汉式单例模式:
public class Student implements Serializable { public static Student student = new Student(); public static Student getInstance(){ return student; } }
上面的实例实现了单例模式并且可以被序列化,但是能否保证被反序列化过后还是单例呢?我们来做个演示:
public class Student implements Serializable { public static Student student = new Student(); public static Student getInstance() { return student; } public static void main(String[] args) throws Exception { ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("D:\\objFile.obj")); Student student = Student.getInstance(); out.writeObject(student); out.close(); ObjectInputStream in = new ObjectInputStream(new FileInputStream("D:\\objFile.obj")); Student student1 = (Student) in.readObject(); in = new ObjectInputStream(new FileInputStream("D:\\objFile.obj")); Student student2 = (Student) in.readObject(); System.out.println("obj1 hashcode:" + student1.hashCode()); System.out.println("obj2 hashcode:" + student2.hashCode()); in.close(); } }
查看控制台输出:
obj1 hashcode:1104106489 obj2 hashcode:94438417
可以看到两个 hashcode 不一致,说明被反序列化过后便不再是单例。要保证单例还必须在单例类中实现readResolve的方法:
public class Student implements Serializable { public static Student student = new Student(); public static Student getInstance() { return student; } private Object readResolve(){ return student; } }
查看控制台输出:
obj1 hashcode:990368553 obj2 hashcode:990368553
为什么加不加 readResolve 方法区别这么大? 如果对象中存在 readResolve 方法,那么在反序列化的时候将会采用 深复制 的形式创建对象,反之就会采用 浅复制 的形式创建对象。
除了添加 readResolve 方法来保证序列化也是单例的解决方案,也可以采用枚举类来保证对象的单例:
public enum Instance { STUDENT }
测试方法:
public static void main(String[] args) throws Exception { ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("D:\\objFile.obj")); Instance instance = Instance.STUDENT; out.writeObject(instance); out.close(); ObjectInputStream in = new ObjectInputStream(new FileInputStream("D:\\objFile.obj")); Instance instance1 = (Instance) in.readObject(); in = new ObjectInputStream(new FileInputStream("D:\\objFile.obj")); Instance instance2 = (Instance) in.readObject(); System.out.println("obj1 hashcode:" + instance1.hashCode()); System.out.println("obj2 hashcode:" + instance2.hashCode()); in.close(); }
查看控制台输出:
obj1 hashcode:990368553 obj2 hashcode:990368553
第4条:通过私有构造器强化不可实例的能力
有些工具类,例如 Arrays 等对它们进行实例化并没有意义,所以应该在它们的构造方法上应该使用private修饰。
第5条:避免创建不必要的对象
书中提到 当你应该重用现有对象的时候,请不要创建新的对象,最能体现这一点的莫过于 String 对象的创建了。
String name = "MarkLogZhu"; String name = new String("MarkLogZhu");
第一种 String 字符串的创建是在方法区(JDK7后改到了堆内存)中的常量池中创建一个”hello”常量,将来若再有一个字符串变量为“hello”时将直接指向常量池中的“hello”变量而不用重新创建;第二种则是会在堆变量中创建一个新的 String 实例,将来若再以此方式创建一个字符串变量为“hello”时还是会重新创建一个 String 实例。显然第二种方式创建了一个“不必要”的实例,相比较而言第一种方式更加高效。
除了这种外还需要关注程序中是否有些局部变量可以提升成类变量,以避免重复创建对象。例如:
public boolean isBabyBoomer(Date birthDate){ Calendar gmtCal = Calendar.getInstance(TimeZone.getTimeZone("GMT")); gmtCal.set(1949, Calendar.JANUARY, 1, 0, 0, 0); Date boomStart = gmtCal.getTime(); gmtCal.set(1965, Calendar.JANUARY, 1, 0, 0, 0); Date boomEnd = gmtCal.getTime(); return birthDate.compareTo(boomStart) >= 0 && birthDate.compareTo(boomEnd) < 0; }
boomStart 和 boomEnd 对象在每次方法调用的时候都会重新生成,实际上是可以将他提升成类变量,以避免重复创建对象:
private static final Date BOOMSTART; private static final Date BOOMEND; static { Calendar gmtCal = Calendar.getInstance(TimeZone.getTimeZone("GMT")); gmtCal.set(1949, Calendar.JANUARY, 1, 0, 0, 0); BOOMSTART = gmtCal.getTime(); gmtCal.set(1965, Calendar.JANUARY, 1, 0, 0, 0); BOOMEND= gmtCal.getTime(); } public boolean isBabyBoomer(Date birthDate){ return birthDate.compareTo(BOOMSTART) >= 0 && birthDate.compareTo(BOOMEND) < 0; }
第6条:消除过期的对象引用
之所以要消除过期的对象引用其目的就在于尽量避免内存泄露的问题,书中举了一个“栈”的例子,其中在弹出一个元素时代码如下:
public Object pop() { if (size == 0) { throw new EmptyStackException(); } return elements[--size]; }
可以看到弹出元素时仅仅是将元素弹出后在将数组的索引-1,实际上数组维护的那个元素引用还在,也就是说那个被弹出的元素并不会被 GC,如此一来最后就很有可能导致内存泄露的问题。解决此类的办法就是,当不再使用这个元素时,将其置为 null。
public Object pop() { if (size == 0) { throw new EmptyStackException(); } Object result = elements[--size]; elements[size] = null; return result; }
需要注意的是消除对象引用并不是在任意条件下都成立,你应该仔细思考此时的引用是否为过期引用,“清空对象引用应该是一种例外,而不是一种规范行为”。
简单来说如果对象内部自己管理内存,如有 list 、数组等属性时,就需要警惕内存泄漏的问题,除此之外该干嘛干嘛去。
第7条:避免使用终结方法
这里的终结方法指的是 finalize()方法。在 C++ 中有一个“析构函数”,它代表在这个对象垃圾回收前所做的一些动作例如资源的关闭等。但是对于 Java 来说垃圾回收是自动的,或者称之为不可预知或不可控,尽管 finalize 方法所代表的也是在垃圾回收前所做的一些动作,但对于 GC 的时间你不能掌握,也就是说不能保证 finalize 方法会被及时执行,这是很危险的,一般情况下这个方法不会被用到。
第8条:覆盖equals时请遵守通用约定
对象的 equals() 方法可以被覆盖,在覆盖时需要遵守通用的约定,否则程序将会出现未知的错误,通用的约定包含:
- 自反性
- 对称性
- 传递性
- 一致性
- 非空性
自反性
对象需要等于它自身,实在不知道怎么违法这个约定。
@Override public boolean equals(Object obj) { if(this != obj){ return false; } return true; }
对称性
如果 A == B,那么 B == A 也必须是成立的。
传递性
如果 A == B, B == C,那么 A == C 也必须是成立的。
但是在子类的时候常常会出现违反这个约定的情况,这个时候可以考虑下是否该使用组合,而不是继承。
一致性
如果两个对象相等,除非它们有被修改过,否则将一直相等。
非空性
所有的对象必须不等于 null。
最佳实践方式
@Override public boolean equals(Object obj) { // 1.判断对象是否是该类的引用 if(this != obj){ return false; } // 2.判断对象的类型是否正确 if(!(obj instanceof Student)){ return false; } // 3.将对象转为正确的类型 Student student = (Student) obj; // 4.对类中的关键字段进行检查,确认是否相等 if(!this.name.equals(student.name) || !this.sex.equals(student.sex)){ return false; } return true; }
第9条:覆盖equals时总要覆盖hashCode
在覆盖 equals 方法的同时也必须要覆盖 hashCode 方法,Object 规范中规定 相等的对象必须具有相等的散列码(hashCode)。
怎么编写hashCode
* 1.申明一个int 类型的变量 result ,设置一个非零的默认值。
* 2.对于对象中的关键字段完成以下步骤
- 为该字段计算 int 类型的散列码 c
字段类型 | 公式 |
---|---|
boolean | c = (f ? 1 : 0); |
byte,char,short,int | c = (init) f; |
long | c = (int)(f ^ (f >> 32)); |
float | c = Float.floatToIntBits(f); |
double | t = Double.doubleToLongBits(f); c = (int) ( t ^ (t >> 32)); |
对象引用 | c = 对象.hashCode(); |
数组 | 循环数组,根据上面的公式计算散列码 |
- 按照下面的公式,把步骤 2.a 中计算得到的散列码 c 累加到变量 result。
result = 31 * result + c;
* 3. 返回 result
第10条:始终要覆盖toString
为了方便之后的日志输出,这一条约定很显然是行之有效的方法。
第11条:谨慎地覆盖clone
什么是 clone
我们先来看一段代码:
public class Student { private String name; private int age; public Student(String name, int age) { this.name = name; this.age = age; } public String getName() { return name; } public void setName(String name) { this.name = name; } public int getAge() { return age; } public void setAge(int age) { this.age = age; } public static void main(String[] args) { Student student1 = new Student("MarkLogZhu", 18); Student student2 = student1; student2.setAge(0); System.out.println(student1.getAge()); } }
可以看到我们声明了一个 Student 对象并赋值,然后又声明了第二个 Student 对象,并将第一个 Student 对象的引用复制给了第二个 Student。然后对第二个 Student 对象进行年龄的修改,然后输出第一个 Student 的值,这个时候值是多少呢?
聪明的你应该已经知道会输出 0 了吧,那么能否避免这个问题呢?
使用 clone 方法就可以拷贝一份数据出来,而不是只是把引用复制:
public class Student implements Cloneable{ private String name; private int age; public Student(String name, int age) { this.name = name; this.age = age; } public String getName() { return name; } public void setName(String name) { this.name = name; } public int getAge() { return age; } public void setAge(int age) { this.age = age; } @Override protected Student clone() throws CloneNotSupportedException { return (Student) super.clone(); } public static void main(String[] args) throws CloneNotSupportedException { Student student1 = new Student("MarkLogZhu", 18); Student student2 = student1.clone(); student2.setAge(0); System.out.println(student1.getAge()); } }
控制台输出:
18
如果 clone 的表现真的有它现在所展现的这么优秀也不会被建议要谨慎地覆盖,那它有什么问题呢?
这个问题涉及到了 浅拷贝 和 深拷贝 的知识,让我们在看看一个实例:
public class CopyTest { private String content; public CopyTest(String content){ this.content = content; } public String getContent() { return content; } public void setContent(String content) { this.content = content; } } public class Student implements Cloneable{ private String name; private int age; private CopyTest copyTest; public Student(String name, int age,CopyTest copyTest) { this.name = name; this.age = age; this.copyTest = copyTest; } public String getName() { return name; } public void setName(String name) { this.name = name; } public int getAge() { return age; } public void setAge(int age) { this.age = age; } public CopyTest getCopyTest() { return copyTest; } public void setCopyTest(CopyTest copyTest) { this.copyTest = copyTest; } @Override protected Student clone() throws CloneNotSupportedException { return (Student) super.clone(); } public static void main(String[] args) throws CloneNotSupportedException { Student student1 = new Student("MarkLogZhu", 18,new CopyTest("student1")); Student student2 = student1.clone(); student2.getCopyTest().setContent("student2"); System.out.println(student1.getCopyTest().getContent()); } }
我们新建了一个 CopyTest 类,并且是 Student 的属性,然后我们跟之前一样拷贝并修改它的内容,这个时候它的输出是什么呢?
控制台输出:
student2
很遗憾不是我们预期的 student1 ,而是 student2。为什么会这么呢?
是因为 clone 默认只能实现 浅拷贝 ,而不能实现 深拷贝。
浅拷贝和深拷贝的区别
浅拷贝---能复制变量,如果对象内还有对象,则只能复制对象的地址
深拷贝---能复制变量,也能复制当前对象的内部对象
那么怎么实现深拷贝呢?让我们修改下代码:
CopyTest 增加 clone 方法:
public class CopyTest implements Cloneable { ...... @Override protected CopyTest clone() throws CloneNotSupportedException { return (CopyTest) super.clone(); } }
Student 修改 clone 方法实现:
@Override protected Student clone() throws CloneNotSupportedException { Student student = (Student) super.clone(); student.copyTest = this.copyTest.clone(); return student; }
然后再运行程序,查看控制台输出:
student1
可以看到现在正常输出了。
我们做了什么操作呢?
- 1.CopyTest属性对象自己需要实现 clone() 方法
- 2.Student需要修改 clone() 方法
这样的约束并不是强制的,很有可能会出现遗漏导致程序出现未知错误。所以被建议要谨慎地覆盖。
第12条:考虑实现 Comparable 接口
当你的类需要进行排序时,可以考虑实现 Comparable
接口,Comparable
接口中只有一个方法 compareTo
。相比于 equals
方法只能返回 True
和 false
,compareTo
可以代表更多的含义:
public class Student implements Comparable<Student> { @Override public int compareTo(Student student) { return this.age - student.getAge(); } }
compareTo
约定第一个对象若“大于”第二个对象则返回整数,“等于”则返回0,“小于”则返回负数,compareTo
能约定更为复杂的“比较”。
有一个强烈的建议就是 compareTo
应该返回和 equals
方法相同的结果,当然建议的意思就是你也可以不采纳,但最好有个注释以防止后来者踩坑。
第13条:使类和成员的可访问性最小化
这条建议实际上就是讲解面向对象的三大特性之一 “封装”。封装的好处有:
- 只提供方法调用而隐藏内部的实现,调用者不需要关心内部实现
- 可以灵活修改类内部实现而不用担心影响调用者的使用
- 内部属性不会被其他类所使用,防止对象间的不良交互,提高代码的模块化和安全性
实现“封装”的方式其实就是通过修饰符权限来控制:
修饰符 | 权限范围 |
---|---|
private | 只有在类内部才可以访问 |
package-private | 默认的访问权限(接口除外,接口默认为 public),只有在同一个包下的类才能访问,就算是它的子类但不是在同一包下也不能访问 |
protected | 只有在同一个包下,或者是它的子类才能访问 |
public | 任何类都可以访问 |
子类不能提供比父类更大范围的权限,如父类方法是以 protected
修饰,子类继承后不能实现为 public
的修饰方法。当然比父类访问范围小也是不可以的。
第14条:在公有类中使用访问方法而非公有域
这条其实是从上一条延伸过来,不应该直接暴露字段给外部调用,而是应该通过 get/set
方法的形式提供调用。使用方法的形式调用,可以更好的保留更改类内部表示的灵活性。
第15条:使可变性最小化
最好的类就是不变的类,如果做不到不变,那么就让它的可变性变的更小的吧。通过使用 final
关键字来申明不可变。
final 用法
- 和 static 关键字配合,以常量的形式代替硬编码:
private static final int ZERO = 0;
- 修饰类使其不能被继承,如
String
- 修饰成员变量,使得该变量变为不可变的对象引用,此时应该给它赋初值,之后它不能被重新赋值,准确来讲是它的引用不可改变。注意是引用不可改变,不代表被引用的对象内部不能改变。
第16条:复合优先于继承
继承是实现代码复用的有效方式,但是有时候它也会破坏类的封装性。一个子类依赖于其父类的实现细节来保证其正确的功能。 父类的实现可能会从发布版本不断变化,如果是这样,子类可能会被破坏,即使它的代码没有任何改变。 因此,一个子类必须与其超类一起更新而变化,除非父类的作者为了继承的目的而专门设计它,并对应有文档的说明。
复合(has-a)和继承(is-a)两者在意义上有明显的区别:
- 1.继承明确了基类和子类的职责关系,基类(A)要实现的功能,子类(B)也要实现,但是实现方式可以有区别。
- 2.复合则属于互相平行关系,以A和B来说,A实现的功能,B不必去实现,但是B在实现所属业务的过程中可以调用A的方法。
- 3.根据业务需要,使用不同的方式才是最合适的方式。业务上明确A和B是继承关系的,则使用继承。否则,这个时候就需要去考虑下,是否还应该使用继承,是否使用复合更合适。
第17条:要么为继承而设计,并提供文档说明,要么就禁止继承
除非真的是非继承不可的,尽量不要使用继承。因为继承的成本太高了,需要关注它的父类的生命周期。如果你不这样做,子类可能会依赖于父类的实现细节,并且如果父类的实现发生改变,子类可能会损坏。 为了允许其他人编写高效的子类,可能还需要导出一个或多个受保护的方法。 除非你知道有一个真正的子类需要,否则你可能最好是通过声明你的类为final禁止继承,或者确保没有可访问的构造方法。
第18条:接口优于抽象类
接口和抽象类的区别
- 1.接口不能被实例化不能有构造器,抽象类也不能被实例化但可以有构造器;
- 2.接口中不能有实现方法(JDK8在接口中可以有实现方法,称“默认方法”),抽象类中可以有实现方法,也可以没有;
- 3.接口方法的默认修饰符就是public,不可以用其他修饰符,抽象类可以有public、protected、default;
- 4.一个类只支持单继承,但可以实现多接口;
第四点就已经能够很好的体现接口优于抽象类了,不同的行为可以提交多个接口,但是如果使用抽象类的话你就必须实现一些不相干的方法。
第19条:接口只用于定义类型
这个建议是让接口不要只用于定义常量,使之变成常量接口,如果一个类只有常量应该使用枚举类型或者不可实例化的工具类。JDK中的反例就是 java.io.ObjectStreamConstant
。
第20条:类层次优于标签类
标签类是指在类中定义了一个变量,使用该变量的值来控制该做什么动作。例如:
定义一个Figure类,使用Shapre变量,可以传入“长方形”或者“圆形”,根据传入的类型不同调用共同的方法。
这就是一个标签类,如果新增一个“三角形”的话,就得修改这个标签类的代码。而更好的方法就是利用继承,合理利用继承能更好的体现面向对象的多态性。
第21条:用函数对象表示策略
什么是函数对象?
在 JDK8 之前 Java 还没有支持 lamda 表达式,方法参数不能传递一个方法只能通过传递对象的方式“曲线救国”,例如Arrays.sort(T[] a, Comparator<? super T> c)方法,第一个参数传递数组,根据传入第二个自定义的比较类中的比较方法进行排序。如果能传入函数指针、Lambda表达式等,那就自然不用传递一个类。
第22条:优先考虑静态成员类
什么是嵌套类?
嵌套类(nested class)是在另一个类中定义的类。 有四种嵌套类,分别是:静态成员类,非静态成员类,匿名类和。 除了第一种以外,剩下的三种都被称为内部类(inner class)。
静态成员类:
public class Main{ public static class NestClass{} } // 在外面使用时候形式如下,在 Main 中使用则不需要加上外部类限定 Main.NestClass nestClass = new Main.NestClass();
非静态成员类:
public class Main{ public class NestClass{} }
匿名类:
Thread thread = new Thread(new Runnable() { @Override public void run() { System.out.println("这是一个匿名类"); } }); thread.start();
局部类:
public class Test { { class AA{}//块内局部类 } public Test(){ class AA{}//构造器内局部类 } public static void main(String[] args){ } public void test(){ class AA{}//方法内局部类 } } //注意到了吧,可以同名,编译后,形成诸如:外部类名称+$+同名顺序+局部类名称 //Test$1AA.class/Test$2AA.class/Test$3AA.class
为什么优先考虑静态成员类?
静态成员类 相比较于 非静态成员类 就是多了一个 static 关键字修饰类,另外一个更重要的区别在于非静态成员类的每个实例都包含一个额外的指向外围对象的引用,保存这份引用要耗费时间和空间。
那么什么时候使用静态什么时候使用非静态呢?
如果声明成员类不要求访问外围实例,就要始终把 static 修饰符放在它的声明中。也就是说如果成员类和外围实例类有交互,那这个类就应该是非静态的,如果没有交互而是作为外围类的一个组件存在在应使用静态的。
局部类的使用场景
只要是在任何“可以声明局部变量的地方”,都可以声明局部类,用得最少,如果要使用那也必须非常简短。
第23条:请不要新代码中使用原生态类型
这条建议针对的是“泛型”,“泛型”是在 JDK5 的时候新增的。为了跟之前的版本做兼容没有强制规定必须限制类型,但是它有可能在运行的时候会报错:
public static void main(String[] args) { List list = new ArrayList(); list.add("124"); list.add(3); for(int i=0,size = list.size();i<size;i++){ System.out.println(list.get(i)); } }
在上面的实例中,list
即可以添加字符串也可以添加数字,如果这个时候增加的是不同的对象,后面对添加的对象取值,就很有可能出现问题。这个错误只能在运行时出现,为了能够在编译期就能明显发现这种错误,我们需要指定泛型的类型:
public static void main(String[] args) { List<String> list = new ArrayList(); list.add("124"); list.add(3); for(int i=0,size = list.size();i<size;i++){ System.out.println(list.get(i)); } }
会出现如下错误:
add(java.lang.String) in List cannot be applied to (int)
简单来说,使用泛型会相对“安全”点,从一开始就能限定数据类型,防止之后不小心插入了错误的类型,而对于原生态类型则不会检查插入的类型,有可能在以后插入了其他类型而只有在运行时才抛出异常,所以鼓励使用泛型。
第24条:消除非受检警告
Set<Student> exaltation = new HashSet();
会出现如下警告:
Unchecked assignment: 'java.util.HashSet' to 'java.util.Set<com.marklogzhu.platform.test1.Student>' less... (Ctrl+F1) Signals places where an unchecked warning is issued by the compiler, for example: void f(HashMap map) { map.put("key", "value"); } Hint: Pass -Xlint:unchecked to javac to get more details.
然后可以进行指示修正,让警告消失。 请注意,实际上并不需要指定类型参数,只是为了表明它与Java 7中引入的钻石运算符("<>")一同出现。然后编译器会推断出正确的实际类型参数(在本例中为Student):
Set<Student> exaltation = new HashSet<>();
但有时候一些警告难以消除,但你确认没有问题时,可以使用 @SuppressWarnings(“unchecked”) 注解来抑制警告。请注意一定要最小范围的使用它,如可以在方法上使用的就不要在类上面申明,可以在变量中申明的就不要在方法上申明。
每个未经检查的警告代表在运行时出现 ClassCastException
异常的可能性。 尽你所能消除这些警告。 如果无法消除未经检查的警告,并且可以证明引发该警告的代码是安全类型的,则可以在尽可能小的范围内使用 @SuppressWarnings(“unchecked”)
注解来禁止警告,并在注释中记录你决定抑制此警告的理由。
第25条:列表优先于数组
列表和数组最大的区别在于,数组是协变的,这里的“变”指的是数据类型,而不是说数组的长度,数组的长度当然从一开始就确定不可改变,但对于以下代码在编译期是合法的:
public static void main(String[] args) throws Exception{ Object[] objects = new Long[1]; objects[0] = "hello world"; System.out.println(objects[0]); }
但是在运行时就会抛出错误:
Exception in thread "main" java.lang.ArrayStoreException: java.lang.String at com.marklogzhu.platform.test1.Student.main(Student.java:14)
而在使用列表的时候,将会在编译期就会报错。
第26条:优先考虑泛型
当你不知道需要传递什么参数的时候请优先考虑泛型,例如:
public interface List<E> extends Collection<E> { ...... }
第27条:优先考虑泛型方法
正如类可以是泛型的,方法也可以是泛型的。 对参数化类型进行操作的静态工具方法通常都是泛型的,例如:
@Component("applicationContextHelper") public class ApplicationContextHelper implements ApplicationContextAware { /** * Spring 应用上下文环境 */ private static ApplicationContext applicationContext; public void setApplicationContext(ApplicationContext context) throws BeansException { applicationContext = context; } public static <T> T popBean(Class<T> clazz) { if (applicationContext == null) { return null; } return applicationContext.getBean(clazz); } public static <T> T popBean(String name, Class<T> clazz) { if (applicationContext == null) { return null; } return applicationContext.getBean(name, clazz); } }
第28条:利用有限制通配符来提升 API 的灵活性
什么是通配符
List<?> list = new ArrayList<>();
像 <?>
这种就叫做通配符,它通常用于定义一个引用变量,而不必如下面这样定义:
List<String> list = new ArrayList<>(); List<Integer> list = new ArrayList<>();
<?>
和 <T>
的区别
<?>
定义在引用变量上,而 <T>
是用在类上或方法上。
什么是有限制通配符
有范围限制的通配符,如:
<? extends E>:表示可接受 E 类型的子类型; <? super E>:表示可接受 E 类型的父类型。
为什么要使用有限制通配符
避免出现类型转换错误,所以尽量让方法的使用是一个类型,例如:
Stack<Number> numberStack = new Stack<>(); Iterable<Integer> integers = ... ; numberStack.pushAll(integers);
它会抛出如下错误:
StackTest.java:7: error: incompatible types: Iterable<Integer> cannot be converted to Iterable<Number> numberStack.pushAll(integers);
第29条:优先考虑类型安全的异构容器
异构”的英文 heterogeneous 意为多种多样的,书中所举的例子如下:
public class Favorites { private Map<Class<?>, Object> favorites = new HashMap<Class<?>, Object>(); public <T> void putFavorite(Class<T> type, T instance) { if (type == null) { throw new NullPointerException(); } favorites.put(type, instance); } public <T> T getFavorite(Class<T> type) { return type.cast(favorites.get(type)); } }
public static void main(String[] args) { Favorites f = new Favorites(); f.putFavorite(String.class, "Java"); f.putFavorite(Integer.class, 0xcafebabe); f.putFavorite(Class.class, Favorites.class); String favoriteString = f.getFavorite(String.class); Integer favoriteInteger = f.getFavorite(Integer.class); Class<?> favoriteClass = f.getFavorite(Class.class); System.out.printf("%s %x %s", favoriteString, favoriteInteger, favoriteClass.getName()); }
Favorite类使用起来有点Map的感觉,putFavorite方法就类似Map.put,或者说用Map不就能实现吗?例如:
public static void main(String[] args) { Map<Class<?>, Object> map = new HashMap<Class<?>, Object >(); map.put(String.class, "Java"); map.put(Integer.class, 122); System.out.println(map.get(String.class)); System.out.println(map.get(Integer.class)); }
能运行和上面结果一致,但问题就在于以下代码:
public static void main(String[] args) { Map<Class<?>, Object> map = new HashMap<Class<?>, Object >(); map.put(String.class, "Java"); map.put(Integer.class, 122); Object str = map.get(String.class); //Integer str = (Integer) map.get(String.class); Object in = map.get(Integer.class); }
根据键取出来的值是 Object,也就是说这是很危险的一件事情。如果代码写成上面注释那样的话,在编译时是无法判断的,只有在运行时才会抛出异常。记住,能在编译时检查就在编译时检查,而不要等到真正运行起来才做检查,这也就是上面 Favorite 所带来的好处,它是类型安全的,同时它也是异构的,这个例子值得细细品味。
第30条:用 enum 代替 int 常量
在代码中你可能使用过如下的代码,用来减少代码中的硬编码:
// 星期一 private static final int MONDAY = 1; // 星期二 private static final int TUESDAY = 2; // 星期三 private static final int WEDNESDAY = 3; // 星期四 private static final int THURSDAY = 4; // 星期五 private static final int FRIDAY = 5; // 星期六 private static final int SATURDAY = 6; // 星期日 private static final int SUNDAY = 7;
使用这种方式会存在如下问题:
- 表达内容少
- 名称相同的两个常量,需要不同的前缀避免冲突导致命名过长
- ......
这个时候通常建议使用枚举类,实际上枚举类型对于强化项目代码的结构和规整也很有帮助:
public enum PayrollDayEnum { MONDAY(1, "星期一"), TUESDAY(2, "星期二"), WEDNESDAY(3, "星期三"), THURSDAY(4, "星期四"), FRIDAY(5, "星期五"), SATURDAY(6, "星期六"), SUNDAY(7, "星期日"); PayrollDayEnum(int day, String desc) { this.day = day; this.desc = desc; } private int day; private String desc; public int getDay() { return day; } public void setDay(int day) { this.day = day; } public String getDesc() { return desc; } public void setDesc(String desc) { this.desc = desc; } }
public static void main(String[] args) { System.out.println(PayrollDayEnum.MONDAY.getCode()+":"+PayrollDayEnum.MONDAY.getDesc()); }
这可能是我们使用枚举比较常见的一种用法。实际上枚举还有其它一些比较“高级”的用法,我们不妨从书中举例来一一说明。首先用枚举来实现加减乘除四种操作:
public enum Operation { PLUS, MINUS, TIMES, DIVIDE; double apply(double x, double y) { switch (this) { case PLUS: return x + y; case MINUS: return x - y; case TIMES: return x * y; case DIVIDE: return x / y; } throw new AssertionError("Unknow op:" + this); } }
public static void main(String[] args) { double x = 1.1; double y = 2.2; double result = Operation.PLUS.apply(x, y); System.out.println(result); }
如果这时候需要新增另外一种操作的时候却忘了新增 case
怎么办?编译时编译器并不会给出任何提示,同样的功能考虑以下实现能很好的避免这种遗忘新增 case
的情况:
public enum Operation { PLUS { double apply(double x, double y) { return x + y; } }, MIUS { double apply(double x, double y) { return x - y; } }, TIMES { double apply(double x, double y) { return x * y; } }, DEVIDE { double apply(double x, double y) { return x / y; } }; abstract double apply(double x, double y); }
现在你可以尝试新增一个操作,可以发现编译器会提示你必须实现 apply
方法,否则编译不通过。
第31条:用实例域代替序数
枚举类型中有一个 ordinal
方法,它返回每个枚举常量类型的数值位置。它的使用方式如下:
public enum Ensemble { SOLO, DUET, TRIO, QUARTET, QUINTET, SEXTET, SEPTET, OCTET, NONET, DECTET; public int numberOfMusicians() { return ordinal() + 1; } }
虽然这个枚举类也可以正常工作,但是如果对这些常量进行重新排序,那么程序很可能就会出现逻辑错误。所以不建议使用这个方法,因为这不能很好地对枚举进行维护,正确应该是利用实例域,例如:
public enum Ensemble { SOLO(1), DUET(2), TRIO(3), QUARTET(4), QUINTET(5), SEXTET(6), SEPTET(7), OCTET(8), DOUBLE_QUARTET(8), NONET(9), DECTET(10), TRIPLE_QUARTET(12); private final int numberOfMusicians; Ensemble(int size) { this.numberOfMusicians = size; } public int numberOfMusicians() { return numberOfMusicians; } }
在枚举规范中申明了 ordinal
方法的使用场景:“大多数程序员对这种方法没有用处。 它被设计用于基于枚举的通用数据结构,如 EnumSet
和 EnumMap
。“除非你在编写这样数据结构的代码,否则最好避免使用 ordinal
方法。
第32条:用 EnumSet 代替位域
EnumSet 在目前为止我都没有使用过,这里先来介绍下 EnumSet
是什么类型,它存在的意义是什么。
我们都知道 HashSet
不包含重复元素,同样 EnumSet
也和 HashSet
一样实现自 AbstractSet
,它也不包含重复元素,可以说它就是为 Enum
枚举类型而生,在《Thinking in Java》中这样描述 “Java SE5引入EnumSet,是为了通过enum创建一种替代品,以替代传统的基于int的“位标识””。本书中也是提到用 EnumSet
来代替位域。
关于 EnumSet
中的元素必须来自同一个 Enum
,并且构造一个 EnumSet
实例是通过静态工厂方法—— noneOf
,用法如下:
public enum Operation { PLUS, MINUS, TIMES, DEVIDE; }
public static void main(String[] args) throws InterruptedException { EnumSet<Operation> enumSet = EnumSet.noneOf(Operation.class); enumSet.add(Operation.DEVIDE); System.out.println(enumSet); enumSet.remove(Operation.DEVIDE); System.out.println(enumSet); }
在书中提到的位域,实际上就是 OR
位运算,换句话说就是“并集”也就是 Set
所代表的就是并集,在使用 int
型枚举模式的时候可能会用到类似 “1 || 2”,这个时候不如用 Enum
枚举加上 EnumSet
来实现。
第33条:用 EnumMap 代替序数索引
有了上一条 EnumSet
的经验,实际上 EnumMap
和 HashMap
也类同,不同的是它是为 Enum
为生的,同样它的键也只允许来自同一个 Enum
。本条目所说的不要使用序数,实际上就是利用Map而不是是使用数组这个意思。我们来看下《Thinking in Java》中的例子(命令设计模式):
public enum AlamPoints { KITCHEN, BATHROOM; }
public interface Command { void action(); }
public static void main(String[] args) throws InterruptedException { EnumMap<AlamPoints, Command> em = new EnumMap<AlamPoints, Command>(AlamPoints.class); em.put(AlamPoints.KITCHEN, new Command() { @Override public void action() { System.out.println("Kitchen fire"); } }); em.put(AlamPoints.BATHROOM, new Command(){ @Override public void action() { System.out.println("Bathroom alert!"); } }); for (Map.Entry<AlamPoints, Command> e : em.entrySet()) { System.out.print(e.getKey() + ":"); e.getValue().action(); } }
这个例子说明了 EnumMap
的基本用法,和 HashMap
除了在构造方法上的不同外,基本无异。
第34条:用接口模拟可伸缩的枚举
在第30条的时候我们提过这样一个实例:
public enum Operation { PLUS { double apply(double x, double y) { return x + y; } }, MIUS { double apply(double x, double y) { return x - y; } }, TIMES { double apply(double x, double y) { return x * y; } }, DEVIDE { double apply(double x, double y) { return x / y; } }; abstract double apply(double x, double y); }
避免新增一个枚举操作时忘记实现 apply
方法,所以定义了一个 abstract
方法,强制必须覆盖apply
方法。但是从软件开发可扩展性来讲,这并不是一个好的解决方案。软件可扩展性并不是在原有代码上做修改,如果这段代码是在jar中的呢?这个时候就需要接口出场了,我们修改上述例子:
public interface Operation { double apply(double x, double y); }
public enum BasicOperation implements Operation{ PLUS("+") { public double apply(double x, double y) { return x + y; } }, MIUS("-") { public double apply(double x, double y) { return x - y; } }, TIMES ("*") { public double apply(double x, double y) { return x * y; } }, DEVIDE ("/") { public double apply(double x, double y) { return x / y; } }; private final String symbol; BasicOperation(String symbol) { this.symbol = symbol; } }
当我们需要扩展操作符枚举的时候只需要重新实现Operation接口即可:
public enum ExtendedOperation implements Operation { EXP ("^") { public double apply(double x, double y) { return Math.pow(x, y); } }, REMAINDER ("%") { public double apply(double x, double y) { return x % y; } }; private final String symbol; ExtendedOperation(String symbol) { this.symbol = symbol; } }
这样就达到代码的可扩展性,这样做的有一个小小的不足就是无法从一个枚举类型继承到另外一个枚举类型。
第35条:注解优先于命名模式
命名模式的缺点:
- 拼写错误,不会编译错误。例如:
public void tets(){...}
- 无法确保它们仅用于适当的程序元素
- 没有提供将参数值与程序元素相关联的好的方法
而注解可以解决以上的问题,如 @Test 注解:
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface Test { }
注:Test
注解类型的声明本身使用 Retention
和 Target
注解进行标记。 注解类型声明上的这种注解称为元注解。
@Test public void testFindByOrderId();
这种使用方式被称为标记注解,因为它没有参数,只是“标记”注解元素。 如果程序员错拼 Test
或将Test
注解应用于程序元素而不是方法声明,则该程序将无法编译。
注解永远不会改变被注解代码的语义,它们只负责提供信息供相关的程序使用。
对于注解运行原理,以及如何正确使用自定义的注解在这里不做过多讲解,此条的目的在于对待“特定程序员”,注解是他们编写“工具类”、“框架类”的利器。
第36条:坚持使用Override注解
如果要重写父类的方法,一定要使用 Override
注解。它会提供编译期的检查,将避免产生大量的恶意 bug
。
第37条:用标记接口定义类型
标记接口是没有包含方法声明的接口,而只是指明一个类实现了具有某种属性的接口,例如实现了 Serializable
接口表示它可被实例化。在有的情况下使用标记注解比标记接口可能更好,但书中提到了几点标记接口胜过标记注解:
- 1)标记接口类型的存在允许在编译时捕获错误,如果使用标记注解,则直到运行时才能捕获错误。
- 2)可以更精确地定位目标
标记注解优于标记接口的主要优点是它们是较大的注解工具的一部分。因此,标记注解允许在基于注解的框架中保持一致性。
什么时候应该使用标记注解,什么时候应该使用标记接口?
如果标记适用于除类或接口以外的任何程序元素,则必须使用注解,因为只能使用类和接口来实现或扩展接口。
如果标记仅适用于类和接口,那么问自己问题:“可能我想编写一个或多个只接受具有此标记的对象的方法呢?”如果是这样,则应该优先使用标记接口而不是注解。这将使你可以将接口用作所讨论方法的参数类型,这将带来编译时类型检查的好处。
如果你能说服自己,永远不会想写一个只接受带有标记的对象的方法,那么最好使用标记注解。另外,如果标记是大量使用注解的框架的一部分,则标记注解是明确的选择。
第38条:检查参数的有效性
参数的有效性检查,最常见的莫过于检查参数是否为 null
。
有时出现调用方未检查传入的参数是否为空,同时被调用方也没有检查参数是否为空,结果这就导致两边都没检查以至于出现 null
的值程序出错,通常情况下会规定调用方或者被调用方来检查参数的合法性,或者干脆规定都必须检查。null
值的检查相当有必要,很多情况下没有检查值是否为空,结果导致抛出 NullPointerException
异常。
第39条:必要时进行保护性拷贝
public class Order { private String orderId; private Date createTime; public String getOrderId() { return orderId; } public void setOrderId(String orderId) { this.orderId = orderId; } public Date getCreateTime() { return createTime; } public void setCreateTime(Date createTime) { this.createTime = createTime; } }
这是一个简略版的一个订单实体类,这里面存在着一个问题。
我们知道 Date 是一个引用类型的数据。如果将这个订单复制给其他对象时,其他对象对创建时间进行修改操作,原来的对象也会被修改。为了避免这个问题,我们需要做一个拷贝操作:
public class Order implements Cloneable{ private String orderId; private Date createTime; public String getOrderId() { return orderId; } public void setOrderId(String orderId) { this.orderId = orderId; } public Date getCreateTime() { return createTime; } public void setCreateTime(Date createTime) { this.createTime = createTime; } @Override protected Order clone() throws CloneNotSupportedException { Order order = this.clone(); order.createTime = (Date) this.createTime.clone(); return order; } }
这样建议跟 第11条:谨慎地覆盖clone
某种意义上说冲突的,需要看使用场景。
第40条:谨慎设计方法签名
方法签名不仅仅是指方法命名,还包括方法所包含的参数。
方法命名要遵循一定的规则和规律,可参考JDK的命名;
方法所包含的参数最好不应超过4个,如果超过4个则应考虑拆分成多个方法或者创建辅助类用来保存参数的分组。
第41条:慎用重载
与重载类似的是重写,但是它们有本质区别:
- 重写是子类的方法重新实现父类的方法,包括方法名和参数都要相同;
- 重载则不用要求是要继承,只要求拥有相同的方法名,参数类型不同个数不同都可以称之为重载。
重载会带来什么问题?
我们来演示一个实例:
public class CollectionClassifier { public static String classify(Set<?> set) { return "Set"; } public static String classify(List<?> list) { return "List"; } public static String classify(Collection<?> collection) { return "Unknown Collection"; } public static void main(String[] args) { Collection<?>[] collections = {new HashSet<String>(), new ArrayList<String>(), new HashMap<String, String>().values()}; for (Collection<?> c : collections) { System.out.println(classify(c)); } } }
运行结果如下:
Unknown Collection Unknown Collection Unknown Collection
可以看到跟我们预期的结果不一致。在来看一个实例:
public static void main(String[] args) { Set<Integer> set = new TreeSet<Integer>(); List<Integer> list = new ArrayList<Integer>(); for (int i = -3; i < 3; i++) { set.add(i); list.add(i); } for (int i = 0; i < 3; i++) { set.remove(i); list.remove(i); } System.out.println(set + " " + list); }
运行结果:
[-3, -2, -1] [-2, 0, 2]
原因就在于对于 List
的 remove
方法有两个,这两个在这里产生了歧义,其中一个是删除下标索引元素,另一个是删除集合中的元素。如果将第21行
list.remove(i);
修改为
list.remove((Integer)i);
则避免得到了我们想要的结果。
这就是重载带来的“危害”,稍不留神就出现了致命问题。比较好的解决办法学习 ObjectOutputStream
类中做法:writeBoolean(boolean)
,writeInt(int)
,writeLong(long)
。如果两个重载方法的参数类型很相似,那一定得考虑这样做是否容易造成“程序的误解”。
第42条:慎用可变参数
可变参数的作用
我们在编写方法的过程中,可能会遇见一个方法有不确定参数个数的情况。一般我们会用方法重载来解决问题:
public void method(); public void method(int i); public void method(int i, int j); public void method(int i, int j, int k);
但是当参数多的时候就会显得很繁琐,同时每次扩展都会很麻烦。于是我们可以使用数组作为参数:
int[] a={1, 2, 3, 4}; public void method(int[] args);
这样还是有个准备参数的过程(需要构造一个数组,麻烦啊)。于是我们可以使用不定项参数(可变参数)的方式:
public void method(int...args);
是的,你没有看错就是省略号,格式就是这样,不是我省略了什么。
为什么要慎用可变参数
- 1) 在没有传入参数的时候,程序没有如果没有做任何保护会导致程序错误。
- 2) 它会带来一定的性能问题,因为可变参数方法的每次调用都会导致进行一次数组分配和初始化。
总之,“在定义参数数目不定的方法时,可变参数是一种很方便的方式,但是它们不应该被过度滥用。如果使用不当,会产生混乱的结果”。
第43条:返回零长度的数组或者集合,而不是null
像下面的代码应该在程序中可能是随处可见的:
List<Student> studentList = studentDao.findByUserName(userName); if(studentList !=null && studentList.size()>=0){ ...... }
对于零长度的数组或者集合,如果返回 null
的话,调用方又没有做 null
判断,就会很容易报 java.lang.NullPointerException
异常。所以为了避免这个问题,应该返回零长度的数组或者集合,而不是 null
。
第44条:为所有导出的API元素编写文档注释
// 写这段代码的时候,只有上帝和我知道它是干嘛的 。现在,只有上帝知道了。
为了避免上面这个问题的出现,请为所有的方法或你认为比较重要的地方写上注释。
第45条:将局部变量的作用域最小化
人的记忆和注意力是有限的,所以尽可能的将局部变量的作用域最小化是代码可读性提升的方式之一,关于这块可以花一天时间阅读下《编写可读代码的艺术》你可能会有更深的体会。
并且作用域最小化,出现错误的概率也会相对变小(毕竟一屏幕就能看到,设置错了也能马上发现)。
第46条:for-each 循环优先于传统的for循环
for-each 语法格式:
for (Element e : elements) { doSomething(e); }
当对集合、数组中的元素只做遍历时应首选 for-each
,而不是通过 for
循环手动移动数组下标。
但是如果是对元素进行删除等需要下标操作的时候还是需要选择 for
循环。
第47条:了解和使用类库
在项目中不要重复造轮子,利用现有的已成熟的技术能避免很多 bug
和其他问题。当然自己学习的时候请疯狂造吧,不然你怎么会进步呢?
第48条:如果需要精确的答案,请避免使用float和double
float
和 double
表示浮点类型数据,对于要精确到小数点的数值运算,通常会选择 float
或者 double
类型,但实际上这两种类型对于精确的计算是存在一定隐患的。
对于精确的数值计算,应该要优先使用 BigDecimal
,或者可以使用int
、long
型将单位缩小不再有小数点。
double
实例如下:
public static void main(String[] args) { double funds = 1.0; double price = .10; for (int i = 0; i < 5; i++) { funds -= price; } System.out.println(funds); }
运行结果:
0.5000000000000001
BigDecimal
实例如下:
public static void main(String[] args) { BigDecimal funds = new BigDecimal(1.0); BigDecimal price = new BigDecimal(.10); for (int i = 0; i < 5; i++) { funds = funds.subtract(price); } System.out.println(funds.doubleValue()); }
运行结果:
0.5
第49条:基本类型优先于装箱基本类型
Java
数据类型分为基本类型和引用类型,对于 int
基本类型的值我们直接用 ==
比较是否相等,那么它的装箱类型 Integer
又是怎样呢?
public static void main(String[] args) { Integer a = 127; Integer b = 127; Integer c = 1000; Integer d = 1000; System.out.println(a == b); System.out.println(c == d); }
运行结果:
true false
这是由于在 Integer
中会缓存-128~127的小数值,在自动装箱的时候对这些小数值能直接比较。再来看下面例子:
public static void main(String[] args) { Integer a = new Integer(127); Integer b = new Integer(127); Integer c = new Integer(1000); Integer d = new Integer(1000); System.out.println(a == b); System.out.println(c == d); }
运行结果:
false false
这个时候就都是引用类型,需要通过 equals
方法来比较了。
基本类型在某些场景下优先于装箱基本类型,并且装箱基本类型还会带来性能问题。所以某些场景下要优先考虑基本类型。
第50条:如果其他类型更合适,则尽量避免使用字符串
对变量申明合适的类型,而不是用字符串通用。比如只有 true
和 false
的值用布尔类型是更合适的;数值计算用数值类型比用字符串更好。
第51条:当心字符串连接的性能
String
字符串是不可变的,每次对一个字符串变量的赋值实际上都在内存中开辟了新的空间。如果要经常对字符串做修改应该使用 StringBuilder
(线程不安全)或者 StringgBuffer
(线程安全),其中 StringBuilder
由于不考虑线程安全,它的速度更快。
第52条:通过接口引用对象
考虑程序代码的灵活性应该优先使用接口而不是类来引用对象,例如:
List<String> list = new ArrayList<String>();
这样带来的好处就是可以更换 List
的具体实现只需一行代码,这样就能实现程序的灵活性。
第53条:接口优先于反射机制
反射可以在编译时不知道对象,运行时访问对象,但是但它也有以下负面影响:
- 丧失了编译时类型检查
- 执行反射访问所需要的代码非常笨拙和冗长(这需要一定的编码能力)
- 性能损失
在使用反射时利用接口指的是,在编译时无法获取相关的类,但在编译时有合适的接口就可以引用这个类,当在运行时以反射方式创建实例后,就可以通过接口以正常的方式访问这些实例。
第54条:谨慎地使用本地方法
所谓的本地方法就是在 JDK 源码中你所看到在有的方法中会有 “native” 关键字的方法,这种方法表示用 C 或者 C++ 等本地程序设计语言编写的特殊方法。之所以会存在本地方法的原因主要有:访问特定平台的接口、提高性能。
实际上估计很少很少在代码中使用本地方法,就算是在设计比较底层的库时也不会使用到,除非要访问很底层的资源。当使用到本地方法时唯一的要求就是全面再全面地测试,以确保万无一失。
第55条:谨慎地进行优化
不要盲目的进行优化,相比于性能,写出结构优美、设计良好的代码更重要。
性能的问题应该有数据做支撑,也就是有性能测试软件对程序测试来评判出性能问题出现在哪个地方,从而做针对性的修改。
逻辑的问题需要有单元测试做最后的保证。
第56条:遵守普遍接受的命名惯例
代码是给人看的,所以让人看懂的代码才是好代码的基础。
第57条:只针对异常的情况才使用异常
这条建议是不要滥用异常,比如可以通过 if
判断避免的错误就不要使用通过捕获异常来解决,例如:
public static void main(String[] args) { List<String> list = null ; try{ for(String str : list){ System.out.println(str); } }catch (Exception e){ e.printStackTrace(); } }
上面这种写法就是很典型的错误,通过 try/catch
捕获异常实现逻辑对性能影响很大。
第58条:对可恢复的情况使用受检异常,对程序错误使用运行时异常
什么时候使用受检查的异常(throws Exception),什么时候使用不受检查的异常(throws RuntimeException),本书中给出原则是:如果期望调用者能够适当地恢复,对于这种情况就应该使用受检的异常。对于程序错误,则使用运行时异常。例如在数据库的事务上通常就会对异常做处理,防止出现数据不一致等情况的发生。
第59条:避免不必要地使用受检的异常
对于异常本身初衷是使程序具有更高的可靠性,特别是受检查的异常。但滥用受检查的异常可能就会使得程序变得复杂。
两种情况同时成立的情况下就可以使用受检查的异常:
- 1、正确地使用 API 并不能阻止这种异常条件的产生;
- 2、如果一旦产生异常,使用 API 的程序员可以立即采取有用的动作。
以上两种情况成立时,就可以使用受检查的异常,否则可能就是徒增烦恼。
另外在一个方法抛出受检查异常时,也需要仔细考量,因为对于调用者来讲就必须处理做相应处理,或捕获或继续向上抛出。如果是一个方法只抛出一个异常那么实际上可以将抛出异常的方法重构为 boolean
返回值来代替。
第60条:优先使用标准的异常
抛出能更明显反馈错误信息的异常,而不是抛出 Exception
异常这么笼统的异常。例如参数值不合法就抛出 llegalArgumentException
等。
常见异常
异常 | 使用场景 |
---|---|
IllegalArgumentException | 不合法的参数异常 |
IllegalStateException | 对象状态不正确 |
NullPointerException | 参数值为null |
Indexoutofboundsexception | 数值下标越界 |
ConcurrentModificationException | 当方法检测到对象的并发修改,但不允许这种修改时,抛出此异常。 |
UnsupportedOperationException | 对象不支持用户请求方法 |
第61条:抛出与抽象相对应的异常
简单来说就是将异常转换,将错误信息更具体的返回给调用者,例如:
public static void main(String[] args) { List<String> list = null ; try{ for(String str : list){ System.out.println(str); } }catch (Exception e){ throw MyException("对象 list 空指针异常"); } }
将 Exception
转为自定义的异常,将错误信息更友好的提供给调用者。
第62条:每个方法抛出的异常都要有文档
跟方法一样,异常也应该有注释,当调用方能明确知道异常产生的原因,他们的处理将更合理。
第63条:在细节信息中包含能捕获失败的信息
捕获异常的时候也要做好日志输出功能,很多时候我们需要通过日志来复现异常原因。
第64条:努力使失败保持原子性
失败的方法调用应该使对象保持在被调用之前的状态,具有这种属性的方法被称为具有失败原子性。
失败过后,我们不希望这个对象不可用。在数据库事务操作中抛出异常时通常都会在异常做回滚或者恢复处理,要实现对象在抛出异常过后照样能处在一种定义良好的可用状态之中,有以下两个办法:
- 1) 设计一个不可变的对象。不可变的对象被创建之后它就处于一致的状态之中,以后也不会发生变化。
- 2) 在执行操作之前检查参数的有效性。例如对栈进行出栈操作时提前检查栈中的是否还有元素。
- 3) 在失败过后编写一段恢复代码,使对象回滚到操作开始前的状态。
在对象的临时拷贝上执行操作,操作完成过后再用临时拷贝中的结果代替对象的内容,如果操作失败也并不影响原来的对象。
第65条:不要忽略异常
忽略就是指写出以下代码:
try { doSomething(); } catch (Exception e) { }
这样的代码就是把异常 吃掉了,当出现异常的时候,你都不知道是哪里出现问题。
第66条:同步访问共享的可变数据
当多个线程共享可变数据的时候,每个读或者写数据的线程都必须执行同步,以确保线程安全,程序正确运行。使用 synchronized
和 volatile
这两个关键字来保证。
第67条:避免过度同步
上一条谈到要使用同步,这一条告诉我们不要过度使用。对于在同步区域的代码,千万不要擅自调用其他方法,特别是会被重写的方法,因为这会导致你无法控制这个方法会做什么,严重则有可能导致死锁和异常。通常,应该在同步区域内做尽可能少的工作。获得锁,检查共享数据,根据需要转换数据,然后放掉锁。
第68条:executor和task优先于线程
之所以推荐 executor
和 task
原因就在于这样便于管理。
第69条:并发工具优先于wait和notify
从JDK5 新增加的 java.util.concurret 并发包中提供了三个方面的并发工具:Executor Framework
(这在上一条中有提到)、并发集合(Concurrent Collection
)以及同步器(Synchronizer
)。
随着 JDK 的发展,基于原始的同步操作 wait 和 notify 已不再提倡使用,因为基础所以很多东西需要自己去保证,越来越多并发工具类的出现使得我们应该学习如何使用这些更为高效和易用的并发工具。
第70条:线程安全性的文档化
书中提到了很有意思的情景,有人会下意识的去查看 API 文档此方法是否包含 synchronized 关键字,如不包含则认为不是线程安全,如包含则认为是线程安全。实际上线程安全不能“要么全有要么全无”,它有多种级别:
- 不可变的——也就是有 final 修饰的类,例如 String、Long,它们就不用外部同步。
- 无条件的线程安全——这个类没有 final 修饰,但其内部已经保证了线程安全,例如并发包中的并发集合类,同样它们无需外部同步。
- 有条件的线程安全——这个有的方法需要外部同步,而有的方法则和“无条件的线程安全”一样无需外部同步。
- 非线程安全——这就是最“普通”的类了,内部的任何方法想要保证安全性就必须要外部同步。
线程对立的——这种类就可以忽略不计了,这个类本身不是线程安全,并且就算外部同样同样也不是线程安全的,JDK 中很少很少,几乎不计,自身也不会写出这样的类,或者也不要写出这种类。
可见队员线程是否安全不能仅仅做安全与不安全这种笼统的概念,更不能根据 synchronized
关键字来判断是否线程安全。你应该在文档注释中注明是以上哪种级别的线程安全,如果是有条件的线程安全不仅需要注明哪些方法需要外部同步,同时还需要注明需要获取什么对象锁。
第71条:慎用延迟初始化
延迟初始化又称懒加载或者懒汉式,这在单例模式中很常见。但是在并发环境下使用会出现线程安全错误,所以一定要利用 synchronized
进行同步:
public class Singleton { private volatile Singleton singleton; private Singleton() { } public Singleton getInstance() { Singleton result = singleton; if (result == null) { synchronized (this) { result = singleton; if (result == null) { singleton = result = new Singleton(); } } } return result; } }
第72条:不要依赖于线程调度器
第69条说的是 executor和task优先于线程 ,此处又指不要依赖,实际上这里的不要依赖指的是不要将正确性依赖于线程调度器。例如:调整线程优先级,线程的优先级是依赖于操作系统的并不可取;调用 Thrad.yield 是的线程获得 CPU 执行机会,这也不可取。所以不要将程序的正确性依赖于线程调度器。
第73条:避免使用线程组
ThreadGroup 目前没有用过,这里做下记录即可。
第74条:谨慎地实现Serializable接口
什么是序列化和反序列化?
将一个对象编码成一个字节流的过程叫做 对象序列化(serializing);相反的处理过程就叫做 反序列化(deserializing)。
序列化的作用?
一旦对象被序列化后,他就可以从一台正在运行的虚拟机上传递到另一台虚拟机上,或者被存储到磁盘上。
如何实现对象序列化
在 Java 中的类只需要实现 Serializable 接口即可被序列化。
存在什么问题?
它会带来以下几个代价:
1) 实现 Serializable 接口后就基本等同于将这个对象如同 API 一样暴露发布出去,这意味着你不可随意更改这个类,也就是大大降低了“改变这个类的实现”的灵活性。
2) 增加了出现 Bug 和安全漏洞的可能性,一个类的构造器往往是用来构建一个类约束关系。序列化机制是一种语言之外的对象创建机制,反序列化可以看作是一个“隐藏的构造器”,这也就是说如果按照默认的反序列化机制很容易不按照约定的构造器建立约束关系,以及很容易使对象的约束关系遭到破坏,以及遭受到非法访问。
3) 随着类的版本的改变,测试的负担增加。因为类的改变需要不断检查测试新版本与旧版本之间的“序列化-反序列化”是否兼容。
上面这三点代价,在有的条件下是值得的,例如很常见的如果一个类将加入到某个框架中,并且该框架依赖序列化来实现对象的传输和持久化这个时候就需要这个类实现 Serializable 接口。
另外书中举了JDK中为了继承而设计的实现了** Serializable** 接口的类,Throwable 类实现了 Serializable 接口,所以 RMI 的异常可以从服务器端传到客户端。HttpServlet 实现了 Serializable 接口,因此会话状态可以被缓存。
尽管一个类要实现序列化很简单,但实现前一定要想好以及设计好这个类是否需要序列化,是否值得付出上面三个代价。
第75条:考虑使用自定义的序列化形式
目前没有用到,这里做下记录。
第76条:保护性的编写readObject方法
目前没有用到,这里做下记录。
第77条:对于实例控制,枚举类型优先于readResolve
目前没有用到,这里做下记录。
第78条:考虑用序列化代理代理序列化实例
目前没有用到,这里做下记录。