序言
声明
因为简书篇幅限制,151个建议只能分开.这里是 [71-90]
本书来源 @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
建议71:推荐使用subList处理局部列表
我们来看这样一个简单的需求:一个列表有100个元素,现在要删除索引位置为20~30的元素。这很简单,一个遍历很快就可以完成,代码如下:
public class Test71 {
public static void main(String[] args) {
// 初始化一个固定长度,不可变列表
List<Integer> initData = Collections.nCopies(100, 0);
// 转换为可变列表
List<Integer> list = new ArrayList<Integer>(initData);
// 遍历,删除符合条件的元素
for (int i = 0; i < list.size(); i++) {
if (i >= 20 && i < 30) {
list.remove(i);
}
}
}
}
或者将for循环改为:
for(int i=20;i<30;i++){
if(i<list.size()){
list.remove(i);
}
}
相信首先出现在大家脑海中的实现算法就是此算法了,遍历一遍,符合条件的删除,简单而使用,不过,有没有其它方式呢?有没有“one-lining”一行代码就解决问题的方式呢?
有,直接使用ArrayList的removeRange方法不就可以了吗?不过好像不可能呀,虽然JDK上由此方法,但是它有protected关键字修饰着,不能直接使用,那怎么办?看看如下代码:
public static void main(String[] args) {
// 初始化一个固定长度,不可变列表
List<Integer> initData = Collections.nCopies(100, 0);
// 转换为可变列表
List<Integer> list = new ArrayList<Integer>(initData);
//删除指定范围内的元素
list.subList(20, 30).clear();
}
上一个建议讲了subList方法的具体实现方式,所有的操作都是在原始列表上进行的,那我们就用subList先取出一个子列表,然后清空。因为subList返回的list是原始列表的一个视图,删除这个视图中 的所有元素,最终都会反映到原始字符串上,那么一行代码解决问题了。
顺便贴一下上面方法调用的源码:
public void clear() {
removeRange(0, size());
}
protected void removeRange(int fromIndex, int toIndex) {
ListIterator<E> it = listIterator(fromIndex);
for (int i=0, n=toIndex-fromIndex; i<n; i++) {
it.next();
it.remove();
}
}
建议72:生成子列表后不要再操作原列表
前面说了,subList生成的子列表是原列表的一个视图,那在subList执行完后,如果修改了原列表的内容会怎样呢?视图是否会改变呢?如果是数据库视图,表数据变更了,视图当然会变了,至于subList生成的视图是否会改变,还是从源码上来看吧,代码如下:
public class Test72 {
public static void main(String[] args) {
List<String> list = new ArrayList<String>();
list.add("A");
list.add("B");
list.add("C");
List<String> subList = list.subList(0, 2);
//原字符串增加一个元素
list.add("D");
System.out.println("原列表长度:"+list.size());
System.out.println("子列表长度:"+subList.size());
}
}
程序中有一个原始列表,生成了一个子列表,然后在原始列表中增加一个元素,最后打印出原始列表和子列表的长度,大家想一下,这段程序什么地方会出现错误呢?list.add("D")会报错吗?不会,subList并没有锁定原列表,原列表当然可以继续修改。难道有size方法?正确,确实是size方法出错了,输出结果如下:
Exception in thread "main" 原列表长度:4
java.util.ConcurrentModificationException
at java.util.ArrayList$SubList.checkForComodification(Unknown Source)
at java.util.ArrayList$SubList.size(Unknown Source)
at cn.icanci.test_151.Test72.main(Test72.java:25)
什么,居然是subList的size方法出现了异常,而且还是并发修改异常?这没道理呀,这里根本就没有多线程操作,何来并发修改呢?这个问题很容易回答,那是因为subList取出的列表是原列表的一个视图,原数据集(代码中的lsit变量)修改了,但是subList取出的子列表不会重新生成一个新列表(这点与数据库视图是不相同的),后面在对子列表继续操作时,就会检测到修改计数器与预期的不相同,于是就抛出了并发修改异常。出现这个问题的最终原因还是在子列表提供的size方法的检查上,还记得上面几个例子中经常提到的修改计数器?原因就在这里,我们来看看size的源代码:
public int size() {
checkForComodification();
return size;
}
其中的checkForComodification()方法就是用于检测是否并发修改的,代码如下:
private void checkForComodification()
{
//判断当前修改计数器是否与子列表生成时一致
if(modCount != l.modCount)
throw new ConcurrentModificationException();
else
return;
}
modCount 是从什么地方来的呢?它是在subList子列表的构造函数中赋值的,其值等于生成子列表时的修改次数吗。因此在生成子列表后再修改原始列表,l.modCount的值就必然比modeCount大1,不再保持相等了,于是就抛出了ConcurrentModificationException异常。
subList的其它方法也会检测修改计数器,例如set、get、add等方法,若生成子列表后,再修改原列表,这些方法也会抛出ConcurrentModificationException异常。
对于子列表的操作,因为视图是动态生成的,生成子列表后再操作原列表,必然会导致"视图 "的不稳定,最有效的方法就是通过Collections.unmodifiableList方法设置列表为只读状态,代码如下:
public static void main(String[] args) {
List<String> list = new ArrayList<String>();
List<String> subList = list.subList(0, 2);
//设置列表为只读状态
list=Collections.unmodifiableList(list);
//对list进行只读操作
//......
//对subList进行读写操作
//......
}
这在团队编码中特别有用,比如我生成了一个list,需要调用其他同事写的共享方法,但是一些元素是不能修改的,想想看,此时subList方法和unmodifiableList方法配合使用是不是就可以解决我们的问题了呢?防御式编程就是教我们如此做的。
这里还有一个问题,数据库的一张表可以有多个视图,我们的List也可以有多张视图,也就是可以有多个子列表,但问题是只要生成的子列表多于一个,任何一个子列表都不能修改了,否则就会抛出ConcurrentModificationException异常。
注意:subList生成子列表后,保持原列表的只读状态。
建议73:使用Comparator进行排序
在项目开发中,我们经常要对一组数据进行排序,或者升序或者降序,在Java中排序有多种方式,最土的方式就是自己写排序算法,比如冒泡排序、快速排序、二叉树排序等,但一般不需要自己写,JDK已经为我们提供了很多的排序算法,我们采用"拿来主义" 就成了。在Java中,要想给数据排序,有两种实现方式,一种是实现Comparable接口,一种是实现Comparator接口,这两者有什么区别呢?我们来看一个例子,就比如给公司职员按照工号排序吧,先定义一个职员类代码,如下所示:
import org.apache.commons.lang.builder.CompareToBuilder;
import org.apache.commons.lang.builder.ToStringBuilder;
public class Employee implements Comparable<Employee> {
// 工号--按照进入公司的先后顺序编码的
private int id;
// 姓名
private String name;
// 职位
private Position position;
public Employee(int _id, String _name, Position _position) {
id = _id;
name = _name;
position = _position;
}
//getter和setter方法略
// 按照Id排序,也就是按照资历的深浅排序
@Override
public int compareTo(Employee o) {
return new CompareToBuilder().append(id, o.id).toComparison();
}
@Override
public String toString() {
return ToStringBuilder.reflectionToString(this);
}
}
//枚举类型(三个级别Boss(老板)、经理(Manager)、普通员工(Staff))
enum Position {
Boss, Manager, Staff
}
这是一个简单的JavaBean,描述的是一个员工的基本信息,其中id是员工编号,按照进入公司的先后顺序编码,position是岗位描述,表示是经理还是普通职员,这是一个枚举类型。
注意Employee类中的compareTo方法,它是Comparable接口要求必须实现的方法,这里使用apache的工具类来实现,表明是按照Id的自然序列排序的(也就是升序),现在我们看看如何排序:
public static void main(String[] args) {
List<Employee> list = new ArrayList<Employee>(5);
// 两个职员
list.add(new Employee(1004, "马六", Position.Staff));
list.add(new Employee(1005, "赵七", Position.Staff));
// 两个经理
list.add(new Employee(1002, "李四", Position.Manager));
list.add(new Employee(1003, "王五", Position.Manager));
// 一个老板
list.add(new Employee(1001, "张三", Position.Boss));
// 按照Id排序,也就是按照资历排序
Collections.sort(list);
for (Employee e : list) {
System.out.println(e);
}
}
是按照ID升序排列的,结果正确,但是,有时候我们希望按照职位来排序,那怎么做呢?此时,重构Employee类已经不合适了,Employee已经是一个稳定类,为了排序功能修改它不是一个好办法,哪有什么好的解决办法吗?
有办法,看Collections.sort方法,它有一个重载方法Collections.sort(List<T> list, Comparator<? super T> c),可以接收一个Comparator实现类,这下就好办了,代码如下:
class PositionComparator implements Comparator<Employee> {
@Override
public int compare(Employee o1, Employee o2) {
// 按照职位降序排列
return o1.getPosition().compareTo(o2.getPosition());
}
}
创建了一个职位排序法,依据职位的高低进行降序排列,然后只要Collections.sort(list)修改为Collections.sort(list,new PositionComparator() )即可实现按照职位排序的要求。
现在问题又来了:按职位临时倒叙排列呢?注意只是临时的,是否需要重写一个排序器呢?完全不用,有两个解决办法:
直接使用Collections.reverse(List <?> list)方法实现倒序排列;
通过Collections.sort(list , Collections.reverseOrder(new PositionComparator()))也可以实现倒序排列。
第二个问题:先按照职位排序,职位相同再按照工号排序,这如何处理呢?这可是我们经常遇到的实际问题。很好处理,在compareTo或者compare方法中判断职位是否相等,相等的话再根据工号排序,使用apache工具类来简化处理,代码如下:
@Override
public int compareTo(Employee o) {
return new CompareToBuilder().append(position, o.position)
.append(id, o.id).toComparison();
}
在JDK中,对Collections.sort方法的解释是按照自然顺序进行升序排列,这种说法其实不太准确的,sort方法的排序方式并不是一成不变的升序,也可能是倒序,这依赖于compareTo的返回值,我们知道如果compareTo返回负数,表明当前值比对比值小,零表示相等,正数表明当前值比对比值大,比如我们修改一下Employee的compareTo方法,如下所示:
@Override
public int compareTo(Employee o) {
return new CompareToBuilder().append(o.id, id).toComparison();
}
两个参数调换了一下位置,也就是compareTo的返回值与之前正好相反,再使用Collections.sort进行排序,顺序也就相反了,这样也实现了倒序。
第三个问题:在Java中,为什么要有两个排序接口呢?
其实也很好回答,实现了Comparable接口的类表明自身是可以比较的,有了比较才能进行排序,而Comparator接口是一个工具类接口,它的名字(比较器)也已经表明了它的作用:用作比较,它与原有类的逻辑没有关系,只是实现两个类的比较逻辑,从这方面来说,一个类可以有很多的比较器,只要有业务需求就可以产生比较器,有比较器就可以产生N多种排序,而Comparable接口的排序只能说是实现类的默认排序算法,一个类稳定、成熟后其compareTo方法基本不会变,也就是说一个类只能有一个固定的、由compareTo方法提供的默认排序算法。
注意:Comparable接口可以作为实现类的默认排序算法,Comparator接口则是一个类的扩展排序工具。
建议74:不推荐使用binarySearch对列表进行检索
对一个列表进行检索时,我们使用最多的是indexOf方法,它简单、好用,而且也不会出错,虽然它只能检索到第一个符合条件的值,但是我们可以生成子列表后再检索,这样也即可以查找出所有符合条件的值了。
Collections工具类也提供了一个检索方法,binarySearch,这个是干什么的?该方法也是对一个列表进行检索的,可查找出指定值的索引,但是在使用这个方法时就有一些注意事项,我们看如下代码:
public class Test74 {
public static void main(String[] args) {
List<String> cities = new ArrayList<String> ();
cities.add("上海");
cities.add("广州");
cities.add("广州");
cities.add("北京");
cities.add("天津");
//indexOf取得索引值
int index1= cities.indexOf("广州");
//binarySearch找到索引值
int index2= Collections.binarySearch(cities, "广州");
System.out.println("索引值(indexOf):"+index1);
System.out.println("索引值(binarySearch):"+index2);
}
}
打印结果,先不考虑运行结果,直接看JDK上对binarySearch的描述:使用二分搜索法搜索指定列表,以获得指定对象。其实现的功能与indexOf是相同的,只是使用的是二分法搜索列表,所以估计两种方法返回的结果是一样的,看结果
索引值(indexOf):1
索引值(binarySearch):2
结果不一样,虽然我们说有两个"广州" 这样的元素,但是返回的结果都应该是1才对呀,为何binarySearch返回的结果是2呢?问题就出在二分法搜索上,二分法搜索就是“折半折半再折半” 的搜索方法,简单,而且效率高。看看JDK是如何实现的。
private static final int BINARYSEARCH_THRESHOLD = 5000;
public static <T>
int binarySearch(List<? extends Comparable<? super T>> list, T key) {
if (list instanceof RandomAccess || list.size()<BINARYSEARCH_THRESHOLD)
//随机存取列表或者元素数量少于5000的顺序存取列表
return Collections.indexedBinarySearch(list, key);
else
//元素数量大于5000的顺序存取列表
return Collections.iteratorBinarySearch(list, key);
}
ArrayList实现了RandomAccess接口,是一个顺序存取列表,使用了indexedBinarySearch方法,代码如下:
private static <T> int indexedBinarySearch(
List<? extends Comparable<? super T>> list, T key) {
// 默认商界
int low = 0;
// 默认下界
int high = list.size() - 1;
while (low <= high) {
//中间索引,无符号右移一位
int mid = (low + high) >>> 1;
//中间值
Comparable<? super T> midVal = list.get(mid);
//比较中间值
int cmp = midVal.compareTo(key);
//重置上界和下界
if (cmp < 0)
low = mid + 1;
else if (cmp > 0)
high = mid - 1;
else
//找到元素
return mid; // key found
}
//没有找到,返回负值
return -(low + 1); // key not found
}
这也没啥说的,就是二分法搜索的Java版实现。注意看第10和14行代码,首先是获得中间的索引值,我们的例子中也就是2,那索引值是2的元素值是多少呢?正好是“广州” ,于是就返回索引值2,正确,没问题,我们再看看indexOf的实现,代码如下:
public int indexOf(Object o) {
//null元素查找
if (o == null) {
for (int i = 0; i < size; i++)
if (elementData[i]==null)
return i;
} else {
//非null元素查找
for (int i = 0; i < size; i++)
//两个元素是否相等,注意这里是equals方法
if (o.equals(elementData[i]))
return i;
}
return -1;
}
ndexOf方法就是一个遍历,找到第一个元素值相等则返回,没什么玄机,回到我们的程序来看,for循环的第二遍即是我们要查找的 " 广州 " ,于是就返回索引值1了,也正确,没有任何问题。
两者的算法都没有问题,难道是我们用错了。这的确是我们使用的错误,因为二分法查询的一个首要前提是:数据集以实现升序排列,否则二分法查找的值是不准确的。不排序怎么确定是在小区(比中间值小的区域) 中查找还是在大区(比中间值大的区域)中查找呢?二分法查找必须要先排序,这是二分法查找的首要条件。
问题清楚了,解决办法很easy,使用Collections.sort排下序即可解决。但这样真的可以解决吗?想想看,元素数据是从web或数据库中传递进来的,原本是一个有规则的业务数据,我们为了查找一个元素对它进行了排序,也就是改变了元素在列表中的位置,那谁来保证业务规则的准确性呢?所以说,binarySearch方法在此处受限了,当然,拷贝一个数组,然后再排序,再使用binarySearch查找指定值,也可以解决该问题。
使用binarySearch首先要考虑排序问题,这是我们经常忘记的,而且在测试期间还不好发现问题,等到投入生产环境后才发现查找到的数据不准确,又是一个bug产生了,从这点看,indexOf要比binarySearch简单的多.
使用binarySearch的二分法查找比indexOf的遍历算法性能上高很多,特别是在大数据集且目标值又接近尾部时,binarySearch方法与indexOf方法相比,性能上会提升几十倍,因此从性能的角度考虑时可以选择binarySearch。
建议75:集合中的元素必须做到compareTo和equals同步
实现了Comparable接口的元素就可以排序,compareTo方法是Comparable接口要求必须实现的,它与equals方法有关系吗?有关系,在compareTo的返回为0时,它表示的是 进行比较的两个元素时相等的。equals是不是也应该对此作出相应的动作呢?我们看如下代码:
class City implements Comparable<City> {
private String code;
private String name;
public City(String _code, String _name) {
code = _code;
name = _name;
}
//code、name的setter和getter方法略
@Override
public int compareTo(City o) {
//按照城市名称排序
return new CompareToBuilder().append(name, o.name).toComparison();
}
@Override
public boolean equals(Object obj) {
if (null == obj) {
return false;
}
if (this == obj) {
return true;
}
if (obj.getClass() == getClass()) {
return false;
}
City city = (City) obj;
// 根据code判断是否相等
return new EqualsBuilder().append(code, city.code).isEquals();
}
}
我们把多个城市对象放在一个list中,然后使用不同的方法查找同一个城市,看看返回值有神么异常?代码如下
public static void main(String[] args) {
List<City> cities = new ArrayList<City>();
cities.add(new City("021", "上海"));
cities.add(new City("021", "沪"));
// 排序
Collections.sort(cities);
// 查找对象
City city = new City("021", "沪");
// indexOf方法取得索引值
int index1 = cities.indexOf(city);
// binarySearch查找索引值
int index2 = Collections.binarySearch(cities, city);
System.out.println(" 索引值(indexOf) :" + index1);
System.out.println(" 索引值(binarySearch) :" + index2);
}
输出的index1和index2应该一致吧,都是从一个列表中查找相同的元素,只是使用的算法不同嘛。但是很遗憾,结果不一致:
第一个是 0
第二个是 1
indexOf返回的是第一个元素,而binarySearch返回的是第二个元素(索引值为1),这是怎么回事呢?
这是因为indexOf是通过equals方法判断的,equals方法等于true就认为找到符合条件的元素了,而binarySearch查找的依据是compareTo方法的返回值,返回0即认为找到符合条件的元素了。
仔细审查一下代码,我们覆写了compareTo和equals方法,但是两者并不一致。使用indexOf方法查找时 ,遍历每个元素,然后比较equals方法的返回值,因为equals方法是根据code判断的,因此当第一次循环时 ,equals就返回true,indexOf方法结束,查找到指定值。而使用binarySearch二分法查找时,依据的是每个元素的compareTo方法返回值,而compareTo方法又是依赖属性的,name相等就返回0,binarySearch就认为找到元素了。
问题明白了,修改很easy,将equals方法修改成判断name是否相等即可,虽然可以解决问题,但这是一个很无奈的办法,而且还要依赖我们的系统是否支持此类修改,因为逻辑已经发生了很大的变化,从这个例子,我们可以理解两点:
indexOf依赖equals方法查找,binarySearch则依赖compareTo方法查找;
equals是判断元素是否相等,compareTo是判断元素在排序中的位置是否相同。既然一个决定排序位置,一个是决定相等,那我们就应该保证当排序相同时,其equals也相同,否则就会产生逻辑混乱。
注意:实现了compareTo方法就应该覆写equals方法,确保两者同步。
建议76:集合运算时使用最优雅方式
在初中代数中,我们经常会求两个集合的并集、交集、差集等,在Java中也存在着此类运算,那如何实现呢?一提到此类集合操作,大部分的实现者都会说:对两个集合进行遍历,即可求出结果。是的。遍历可以实现并集、交集、差集等运算,但这不是最优雅的处理方式,下面来看看如何进行更优雅、快速、方便的集合操作:
(1)、并集:也叫作合集,把两个集合加起来即可,这非常简单,代码如下:
public static void main(String[] args) {
List<String> list1 = new ArrayList<String>();
list1.add("A");
list1.add("B");
List<String> list2 = new ArrayList<String>();
list2.add("C");
// 并集
list1.addAll(list2);
}
(2)、交集:计算两个集合的共有元素,也就是你有我也有的元素集合,代码如下:
//交集
list1.retainAll(list2);
其中的变量list1和list2是两个列表,仅此一行,list1中就只包含了list1、list2中共有的元素了,注意retailAll方法会删除list1中没有出现在list2中的元素。
(3)、差集:由所有属于A但不属于B的元素组成的集合,叫做A与B的差集,也就是我有你没有的元素,代码如下:
//差集
list1.removeAll(list2);
也很简单,从list1中删除出现在list2中的元素,即可得出list1和list2的差集部分。
(4)、无重复的并集:并集是集合A加集合B,那如果集合A和集合B有交集,就需要确保并集的结果中只有一份交集,此为无重复的并集,此操作也比较简单,代码如下:
//删除在list1中出现的元素
list2.removeAll(list1);
//把剩余的list2元素加到list1中
list1.addAll(list2);
可能有人会说,求出两个集合的并集,然后转成hashSet剔除重复元素不就解决了吗?错了,这样解决是不行的,比如集合A有10个元素(其中有两个元素值是相同的),集合B有8个元素,它们的交集有两个元素,我们可以计算出它们的并集是18个元素,而无重复的并集有16个元素,但是如果用hashSet算法,算出来则只有15个元素,因为你把集合A中原本就重复的元素也剔除了。
之所以介绍并集、交集、差集,那是因为在实际开发中,很少有使用JDK提供的方法实现集合这些操作,基本上都是采用了标准的嵌套for循环:要并集就是加法,要交集就是contains判断是否存在,要差集就使用了!contains(不包含),有时候还要为这类操作提供了一个单独的方法看似很规范,其实应经脱离了优雅的味道。
集合的这些操作在持久层中使用的非常频繁,从数据库中取出的就是多个数据集合,之后我们就可以使用集合的各种方法构建我们需要的数据,需要两个集合的and结果,那是交集,需要两个集合的or结果,那是并集,需要两个集合的not结果,那是差集。
建议77:使用shuffle打乱列表
在网站上,我们经常会看到关键字云(word cloud)和标签云(tag cloud),用于表达这个关键字或标签是经常被查阅的,而且还可以看到这些标签的动态运动,每次刷新都会有不一样的关键字或标签,让浏览者觉得这个网站的访问量很大,短短的几分钟就有这么多的搜索量。不过,在Java中该如何实现呢?代码如下:
public static void main(String[] args) {
int tagCloudNum = 10;
List<String> tagClouds = new ArrayList<String>(tagCloudNum);
// 初始化标签云,一般是从数据库读入,省略
Random rand = new Random();
for (int i = 0; i < tagCloudNum; i++) {
// 取得随机位置
int randomPosition = rand.nextInt(tagCloudNum);
// 当前元素与随机元素交换
String temp = tagClouds.get(i);
tagClouds.set(i, tagClouds.get(randomPosition));
tagClouds.set(randomPosition, temp);
}
}
现从数据库中读取标签,然后使用随机数打乱,每次产生不同的顺序,嗯,确实能让浏览者感觉到我们的标签云顺序在变化---浏览者多嘛!但是,对于乱序处理我们可以有更好的实现方式,先来修改第一版:
public static void main(String[] args) {
int tagCloudNum = 10;
List<String> tagClouds = new ArrayList<String>(tagCloudNum);
// 初始化标签云,一般是从数据库读入,省略
Random rand = new Random();
for (int i = 0; i < tagCloudNum; i++) {
// 取得随机位置
int randomPosition = rand.nextInt(tagCloudNum);
// 当前元素与随机元素交换
Collections.swap(tagClouds, i, randomPosition);
}
}
上面使用了Collections的swap方法,该方法会交换两个位置的元素值,不用我们自己写交换代码了。难道乱序到此就优化完了吗?没有,我们可以继续重构,第二版如下:
public static void main(String[] args) {
int tagCloudNum = 10;
List<String> tagClouds = new ArrayList<String>(tagCloudNum);
// 初始化标签云,一般是从数据库读入,省略
//打乱顺序
Collections.shuffle(tagClouds);
}
这才是我们想要的结果,就这一行,即可打乱一个列表的顺序,我们不用费尽心思的遍历、替换元素了。我们一般很少用到shuffle这个方法,那它在什么地方用呢?
可用在程序的 "伪装" 上:比如我们例子中的标签云,或者是游侠中的打怪、修行、群殴时宝物的分配策略。
可用在抽奖程序中:比如年会的抽奖程序,先使用shuffle把员工顺序打乱,每个员工的中奖几率相等,然后就可以抽出第一名、第二名。
可以用在安全传输方面:比如发送端发送一组数据,先随机打乱顺序,然后加密发送,接收端解密,然后进行排序,即可实现即使是相同的数据源,也会产生不同密文的效果,加强了数据的安全性。
建议78:减少HashMap中元素的数量
本建议是说HahMap中存放数据过多的话会出现内存溢出,代码如下:
public static void main(String[] args) {
Map<String, String> map = new HashMap<String, String>();
List<String> list = new ArrayList<String>();
final Runtime rt = Runtime.getRuntime();
// JVM中止前记录信息
rt.addShutdownHook(new Thread() {
@Override
public void run() {
StringBuffer sb = new StringBuffer();
long heapMaxSize = rt.maxMemory() >> 20;
sb.append(" 最大可用内存:" + heapMaxSize + " M\n");
long total = rt.totalMemory() >> 20;
sb.append(" 堆内存大小:" + total + "M\n");
long free = rt.freeMemory() >> 20;
sb.append(" 空闲内存:" + free + "M");
System.out.println(sb);
}
});
for (int i = 0; i < 40*10000; i++) {
map.put("key" + i, "value" + i);
// list.add("list"+i);
}
}
这个例子,我经过多次运算,发现在40万的数据并不会内存溢出,如果要复现此问题,需要修改Eclipse的内存配置,才会复现。但现在的机器的内存逐渐的增大,硬件配置的提高,应该可以容纳更多的数据。本人机器是windows64,内存8G配置,Eclipse的配置为 -Xms286M -Xmx1024M,在单独运行此程序时,数据量加到千万级别才会复现出此问题。但在生产环境中,如果放的是复杂对象,可能同样配置的机器存放的数据量会小一些。
但如果换成list存放,则同样的配置存放的数据比HashMap要多一些,本人就针对此现象进行分析一下几点:
1.HashMap和ArrayList的长度都是动态增加的,不过两者的扩容机制不同,先说HashMap,它在底层是以数组的方式保存元素的,其中每一个键值对就是一个元素,也就是说HashMap把键值对封装成了一个Entry对象,然后再把Entry对象放到了数组中。也就是说HashMap比ArrayList多了一次封装,多出了一倍的对象。其中HashMap的扩容机制代码如下(resize(2 * table.length)这就是扩容核心代码):
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
在插入键值对时会做长度校验,如果大于或者等于阈值,则数组长度会增大一倍。
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
boolean oldAltHashing = useAltHashing;
useAltHashing |= sun.misc.VM.isBooted() &&
(newCapacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
boolean rehash = oldAltHashing ^ useAltHashing;
transfer(newTable, rehash);
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
而阈值就是代码中红色标注的部分,新容量*加权因子和MAXIMUM_CAPACITY + 1两个值的最小值。MAXIMUM_CAPACITY的值如下:
static final int MAXIMUM_CAPACITY = 1 << 30;
而加权因子的值为0.75,代码如下:
static final float DEFAULT_LOAD_FACTOR = 0.75f;
所以hashMap的size大于数组的0.75倍时,就开始扩容,经过计算得知(怎么计算的,以文中例子来说,查找2的N次幂大于40万的最小值即为数组的最大长度,再乘以0.75,也就是最后一次扩容点,计算的结果是N=19),在Map的size为393216时,符合了扩容条件,于是393216个元素开始搬家,要扩容则需要申请一个长度为1048576(当前长度的两倍,2的20次方)的数组,如果此时内存不足以支撑此运算,就会爆出内存溢出。这个就是这个问题的根本原因。
2、我们思考一下ArrayList的扩容策略,它是在小于数组长度的时候才会扩容1.5倍,经过计算得知,ArrayLsit在超过80万后(一次加两个元素,40万的两倍),最近的一次扩容是在size为1005305时同样的道理,如果此时内存不足以申请扩容1.5倍时的数组,也会出现内存溢出。
综合来说,HashMap比ArrayList多了一层Entry的底层封装对象,多占用了内存,并且它的扩容策略是2倍长度的递增,同时还会根据阈值判断规则进行判断,因此相对于ArrayList来说,同样的数据,它就会优先内存溢出。
也许大家会想到,可以在声明时指定HashMap的默认长度和加载因子来减少此问题的发生,可以缓解此问题,可以不再频繁的进行数组扩容,但仍避免不了内存溢出问题,因为键值对的封装对象Entry还是少不了的,内存依然增长比较快,所以尽量让HashMap中的元素少量并简单一点。也可以根据需求以及系统的配置来计算出,自己放入map中的数据会不会造成内存溢出呢?
建议79:集合中的哈希码不要重复
在一个列表中查找某值是非常耗费资源的,随机存取的列表是遍历查找,顺序存储的列表是链表查找,或者是Collections的二分法查找,但这都不够快,毕竟都是遍历嘛,最快的还要数以Hash开头的集合(如HashMap、HashSet等类)查找,我们以HashMap为例,看看是如何查找key值的,代码如下:
public class Test79 {
public static void main(String[] args) {
int size = 10000;
List<String> list = new ArrayList<String>(size);
// 初始化
for (int i = 0; i < size; i++) {
list.add("value" + i);
}
// 记录开始时间,单位纳秒
long start = System.nanoTime();
// 开始查找
list.contains("value" + (size - 1));
// 记录结束时间,单位纳秒
long end = System.nanoTime();
System.out.println("List的执行时间:" + (end - start) + "ns");
// Map的查找时间
Map<String, String> map = new HashMap<String, String>(size);
for (int i = 0; i < size; i++) {
map.put("key" + i, "value" + i);
}
start = System.nanoTime();
map.containsKey("key" + (size - 1));
end = System.nanoTime();
System.out.println("map的执行时间:" + (end - start) + "ns");
}
}
执行结果
两个不同的集合容器,一个是ArrayList,一个是HashMap,都是插入10000个元素,然后判断是否包含最后一个加入的元素。逻辑相同,但是执行时间差别却非常大,结果如下:
List的执行时间:502564ns
map的执行时间:20513ns
HahsMap比ArrayList快了两个数量级!两者的contains方法都是判断是否包含指定值,为何差距如此巨大呢?而且如果数据量增大,差距也会非线性增长。
我们先来看看ArrayList,它的contains方法是一个遍历对比,这很easy,不多说。我们看看HashMap的ContainsKey方法是如何实现的,代码如下:
public boolean containsKey(Object key) {
//判断getEntry是否为空
return getEntry(key) != null;
}
getEntry方法会根据key值查找它的键值对(也就是Entry对象),如果没有找到,则返回null。我们再看看该方法又是如何实现的,代码如下:
final Entry<K,V> getEntry(Object key) {
//计算key的哈希码
int hash = (key == null) ? 0 : hash(key);
//定位Entry、indexOf方法是根据hash定位数组的位置的
for (Entry<K,V> e = table[indexFor(hash, table.length)];
e != null;
e = e.next) {
Object k;
//哈希码相同,并且键值也相等才符合条件
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
}
return null;
}
注意看上面代码中红色字体部分,通过indexFor方法定位Entry在数组table中的位置,这是HashMap实现的一个关键点,怎么能根据hashCode定位它在数组中的位置呢?
要解释此问题,还需要从HashMap的table数组是如何存储元素的说起,首先要说明三点:
table数组的长度永远是2的N次幂。
table数组的元素是Entry类型
table数组中的元素位置是不连续的
table数组为何如此设计呢?下面逐步来说明,先来看HashMap是如何插入元素的,代码如下:
public V put(K key, V value) {
//null键处理
if (key == null)
return putForNullKey(value);
//计算hash码,并定位元素
int hash = hash(key);
int i = indexFor(hash, table.length);
for (Entry<K, V> e = table[i]; e != null; e = e.next) {
Object k;
//哈希码相同,并且key相等,则覆盖
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
//插入新元素,或者替换哈希的旧元素并建立链表
addEntry(hash, key, value, i);
return null;
}
注意看,HashMap每次增加元素时都会先计算其哈希码值,然后使用hash方法再次对hashCode进行抽取和统计,同时兼顾哈希码的高位和低位信息产生一个唯一值,也就是说hashCode不同,hash方法返回的值也不同,之后再通过indexFor方法与数组长度做一次与运算,即可计算出其在数组中的位置,简单的说,hash方法和indexFor方法就是把哈希码转变成数组的下标,源代码如下:
final int hash(Object k) {
int h = 0;
if (useAltHashing) {
if (k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h = hashSeed;
}
h ^= k.hashCode();
// This function ensures that hashCodes that differ only by
// constant multiples at each bit position have a bounded
// number of collisions (approximately 8 at default load factor).
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
/**
* Returns index for hash code h.
*/
static int indexFor(int h, int length) {
return h & (length-1);
}
顺便说一下,null值也是可以作为key值的,它的位置永远是在Entry数组中的第一位。
现在有一个很重要的问题摆在前面了:哈希运算存在着哈希冲突问题,即对于一个固定的哈希算法f(k),允许出现f(k1)=f(k2),但k1≠k2的情况,也就是说两个不同的Entry,可能产生相同的哈希码,HashMap是如何处理这种冲突问题的呢?答案是通过链表,每个键值对都是一个Entry,其中每个Entry都有一个next变量,也就是说它会指向一个键值对---很明显,这应该是一个单向链表,该链表是由addEntity方法完成的,其代码如下:
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
void createEntry(int hash, K key, V value, int bucketIndex) {
//取得当前位置元素
Entry<K,V> e = table[bucketIndex];
//生成新的键值对,并进行替换,建立链表
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
这段程序涵盖了两个业务逻辑,如果新加入的元素的键值对的hashCode是唯一的,那直接插入到数组中,它Entry的next值则为null;如果新加入的键值对的hashCode与其它元素冲突,则替换掉数组中的当前值,并把新加入的Entry的next变量指向被替换的元素,于是一个链表就产生了
HashMap的存储主线还是数组,遇到哈希码冲突的时候则使用链表解决。了解了HashMap是如何存储的,查找也就一目了然了:使用hashCode定位元素,若有哈希冲突,则遍历对比,换句话说,如果没有哈希冲突的情况下,HashMap的查找则是依赖hashCode定位的,因为是直接定位,那效率当然就高了。
知道HashMap的查找原理,我们就应该很清楚:如果哈希码相同,它的查找效率就与ArrayList没什么两样了,遍历对比,性能会大打折扣。特别是哪些进度紧张的项目中,虽重写了hashCode方法但返回值却是固定的,此时如果把哪些对象插入到HashMap中,查找就相当耗时了。
注意:HashMap中的hashCode应避免冲突。
建议80:多线程使用Vector或HashTable
Vector是ArrayList的多线程版本,HashTable是HashMap的多线程版本,这些概念我们都很清楚,但我们经常会逃避使用Vector和HashTable,因为用的少,不熟嘛!只有在真正需要的时候才会想要使用它们,但问题是什么时候真正需要呢?我们来看一个例子,看看使用多线程安全的Vector是否可以解决问题,代码如下:
public class Test80{
public static void main(String[] args) {
// 火车票列表
final List<String> tickets = new ArrayList<String>(100000);
// 初始化票据池
for (int i = 0; i < 100000; i++) {
tickets.add("火车票" + i);
}
// 退票
Thread returnThread = new Thread() {
@Override
public void run() {
while (true) {
tickets.add("车票" + new Random().nextInt());
}
};
};
// 售票
Thread saleThread = new Thread() {
@Override
public void run() {
for (String ticket : tickets) {
tickets.remove(ticket);
}
}
};
// 启动退票线程
returnThread.start();
// 启动售票线程
saleThread.start();
}
}
模拟火车站售票程序,先初始化一堆火车票,然后开始出售,同时也有退票产生,这段程序有木有问题呢?可能会有人看出了问题,ArrayList是线程不安全的,两个线程访问同一个ArrayList数组肯定会有问题。
没错,确定有问题,运行结果如下:
Exception in thread "Thread-1" java.util.ConcurrentModificationException
at java.util.ArrayList$Itr.checkForComodification(Unknown Source)
at java.util.ArrayList$Itr.next(Unknown Source)
at cn.icanci.test_151.Test80$2.run(Test80.java:39)
运气好的话,该异常马上就会抛出,也会会有人说这是一个典型错误,只须把ArrayList替换成Vector即可解决问题,真的是这样吗?我们把ArrayList替换成Vector后,结果依旧。仍然抛出相同的异常,Vector应经是线程安全的,为什么还会报这个错呢?
这是因为它混淆了线程安全和同步修改异常,基本上所有的集合类都有一个快速失败(Fail-Fast)的校验机制,当一个集合在被多个线程修改并访问时,就可能出现ConcurrentModificationException异常,这是为了确保集合方法一致而设置的保护措施,它的实现原理就是我们经常提到的modCount修改计数器:如果在读列表时,modCount发生变化(也就是有其它线程修改)则会抛出ConcurrentModificationException异常,这与线程同步是两码事,线程同步是为了保护集合中的数据不被脏读、脏写而设置的,我们来看看线程安全到底用在什么地方,代码如下:
public static void main(String[] args) {
// 火车票列表
final List<String> tickets = new ArrayList<String>(100000);
// 初始化票据池
for (int i = 0; i < 100000; i++) {
tickets.add("火车票" + i);
}
// 10个窗口售票
for (int i = 0; i < 10; i++) {
new Thread() {
public void run() {
while (true) {
System.out.println(Thread.currentThread().getId()
+ "----" + tickets.remove(0));
if (tickets.size() == 0) {
break;
}
}
};
}.start();
}
}
打印
还是火车站售票程序,有10个窗口在卖火车票,程序打印出窗口号(也就是线程号)和车票编号,我们很快就可以看到这样的输出:
......
10----火车票99095
10----火车票99096
10----火车票99096
10----火车票99097
10----火车票99098
10----火车票99100
10----火车票99101
10----火车票99102
10----火车票99103
10----火车票99103
10----火车票99104
......
注意看,上面有两个线程在卖同一张火车票,这才是线程不同步的问题,此时把ArrayList修改为Vector即可解决问题,因为Vector的每个方法前都加上了synchronized关键字,同时知会允许一个线程进入该方法,确保了程序的可靠性。
虽然在系统开发中我们一再说明,除非必要,否则不要使用synchronized,这是从性能的角度考虑的,但是一旦涉及到多线程(注意这里说的是真正的多线程,并不是并发修改的问题,比如一个线程增加,一个线程删除,这不属于多线程的范畴),Vector会是最佳选择,当然自己在程序中加synchronized也是可行的方法。
HashMap的线程安全类HashTable与此相同,不再赘述。
建议81:非稳定排序推荐使用List
我们知道Set和List的最大区别就是Set中的元素不可以重复(这个重复指的是equals方法的返回值相等),其它方面则没有太大区别了,在Set的实现类中有一个比较常用的类需要了解一下:TreeSet,该类实现了默认排序为升序的Set集合,如果插入一个元素,默认会按照升序排列(当然是根据Comparable接口的compareTo的返回值确定排序位置了),不过,这样的排序是不是在元素经常变化的场景中也适用呢?我们来看看例子:
public class Test81 {
public static void main(String[] args) {
SortedSet<Person> set = new TreeSet<Person>();
// 身高180CM
set.add(new Person(180));
// 身高175CM
set.add(new Person(175));
for (Person p : set) {
System.out.println("身高:" + p.getHeight());
}
}
static class Person implements Comparable<Person> {
// 身高
private int height;
public Person(int _height) {
height = _height;
}
public int getHeight() {
return height;
}
public void setHeight(int height) {
this.height = height;
}
// 按照身高排序
@Override
public int compareTo(Person o) {
return height - o.height;
}
}
}
打印结果
身高:175
身高:180
这是Set的简单用法,定义一个Set集合,之后放入两个元素,虽然175后放入,但是由于是按照升序排列的,所以输出结果应该是175在前,180在后。
这没有问题,随着时间的推移,身高175cm的人长高了10cm,而180cm却保持不变,那排序位置应该改变一下吧,代码如下:
public static void main(String[] args) {
SortedSet<Person> set = new TreeSet<Person>();
// 身高180CM
set.add(new Person(180));
// 身高175CM
set.add(new Person(175));
set.first().setHeight(185);
for (Person p : set) {
System.out.println("身高:" + p.getHeight());
}
}
找出身高最矮的人,也就是排在第一位的人,然后修改一下身高值,重新排序了?我们看下输出结果:
身高:175
身高:180
很可惜,竟然没有重现排序,偏离了我们的预期。这正是下面要说明的问题,SortedSet接口(TreeSet实现了此接口)只是定义了在给集合加入元素时将其进行排序,并不能保证元素修改后的排序结果,因此TreeSet适用于不变量的集合数据排序,比如String、Integer等类型,但不使用与可变量的排序,特别是不确定何时元素会发生变化的数据集合。
原因知道了,那如何解决此类重排序问题呢?有两种方式:
(1)、Set集合重排序:重新生成一个Set对象,也就是对原有的Set对象重新排序,代码如下:
set.first().setHeight(185);
//set重排序
set=new TreeSet<Person>(new ArrayList<Person>(set));
就这一行红色代码即可重新排序,可能有人会问,使用TreeSet<SortedSet<E> s> 这个构造函数不是可以更好的解决问题吗?不行,该构造函数只是原Set的浅拷贝,如果里面有相同的元素,是不会重新排序的。
(2)、彻底重构掉TreeSet,使用List解决问题
我们之所以使用TreeSet是希望实现自动排序,即使修改也能自动排序,既然它无法实现,那就用List来代替,然后使用Collections.sort()方法对List排序,代码比较简单,不再赘述。
两种方式都可以解决我们的问题,如果需要保证集合中元素的唯一性,又要保证元素值修改后排序正确,那该如何处理呢?List不能保证集合中的元素唯一,它是可以重复的,而Set能保证元素唯一,不重复。如果采用List解决排序问题,就需要自行解决元素重复问题(若要剔除也很简单,转变为HashSet,剔除后再转回来)。若采用TreeSet,则需要解决元素修改后的排序问题,孰是孰非,就需要根据具体的开发场景来决定了。
注意:SortedSet中的元素被修改后可能会影响到其排序位置。
建议82:由点及面,集合大家族总结
Java中的集合类实在是太丰富了,有常用的ArrayList、HashMap,也有不常用的Stack、Queue,有线程安全的Vector、HashTable,也有线程不安全的LinkedList、TreeMap,有阻塞式的ArrayBlockingQueue,也有非阻塞式的PriorityQueue等,整个集合大家族非常庞大,可以划分以下几类:
(1)、List:实现List接口的集合主要有:ArrayList、LinkedList、Vector、Stack,其中ArrayList是一个动态数组,LinkedList是一个双向链表,Vector是一个线程安全的动态数组,Stack是一个对象栈,遵循先进后出的原则。
(2)、Set:Set是不包含重复元素的集合,其主要实现类有:EnumSet、HashSet、TreeSet,其中EnumSet是枚举类型专用Set,所有元素都是枚举类型;HashSet是以哈希码决定其元素位置的Set,其原理与HashMap相似,它提供快速的插入和查找方法;TreeSet是一个自动排序的Set,它实现了SortedSet接口。
(3)、Map:Map是一个大家族,他可以分为排序Map和非排序Map,排序Map主要是TreeMap类,他根据key值进行自动排序;非排序Map主要包括:HashMap、HashTable、Properties、EnumMap等,其中Properties是HashTable的子类,它的主要用途是从Property文件中加载数据,并提供方便的操作,EnumMap则是要求其Key必须是某一个枚举类型。
Map中还有一个WeakHashMap类需要说明, 它是一个采用弱键方式实现的Map类,它的特点是:WeakHashMap对象的存在并不会阻止垃圾回收器对键值对的回收,也就是说使用WeakHashMap装载数据不用担心内存溢出的问题,GC会自动删除不用的键值对,这是好事。但也存在一个严重的问题:GC是静悄悄的回收的(何时回收,God,Knows!)我们的程序无法知晓该动作,存在着重大的隐患。
(4)、Queue:对列,它分为两类,一类是阻塞式队列,队列满了以后再插入元素会抛出异常,主要包括:ArrayBlockingQueue、PriorityQueue、LinkedBlockingQueue,其中ArrayBlockingQueue是一个以数组方式实现的有界阻塞队列;另一类是非阻塞队列,无边界的,只要内存允许,都可以持续追加元素,我们经常使用的是PriorityQuene类。
还有一种队列,是双端队列,支持在头、尾两端插入和移除元素,它的主要实现类是:ArrayDeque、LinkedBlockingDeque、LinkedList。
(5)、数组:数组与集合的最大区别就是数组能够容纳基本类型,而集合就不行,更重要的一点就是所有的集合底层存储的都是数组。
(6)、工具类:数组的工具类是java.util.Arrays和java.lang.reflect.Array,集合的工具类是java.util.Collections,有了这两个工具类,操作数组和集合就会易如反掌,得心应手。
(7)、扩展类:集合类当然可以自行扩展了,想写一个自己的List?没问题,但最好的办法还是"拿来主义",可以使用Apache的common-collections扩展包,也可以使用Google的google-collections扩展包,这些足以应对我们的开发需要。
第五章 枚举和注解
建议83:推荐使用枚举定义常量
常量声明是每一个项目都不可或缺的,在Java1.5之前,我们只有两种方式的声明:类常量和接口常量,若在项目中使用的是Java1.5之前的版本,基本上都是如此定义的。不过,在1.5版本以后有了改进,即新增了一种常量声明方式:枚举声明常量,看如下代码:
enum Season {
Spring, Summer, Autumn, Winter;
}
这是一个简单的枚举常量命名,清晰又简单。顺便提一句,JLS(Java Language Specification,Java语言规范)提倡枚举项全部大写,字母之间用下划线分割,这也是从常量的角度考虑的(当然,使用类似类名的命名方式也是比较友好的)。
那么枚举常量与我们经常使用的类常量和静态常量相比有什么优势?问得好,枚举的优点主要表现在四个方面:
1.枚举常量简单:简不简单,我们来对比一下两者的定义和使用情况就知道了。先把Season枚举翻写成接口常量,代码如下:
interface Season {
int SPRING = 0;
int SUMMER = 1;
int AUTUMN = 2;
int WINTER = 3;
}
此处定义了春夏秋冬四个季节,类型都是int,这与Season枚举的排序值是相同的。首先对比一下两者的定义,枚举常量只需定义每个枚举项,不需要定义枚举值,而接口常量(或类常量)则必须定义值,否则编译不通过,即使我们不需要关注其值是多少也必须定义;其次,虽然两者被引用的方式相同(都是 “类名 . 属性”,如Season.SPRING),但是枚举表示的是一个枚举项,字面含义是春天,而接口常量确是一个int类型,虽然其字面含义也是春天,但在运算中我们势必要关注其int值。
2.枚举常量属于稳态型
例如我们要描述一下春夏秋冬是什么样子,使用接口常量应该是这样写。
public void describe(int s) {
// s变量不能超越边界,校验条件
if (s >= 0 && s < 4) {
switch (s) {
case Season.SPRING:
System.out.println("this is spring");
break;
case Season.SUMMER:
System.out.println("this is summer");
break;
......
}
}
}
很简单,先使用switch语句判断哪一个是常量,然后输出。但问题是我们得对输入值进行检查,确定是否越界,如果常量非常庞大,校验输入就成了一件非常麻烦的事情,但这是一个不可逃避的过程,特别是如果我们的校验条件不严格,虽然编译能照样通过,但是运行期就会产生无法预知的后果。
我们再来看看枚举常量是否能够避免校验的问题,代码如下:
public void describe(Season s){
switch(s){
case Spring:
System.out.println("this is "+Season.Spring);
break;
case Summer:
System.out.println("this is summer"+Season.Summer);
break;
......
}
}
不用校验,已经限定了是Season枚举,所以只能是Season类的四个实例,即春夏秋冬4个枚举项,想输入一个int类型或其它类型?门都没有!这是我们最看重枚举的地方:在编译期间限定类型,不允许发生越界的情况。
3.枚举具有内置方法
有一个简单的问题:如果要列出所有的季节常量,如何实现呢?接口常量或类常量可以通过反射来实现,这没错,只是虽然能实现,但会非常繁琐,大家可以自己写一个反射类实现此功能(当然,一个一个地动手打印出输出常量,也可以算是列出)。对于此类问题可以非常简单的解决,代码如下:
public void query() {
for (Season s : Season.values()) {
System.out.println(s);
}
}
通过values方法获得所有的枚举项,然后打印出来即可。如此简单,得益于枚举内置的方法,每个枚举都是java.lang.Enum的子类,该基类提供了诸如获得排序值的ordinal方法、compareTo比较方法等,大大简化了常量的访问。
4.枚举可以自定义的方法
这一点似乎并不是枚举的优点,类常量也可以有自己的方法呀,但关键是枚举常量不仅可以定义静态方法,还可以定义非静态方法,而且还能够从根本上杜绝常量类被实例化。比如我们要在常量定义中获得最舒服季节的方法,使用常量枚举的代码如下:
enum Season {
Spring, Summer, Autumn, Winter;
public static Season getComfortableSeason(){
return Spring;
}
}
我们知道,每个枚举项都是该枚举的一个实例,对于我们的例子来说,也就表示Spring其实是Season的一个实例,Summer也是其中一个实例,那我们在枚举中定义的静态方法既可以在类(也就是枚举Season)中引用,也可以在实例(也就是枚举项Spring、Summer、Autumn、Winter)中引用,看如下代码:
public static void main(String[] args) {
System.out.println("The most comfortable season is "+Season.getComfortableSeason());
}
那如果使用类常量要如何实现呢?代码如下:
class Season {
public final static int SPRING = 0;
public final static int SUMMER = 1;
public final static int AUTUMN = 2;
public final static int WINTER = 3;
public static int getComfortableSeason(){
return SPRING;
}
}
想想看,我们怎么才能打印出"The most comfortable season is Spring" 这句话呢?除了使用switch和if判断之外没有其它办法了。
虽然枚举在很多方面比接口常量和类常量好用,但是有一点它是比不上接口常量和类常量的,那就是继承,枚举类型是不能继承的,也就是说一个枚举常量定义完毕后,除非修改重构,否则无法做扩展,而接口常量和类常量则可以通过继承进行扩展。但是,一般常量在项目构建时就定义完毕了,很少会出现必须通过扩展才能实现业务逻辑的场景。
注意: 在项目中推荐使用枚举常量代替接口常量或类常量。
建议84:使用构造函数协助描述枚举项
一般来说,我们经常使用的枚举项只有一个属性,即排序号,其默认值是从0、1、2......,这一点我们很熟悉,但是除了排序号之外,枚举还有一个(或多个)属性:枚举描述,他的含义是通过枚举的构造函数,声明每个枚举项(也就是枚举的实例)必须具有的属性和行为,这是对枚举项的描述或补充,目的是使枚举项描述的意义更加清晰准确。例如有这样一段代码:
public enum Season {
Spring("春"), Summer("夏"), Autumn("秋"), Winter("冬");
private String desc;
Season(String _desc) {
desc = _desc;
}
//获得枚举描述
public String getDesc() {
return desc;
}
}
其枚举选项是英文的,描述是中文的,如此设计使其表述的意义更加精确,方便了多个作者共同引用该常量。若不考虑描述的使用(即访问getDesc方法),它与如下接口定义的描述很相似:
interface Season{
//春
int SPRING =0;
//夏
int SUMMER =1;
//......
}
比较两段代码,很容易看出使用枚举项描述是一个很好的解决办法,非常简单、清晰。因为是一个描述(Description),那我们在开发时就可以赋予更多的含义,比如可以通过枚举构造函数声明业务值,定义可选项,添加属性等,看如下代码:
enum Role {
Admin("管理员", new LifeTime(), new Scope()), User("普通用户", new LifeTime(), new Scope());
private String name;
private LifeTime lifeTime;
private Scope scope;
/* setter和getter方法略 */
Role(String _name, LifeTime _lifeTime, Scope _scope) {
name = _name;
lifeTime = _lifeTime;
scope = _scope;
}
}
class LifeTime {
}
class Scope {
}
这是一个角色定义类,描述了两个角色:管理员和普通用户,同时它还通过构造函数对这两个角色进行了描述:
name:表示的是该角色的中文名称
lifeTime:表示的是该角色的生命周期,也就是多长时间该角色失效
scope:表示的该角色的权限范围
大家可以看出,这样一个描述可以使开发者对Admin和User两个常量有一个立体多维度的认知,有名称,有周期,还有范围,而且还可以在程序中方便的获得此类属性。所以,推荐大家在枚举定义中为每个枚举项定义描述,特别是在大规模的项目开发中,大量的常量定义使用枚举项描述比在接口常量或类常量中增加注释的方式友好的多,简洁的多。
建议85:小心switch带来的空指针异常
使用枚举定义常量时。会伴有大量switch语句判断,目的是为了每个枚举项解释其行为,例如这样一个方法:
public static void doSports(Season season) {
switch (season) {
case Spring:
System.out.println("春天放风筝");
break;
case Summer:
System.out.println("夏天游泳");
break;
case Autumn:
System.out.println("秋天是收获的季节");
break;
case Winter:
System.out.println("冬天滑冰");
break;
default:
System.out.println("输出错误");
break;
}
}
上面的代码传入了一个Season类型的枚举,然后使用switch进行匹配,目的是输出每个季节的活动,现在的问题是这段代码又没有问题:
我们先来看看它是如何被调用的,因为要传递进来的是Season类型,也就是一个实例对象,那当然允许为空了,我们就传递一个null值进去看看代码又没有问题,如下:
public static void main(String[] args) {
doSports(null);
}
似乎会打印出“输出错误”,因为switch中没有匹配到指定值,所以会打印出defaut的代码块,是这样的吗?不是,运行后的结果如下:
Exception in thread "main" java.lang.NullPointerException
at com.book.study85.Client85.doSports(Client85.java:8)
at com.book.study85.Client85.main(Client85.java:28)
竟然是空指针异常,也就是switch的那一行,怎么会有空指针呢?这就与枚举和switch的特性有关了,此问题也是在开发中经常发生的。我们知道,目前Java中的switch语句只能判断byte、short、char、int类型(JDk7允许使用String类型),这是Java编译器的限制。问题是为什么枚举类型也可以跟在switch后面呢?
因为编译时,编译器判断出switch语句后跟的参数是枚举类型,然后就会根据枚举的排序值继续匹配,也就是或上面的代码与以下代码相同:
public static void doSports(Season season) {
switch (season.ordinal()) {
case season.Spring.ordinal():
System.out.println("春天放风筝");
break;
case season.Summer.ordinal():
System.out.println("夏天游泳");
break;
//......
}
}
看明白了吧,switch语句是先计算season变量的排序值,然后与枚举常量的每个排序值进行对比,在我们的例子中season是null,无法执行ordinal()方法,于是就报空指针异常了。问题清楚了,解决很简单,在doSports方法中判断输入参数是否为null即可。
建议86:在switch的default代码块中增加AssertionError错误
switch后跟枚举类型,case后列出所有的枚举项,这是一个使用枚举的主流写法,那留着default语句似乎没有任何作用,程序已经列举了所有的可能选项,肯定不会执行到defaut语句,看上去纯属多余嘛!错了,这个default还是很有作用的。以我们定义的日志级别来说明,这是一个典型的枚举常量,如下所示:
enum LogLevel{
DEBUG,INFO,WARN,ERROR
}
一般在使用的时候,会通过switch语句来决定用户设置的日志级别,然后输出不同级别的日志代码,代码如下:
switch(LogLevel)
{
case:DEBUG:
//.....
case:INFO:
//......
case:WARN:
//......
case:ERROR:
//......
}
由于把所有的枚举项都列举完了,不可能有其它值,所以就不需要default代码快了,这是普遍认识,但问题是我们的switch代码与枚举之间没有强制约束关系,也就是说两者只是在语义上建立了联系,并没有一个强制约束,比如LogLevel的枚举项发生变化了,增加了一个枚举项FATAL,如果此时我们对switch语句不做任何修改,编译虽不会出问题,但是运行期会发生非预期的错误:FATAL类型的日志没有输出。
为了避免出现这类错误,建议在default后直接抛出一个AssertionError错误,其含义就是“不要跑到这里来,一跑到这里就会出问题”,这样可以保证在增加一个枚举项的情况下,若其它代码未修改,运行期马上就会出错,这样一来就很容易找到错误,方便立即排除。
当然也有其它方法解决此问题,比如修改IDE工具,以Eclipse为例,可以把Java-->Compiler--->Errors/Warnings中的“Enum type constant not covered on 'switch' ”设置为Error级别,如果不判断所有的枚举项就不能编译通过。
建议87:使用valueOf前必须进行校验
我们知道每个枚举项都是java.lang.Enum的子类,都可以访问Enum类提供的方法,比如hashCode、name、valueOf等,其中valueOf方法会把一个String类型的名称转换为枚举项,也就是在枚举项中查找出字面值与参数相等的枚举项。虽然这个方法简单,但是JDK却做了一个对于开发人员来说并不简单的处理,我们来看代码:
public static void main(String[] args) {
// 注意summer是小写
List<String> params = Arrays.asList("Spring", "summer");
for (String name : params) {
// 查找字面值与name相同的枚举项,其中Season是前面例子中枚举Season
Season s = Season.valueOf(name);
if (null != s) {
// 有枚举项时
System.out.println(s);
} else {
// 没有该枚举项
System.out.println("无相关枚举项");
}
}
}
这段程序看起来没什么错吧,其中考虑到从String转换为枚举类型可能存在着转换不成功的情况,比如没有匹配找到指定值,此时ValueOf的返回值应该为空,所以后面又跟着if...else判断输出。我们看看运行结果
Spring
Exception in thread "main" java.lang.IllegalArgumentException: No enum constant com.book.study01.Season.summer
at java.lang.Enum.valueOf(Unknown Source)
at com.book.study01.Season.valueOf(Season.java:1)
at com.book.study85.Client85.main(Client85.java:14)
报无效的参数异常,也就说我们的summer(注意s是小写),无法转换为Season枚举,无法转换就 不转换嘛,那也别抛出IllegalArgumentException异常啊,一但抛出这个异常,后续的代码就不会执行了,这与我们的习惯不符合呀,例如我们从List中查找一个元素,即使不存在也不会报错,顶多indexOf方法返回-1。那么我们来深入分析一下该问题,valueOf方法的源代码如下:
public static <T extends Enum<T>> T valueOf(Class<T> enumType, String name) {
//通过反射,从常量列表中查找
T result = enumType.enumConstantDirectory().get(name);
if (result != null)
return result;
if (name == null)
throw new NullPointerException("Name is null");
//最后抛出无效参数异常
throw new IllegalArgumentException("No enum constant " + enumType.getCanonicalName() + "." + name);
}
valueOf方法先通过反射从枚举类的常量声明中查找,若找到就直接返回,若找不到则抛出无效参数异常。valueOf的本意是保护编码中的枚举安全性,使其不产生空枚举对象,简化枚举操作,但是却引入了一个我们无法避免的IllegalArgumentException异常。
大家是否觉得此处的valueOf方法的源码不对,这里要输入两个参数,而我们的Season.valueOf只传递一个String类型的参数,真的是这样吗?是的,因为valueOf(String name)方法是不可见的,是JVM内置的方法,我们只有通过阅读公开的valueOf方法来了解其运行原理了。
问题清楚了,有两个方法可以解决此问题:
(1)、使用try......catch捕捉异常
这里是最直接也是最简单的方式,产生IllegalArgumentException即可确认为没有同名的枚举的枚举项,代码如下:
try{
Season s = Season.valueOf(name);
//有该枚举项时
System.out.println(s);
}catch(Exception e){
e.printStackTrace();
System.out.println("无相关枚举项");
}
(2)、扩展枚举类:由于Enum类定义的方法基本上都是final类型的,所以不希望被覆写,我们可以学习String和List,通过增加一个contains方法来判断是否包含指定的枚举项,然后再继续转换,代码如下。
enum Season {
Spring, Summer, Autumn, Winter;
// 是否包含指定的枚举项
public static boolean contains(String name) {
// 所有的枚举值
Season[] season = values();
for (Season s : season) {
if (s.name().equals(name)) {
return true;
}
}
return false;
}
}
Season枚举具备了静态方法contains后,就可以在valueOf前判断一下是否包含指定的枚举名称了,若包含则可以通过valueOf转换为枚举,若不包含则不转换。
建议88:用枚举实现工厂方法模式更简洁
工厂方法模式(Factory Method Pattern)是" 创建对象的接口,让子类决定实例化哪一个类,并使一个类的实例化延迟到其它子类"。工厂方法模式在我们的开发中经常会用到。下面以汽车制造为例,看看一般的工厂方法模式是如何实现的,代码如下:
//抽象产品
interface Car{
}
//具体产品类
class FordCar implements Car{
}
//具体产品类
class BuickCar implements Car{
}
//工厂类
class CarFactory{
//生产汽车
public static Car createCar(Class<? extends Car> c){
try {
return c.newInstance();
} catch (InstantiationException | IllegalAccessException e) {
e.printStackTrace();
}
return null;
}
}
这是最原始的工厂方法模式,有两个产品:福特汽车和别克汽车,然后通过工厂方法模式来生产。有了工厂方法模式,我们就不用关心一辆车具体是怎么生成的了,只要告诉工厂" 给我生产一辆福特汽车 "就可以了,下面是产出一辆福特汽车时客户端的代码:
public static void main(String[] args) {
//生产车辆
Car car = CarFactory.createCar(FordCar.class);
}
这就是我们经常使用的工厂方法模式,但经常使用并不代表就是最优秀、最简洁的。此处再介绍一种通过枚举实现工厂方法模式的方案,谁优谁劣你自行评价。枚举实现工厂方法模式有两种方法:
(1)、枚举非静态方法实现工厂方法模式
我们知道每个枚举项都是该枚举的实例对象,那是不是定义一个方法可以生成每个枚举项对应产品来实现此模式呢?代码如下:
enum CarFactory {
// 定义生产类能生产汽车的类型
FordCar, BuickCar;
// 生产汽车
public Car create() {
switch (this) {
case FordCar:
return new FordCar();
case BuickCar:
return new BuickCar();
default:
throw new AssertionError("无效参数");
}
}
}
create是一个非静态方法,也就是只有通过FordCar、BuickCar枚举项才能访问。采用这种方式实现工厂方法模式时,客户端要生产一辆汽车就很简单了,代码如下:
public static void main(String[] args) {
// 生产车辆
Car car = CarFactory.BuickCar.create();
}
(2)、通过抽象方法生成产品
枚举类型虽然不能继承,但是可以用abstract修饰其方法,此时就表示该枚举是一个抽象枚举,需要每个枚举项自行实现该方法,也就是说枚举项的类型是该枚举的一个子类,我们俩看代码:
enum CarFactory {
// 定义生产类能生产汽车的类型
FordCar{
public Car create(){
return new FordCar();
}
},
BuickCar{
public Car create(){
return new BuickCar();
}
};
//抽象生产方法
public abstract Car create();
}
首先定义一个抽象制造方法create,然后每个枚举项自行实现,这种方式编译后会产生CarFactory的匿名子类,因为每个枚举项都要实现create抽象方法。客户端调用与上一个方案相同,不再赘述。
大家可能会问,为什么要使用枚举类型的工厂方法模式呢?那是因为使用枚举类型的工厂方法模式有以下三个优点:
避免错误调用的发生:一般工厂方法模式中的生产方法(也就是createCar方法),可以接收三种类型的参数:类型参数(如我们的例子)、String参数(生产方法中判断String参数是需要生产什么产品)、int参数(根据int值判断需要生产什么类型的的产品),这三种参数都是宽泛的数据类型,很容易发生错误(比如边界问题、null值问题),而且出现这类错误编译器还不会报警,例如:
public static void main(String[] args) {
// 生产车辆
Car car = CarFactory.createCar(Car.class);
}
Car是一个接口,完全合乎createCar的要求,所以它在编译时不会报任何错误,但一运行就会报出InstantiationException异常,而使用枚举类型的工厂方法模式就不存在该问题了,不需要传递任何参数,只需要选择好生产什么类型的产品即可。
性能好,使用简洁:枚举类型的计算时以int类型的计算为基础的,这是最基本的操作,性能当然会快,至于使用便捷,注意看客户端的调用,代码的字面意思就是" 汽车工厂,我要一辆别克汽车,赶快生产"。
降低类间耦合:不管生产方法接收的是Class、String还是int的参数,都会成为客户端类的负担,这些类并不是客户端需要的,而是因为工厂方法的限制必须输入的,例如Class参数,对客户端main方法来说,他需要传递一个FordCar.class参数才能生产一辆福特汽车,除了在create方法中传递参数外,业务类不需要改Car的实现类。这严重违背了迪米特原则(Law of Demeter 简称LoD),也就是最少知识原则:一个对象应该对其它对象有最少的了解。
而枚举类型的工厂方法就没有这种问题了,它只需要依赖工厂类就可以生产一辆符合接口的汽车,完全可以无视具体汽车类的存在。
建议89:枚举项的数量限制在64个以内
为了更好地使用枚举,Java提供了两个枚举集合:EnumSet和EnumMap,这两个集合使用的方法都比较简单,EnumSet表示其元素必须是某一枚举的枚举项,EnumMap表示Key值必须是某一枚举的枚举项,由于枚举类型的实例数量固定并且有限,相对来说EnumSet和EnumMap的效率会比其它Set和Map要高。
虽然EnumSet很好用,但是它有一个隐藏的特点,我们逐步分析。在项目中一般会把枚举用作常量定义,可能会定义非常多的枚举项,然后通过EnumSet访问、遍历,但它对不同的枚举数量有不同的处理方式。为了进行对比,我们定义两个枚举,一个数量等于64,一个是65(大于64即可,为什么是64而不是128,512呢,一会解释),代码如下:
//普通枚举项,数量等于64
enum Const{
A,B,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z,
AA,BB,CC,DD,EE,FF,GG,HH,II,JJ,KK,LL,MM,NN,OO,PP,QQ,RR,SS,TT,UU,VV,WW,XX,YY,ZZ,
AAA,BBB,CCC,DDD,EEE,FFF,GGG,HHH,III,JJJ,KKK,LLL
}
//大枚举,数量超过64
enum LargeConst{
A,B,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z,
AA,BB,CC,DD,EE,FF,GG,HH,II,JJ,KK,LL,MM,NN,OO,PP,QQ,RR,SS,TT,UU,VV,WW,XX,YY,ZZ,
AAAA,BBBB,CCCC,DDDD,EEEE,FFFF,GGGG,HHHH,IIII,JJJJ,KKKK,LLLL,MMMM
}
Const的枚举项数量是64,LagrgeConst的枚举项数量是65,接下来我们希望把这两个枚举转换为EnumSet,然后判断一下它们的class类型是否相同,代码如下:
public class Test89 {
public static void main(String[] args) {
EnumSet<Const> cs = EnumSet.allOf(Const.class);
EnumSet<LargeConst> lcs = EnumSet.allOf(LargeConst.class);
// 打印出枚举数量
System.out.println("Const的枚举数量:" + cs.size());
System.out.println("LargeConst的枚举数量:" + lcs.size());
// 输出两个EnumSet的class
System.out.println(cs.getClass());
System.out.println(lcs.getClass());
}
}
程序很简单,现在的问题是:cs和lcs的class类型是否相同?应该相同吧,都是EnumSet类的工厂方法allOf生成的EnumSet类,而且JDK API也没有提示EnumSet有子类。我们来看看输出结果:
Const的枚举数量:64
LargeConst的枚举数量:65
class java.util.RegularEnumSet
class java.util.JumboEnumSet
很遗憾,两者不相等。就差一个元素,两者就不相等了?确实如此,这也是我们重点关注枚举项数量的原因。先来看看Java是如何处理的,首先跟踪allOf方法,其源码如下:
public static <E extends Enum<E>> EnumSet<E> allOf(Class<E> elementType) {
//生成一个空EnumSet
EnumSet<E> result = noneOf(elementType);
//加入所有的枚举项
result.addAll();
return result;
}
allOf通过noneOf方法首先生成了一个EnumSet对象,然后把所有的枚举都加进去,问题可能就出在EnumSet的生成上了,我们来看看noneOf的源码:
public static <E extends Enum<E>> EnumSet<E> noneOf(Class<E> elementType) {
//获得所有的枚举项
Enum[] universe = getUniverse(elementType);
if (universe == null)
throw new ClassCastException(elementType + " not an enum");
//枚举数量小于等于64
if (universe.length <= 64)
return new RegularEnumSet<>(elementType, universe);
else
//枚举数量大于64
return new JumboEnumSet<>(elementType, universe);
}
看到这里,恍然大悟,Java原来是如此处理的:当枚举项数量小于等于64时,创建一个RegularEnumSet实例对象,大于64时则创建一个JumboEnumSet实例对象。
为什么要如此处理呢?这还要看看这两个类之间的差异,首先看RegularEnumSet类,源码如下:
class RegularEnumSet<E extends Enum<E>> extends EnumSet<E> {
private static final long serialVersionUID = 3411599620347842686L;
/**
* Bit vector representation of this set. The 2^k bit indicates the
* presence of universe[k] in this set.
*/
//记录所有的枚举号,注意是long型
private long elements = 0L;
//构造函数
RegularEnumSet(Class<E>elementType, Enum[] universe) {
super(elementType, universe);
}
//加入所有元素
void addAll() {
if (universe.length != 0)
elements = -1L >>> -universe.length;
}
//其它代码略
}
我们知道枚举项的排序值ordinal 是从0、1、2......依次递增的,没有重号,没有跳号,RegularEnumSet就是利用这一点把每个枚举项的ordinal映射到一个long类型的每个位置上的,注意看addAll方法的elements元素,它使用了无符号右移操作,并且操作数是负值,位移也是负值,这表示是负数(符号位是1)的"无符号左移":符号位为0,并补充低位,简单的说,Java把一个不多于64个枚举项映射到了一个long类型变量上。这才是EnumSet处理的重点,其他的size方法、contains方法等都是根据elements方法等都是根据elements计算出来的。想想看,一个long类型的数字包含了所有的枚举项,其效率和性能能肯定是非常优秀的。
我们知道long类型是64位的,所以RegularEnumSet类型也就只能负责枚举项的数量不大于64的枚举(这也是我们以64来举例,而不以128,512举例的原因),大于64则由JumboEnumSet处理,我们看它是怎么处理的:
class JumboEnumSet<E extends Enum<E>> extends EnumSet<E> {
private static final long serialVersionUID = 334349849919042784L;
/**
* Bit vector representation of this set. The ith bit of the jth
* element of this array represents the presence of universe[64*j +i]
* in this set.
*/
//映射所有的枚举项
private long elements[];
// Redundant - maintained for performance
private int size = 0;
JumboEnumSet(Class<E>elementType, Enum[] universe) {
super(elementType, universe);
//默认长度是枚举项数量除以64再加1
elements = new long[(universe.length + 63) >>> 6];
}
void addAll() {
//elements中每个元素表示64个枚举项
for (int i = 0; i < elements.length; i++)
elements[i] = -1;
elements[elements.length - 1] >>>= -universe.length;
size = universe.length;
}
}
JumboEnumSet类把枚举项按照64个元素一组拆分成了多组,每组都映射到一个long类型的数字上,然后该数组再放置到elements数组中,简单来说JumboEnumSet类的原理与RegularEnumSet相似,只是JumboEnumSet使用了long数组容纳更多的枚举项。不过,这样的程序看着会不会觉得郁闷呢?其实这是因为我们在开发中很少使用位移操作。大家可以这样理解:RegularEnumSet是把每个枚举项映射到一个long类型数字的每个位上,JumboEnumSet是先按照64个一组进行拆分,然后每个组再映射到一个long类型数字的每个位上。
从以上的分析可知,EnumSet提供的两个实现都是基本的数字类型操作,其性能肯定比其他的Set类型要好的多,特别是Enum的数量少于64的时候,那简直就是飞一般的速度。
注意:枚举项数量不要超过64,否则建议拆分。
建议90:小心注解继承
Java从1.5版本开始引入注解(Annotation),其目的是在不影响代码语义的情况下增强代码的可读性,并且不改变代码的执行逻辑,对于注解始终有两派争论,正方认为注解有益于数据与代码的耦合,"在有代码的周边集合数据";反方认为注解把代码和数据混淆在一起,增加了代码的易变性,消弱了程序的健壮性和稳定性。这些争论暂且搁置,我们要说的是一个我们不常用的元注解(Meta-Annotation):@Inheruted,它表示一个注解是否可以自动继承,我们开看它如何使用。
思考一个例子,比如描述鸟类,它有颜色、体型、习性等属性,我们以颜色为例,定义一个注解来修饰一下,代码如下:
import java.lang.annotation.ElementType;
import java.lang.annotation.Inherited;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Inherited
public @interface Desc {
enum Color {
White, Grayish, Yellow
}
// 默认颜色是白色的
Color c() default Color.White;
}
该注解Desc前增加了三个注解:Retention表示的是该注解的保留级别,Target表示的是注解可以标注在什么地方,@Inherited表示该注解会被自动继承。注解定义完毕,我们把它标注在类上,代码如下:
@Desc(c = Color.White)
abstract class Bird {
public abstract Color getColor();
}
// 麻雀
class Sparrow extends Bird {
private Color color;
// 默认是浅灰色
public Sparrow() {
color = Color.Grayish;
}
// 构造函数定义鸟的颜色
public Sparrow(Color _color) {
color = _color;
}
@Override
public Color getColor() {
return color;
}
}
// 鸟巢,工厂方法模式
enum BirdNest {
Sparrow;
// 鸟类繁殖
public Bird reproduce() {
Desc bd = Sparrow.class.getAnnotation(Desc.class);
return bd == null ? new Sparrow() : new Sparrow(bd.c());
}
}
上面程序声明了一个Bird抽象类,并且标注了Desc注解,描述鸟类的颜色是白色,然后编写一个麻雀Sparrow类,它有两个构造函数,一个是默认的构造函数,也就是我们经常看到的麻雀是浅灰色的,另外一个构造函数是自定义麻雀的颜色,之后又定义了一个鸟巢(工厂方法模式),它是专门负责鸟类繁殖的,它的生产方法reproduce会根据实现类注解信息生成不同颜色的麻雀。我们编写一个客户端调用,代码如下:
public static void main(String[] args) {
Bird bird = BirdNest.Sparrow.reproduce();
Color color = bird.getColor();
System.out.println("Bird's color is :" + color);
}
现在问题是这段客户端程序会打印出什么来?因为采用了工厂方法模式,它最主要的问题就是bird变量到底采用了那个构造函数来生成,是无参构造函数还是有参构造?如果我们单独看子类Sparrow,它没有被添加任何注释,那工厂方法中的bd变量就应该是null了,应该调用的是无参构造。是不是如此呢?我们来看运行结果:“Bird‘s Color is White ”;
白色?这是我们添加到父类Bird上的颜色,为什么?这是因为我们在注解上加了@Inherited注解,它表示的意思是我们只要把注解@Desc加到父类Bird上,它的所有子类都会从父类继承@Desc注解,不需要显示声明,这与Java的继承有点不同,若Sparrow类继承了Bird却不用显示声明,只要@Desc注解释可自动继承的即可。
采用@Inherited元注解有利有弊,利的地方是一个注解只要标注到父类,所有的子类都会自动具有父类相同的注解,整齐,统一而且便于管理,弊的地方是单单阅读子类代码,我们无从知道为何逻辑会被改变,因为子类没有显示标注该注解。总体上来说,使用@Inherited元注解弊大于利,特别是一个类的继承层次较深时,如果注解较多,则很难判断出那个注解对子类产生了逻辑劫持。