读编写高质量代码总结

目录

目的

减少bug,规范写代码。

一、Java基础有关问题及建议

例如常量和变量的注意事项,如何更安全地序列化、断言到底该如何使用等。

建议1:不要在常量和变量中出现易混淆的字母

字母"l"作为长整型标志务必大写;大写O不要和数字混用。

建议2:不要让常量"蜕变"成变量

public static final int RAND_CONST = new Random().nextInt();常量就是常量,在编译期就必须确定其值,不应该在运行期改变,否则程序的可读性很差。

务必让常量的值在运行期间保持不变

建议3:三元操作符的类型务必一致

String s = String.valueOf(i<100?90:100);
String s1 = String.valueOf(i<100?90:100.0);

当i=80时,s和s1是不相等的,s1的两个操作数类型不一致,编译器会进行类型转换,而且转化为大范围。

三元操作符类型的转换规则:

  • 若两个操作数不可转换,则不作转换,返回值为Object类型
  • 若两个操作数是明确类型的表达式(比如变量),则按照正常的二进制数字来转换,int类型转换为long类型,long类型转换为float类型等
  • 若两个操作数中有一个是数字S,另外一个是表达式,且其类型表示为T,那么,若数字S在T的范围内,则转换为T类型;若S超出了T类型的范围,则T转化为S类型
  • 若两个操作数都是直接量数字,则返回值类型为范围较大者

保证三元操作符中的两个操作数类型一致,即可减少可能错误的发送

建议4:避免带有变长参数的方法重载

Java5中引入变长参数(varags)为了更好地提高方法的复用性,让方法的调用者可以"随心所欲"地传递实参数量,当然变长参数也是要遵循一定规则的,比如变长参数必须是方法中的最后一个参数;一个方法不能定义多个变长参数等

建议5:别让null值和空值威胁到变长方法

public void methodA(String str,Integer... is) {
    
}
public void methodA(String str,String... strs){
    
}

两个methodA方法都进行了重载,如果将来这两个方法都是要提供给调用方的;

调用方传入变长参数的实参数量可以是N个(>=0),当然可以写成methodA("china");但是程序却不能运行,这是methodA方法设计者的责任,他违反了kiss原则(keep it simple,stupid懒人原则)。

建议6:覆写变长方法也循规蹈矩

覆写必须满足的条件:

  • 重写的方法不能缩小访问权限
  • 参数列表必须与被重写方法相同
  • 返回类型必须与重写方法相同或是他的子类
  • 重写方法不能抛出新的异常,或者超出父类范围的异常,但是可以抛出更少、更有限的异常,或者不抛出异常

图片里的Sub类的fun方法也是进行了覆写,因为父类的fun方法编译成字节码后的形参是一个int类型的形参加上一个int数组类型的形参,子类的参数列表也与此相同。

覆写的方法参数与父类相同,不仅仅是类型、数量,还包括显示形式

建议7:警惕自增的陷阱

public static void main(String[] args) {
        int count = 0;
        for (int i=0;i<10;i++){
            count = count ++;
        }
        System.out.println("count="+count);
    }

这段程序输出的count结果是0。

count++是一个表达式,是有返回值的,它的返回值就是count自加前的值,Java对自加是这样处理的:首先把count的值(注意是值,不是引用)拷贝到一个临时变量区,然后对count变量加1,最后返回临时变量区的值。程序第一次循环时的详细处理步骤如下:

1. JVM把count值(0)拷贝到临时变量区
2. count值加1,这时候count的值是1
3. 返回临时变量区的值,注意这个值是0,没修改过
4. 返回值赋给count,此时count值被重置成0

count=count++这条语句可以按如下代码理解:

public static int mockAdd(int count) {
        // 先保存初始值
        int temp = count;
        // 做自增操作
        count = count +1;
        // 返回原始值
        return temp;
    }

于是第一次循环后count的值还是0,其他9次的循环也是一样的,最终你会发现count的值始终没有改变,仍然保持最初的状态。

建议9:少用静态导入

从Java5开始引入了静态导入语法(import static),其目的是为了减少字符输入量,提高代码的可阅读性,以便更好地理解程序。

对于静态导入,一定要遵循两个规则:

  • 不使用*通配符,除非是导入静态常量类(只包含常量的类或接口)
  • 方法名是具有明确、清晰表象意义的工具类

建议10:不要再本类中覆盖静态导入的变量和方法

建议11:养成良好习惯,显式声明UID

SerialVersionUID,也叫做流标识符,即类的版本定义的,它可以显式声明也可以隐式声。显式声明格式:private static final long serialVersionUID =xxxL,而隐式声明式我不声明,你编译器在编译的时候帮我生成。生成的依据是通过包名、类名、继承关系、非私有的方法和属性,以及参数、返回值等诸多因子计算的出的,极度复杂,基本上计算出来的这个值是惟一的。

显示声明serialVersionUID可以避免对象不一致,但尽量不要以这种方式向JVM撒谎

建议12:避免用序列化类,在构造函数中为不变量赋值

我们知道带有final标识的属性是不变量,也就是说只能赋值一次,不能重复赋值,但是在序列化类中就有点复杂了。

final属性是一个直接量,在反序列化时就会重新计算。

在序列化类中,使用构造方法为final变量赋值,很可能就会出现反序列化生成的final变量值与新产生的实例值不相同的情况。

建议13:避免为final变量复杂赋值

反序列化时final变量在以下情况下不会被重新赋值:

  • 通过构造函数为final变量赋值
  • 通过方法返回值为final变量赋值
  • final修饰的属性不是基本类型

建议14:使用序列化的私有方法巧妙解决部分属性持久化问题

Java调用ObjectOutputStream类把一个对象转换成流数据时,会通过反射(Reflection)检查被序列化的类是否有writeObject方法,并且检查其是否符合私有、无返回值的特性。若有,则会委托该方法进行对象序列化,若没有,则由ObjectOutputStream按照默认规则继续序列化。同样,在从流数据恢复成实例对象时,也会检查是否有一个私有的readObject方法,如果有,则会通过该方法读取属性值。此处有几个关键点需要说明:

  1. out.defaultWriteObject()告知JVM按照默认的规则写入对象,惯例的写法是写在第一句话里。
  2. in.defaultReadObject()告知JVM按默认规则读入对象,惯例的写法也是写在第一句话里
  3. out.writeXX和in.readXX 分别是写入和读出相应的值,类似一个队列,先进先出,如果此处有复杂的数据逻辑,建议封装Collection对象处理

建议15:break万万不可忘

建议16:易变业务使用脚本语言编写

建议17:慎用动态编译

建议18:避免instanceof非预期结果

instanceof是一个简单地二元操作符,它是用来判断一个对象是否是一个类实例的,其操作类似>=、==,非常简单

		boolean b = "String" instanceof Object;
        boolean b1 = new String() instanceof String;
        boolean b2 = new Object() instanceof String;
        boolean b3 = 'A' instanceof Character;
        boolean b4 = null instanceof String;
        boolean b5 = (String) null instanceof String;
        boolean b6 = new Date() instanceof String;

上面的代码中b和b1返回值是ture;

b2返回值是false,这句话能编译通过,只要instanceof关键字的左右两个操作数有继承或实现关系,就可以编译通过。

b3,编译不能通过,因为‘A'是一个char类型,也就是一个基本类型,不是一个对象,instanceof只能用于对象的判断,不能用于基本类型的判断

b4返回值是false,这是instanceof特有的规则;若左操作数是null,结果就直接返回false,不再运算右操作数是什么类。

b5范沪指也是false,null是一个万用类型,也可以说它没类型,即使做类型转换还是个null

b6编译通不过,因为Date类和String类没有继承或实现关系,所以在编译时直接就报错了。instanceof操作符的左右操作数必须有继承或实现关系,否则编译会失败。

二、基本类型篇

Java中的基本数据类型(Primitive Data Types)有8个:byte、char、short、int、long、float、double、boolean,他们是Java最基本的单元。

建议21:用偶判断,不用奇判断

Java中,当符号左右两边都是int型时,%表示取余数,/表示取商的整数位;当有一个为float型时,返回类型也就是float型了。

i%2 ==0?"偶数":"奇数"中用偶数0作为条件判断才是正确的表示

建议22:用整数类型处理货币

一般金融行业的计算方法,要求的就是精确;而计算机由于在二进制中无法保存1/5这个十进制数,像这种情况,要解决此问题有两种方法:

  1. 使用BigDecimal
  2. 使用整型:把参与运算的值扩大100倍,并转变为整型,然后再展示的时候再缩小100倍,这样处理的好处是计算简单、准确,一般在非金融行业应用较多

建议23:不要让类型默默转换

public static final int LIGHT_SPEED = 30*10000*1000;
public static void main(String[] args) {
    System.out.println("太阳照射到地球需要8分钟,计算太阳到地球的距离。")
    long dis = LIGHT_SPEED * 60 * 8;
    System.out.println(dis)
}

输出结果是-2028888064,虽然已经用long来接收了;但是Java是先运算再进行类型转换的,所以dis右边的三个int型相乘也是int型,但是超过最大值也就是负值了,再转化为long型,结果还是负值。

所以可以主动声明式类型转化(非强制转化)long dis = 1L *LIGHT_SPEED * 60 * 8;

建议24:边界,边界,还是边界

比如后台限制用户能购买20件商品(i+current<20),但是前端传输过来的条件i是2147483647、current为10,却还是能够购买成功。这是因为int型的最大值就是2147483647,如果加上10就超出范围了,那么实际结果为负就能通过条件判断了。

建议25:不要让四舍五入亏了一方

Java中Math.round()方法,用来获得指定精度的整数或小数;

sout("10.5近似值:"+Math.round(10.5));
sout("-10.5近似值:"+Math.round(-10.5));

上面两个输出结果是:10.5近似值:11,-10.5近似值:-10;

两个绝对值相同的数字,近似值为什么不同?因为Math.round采用的舍入规则所决定的(采用的是正无穷方向舍入规则)。

而对于银行计算利息来说,如果是采用这种四舍五入规则,那么银行会亏损,所以银行家对此提出一个修正算法,叫做银行家舍入。

根据不同的场景,慎重选择不同的舍入模式,以提高项目的精确度,减少算法损失

建议26:提防包装类型的null值

包装类型参与运算时,要做null值校验。包装对象和拆箱对象可以自由转换,但是需要剔除null值,null值并不能转化为基本类型。

建议27:谨慎包装类型的大小比较

包装类型比较不要直接使用 == < >等数学符号,需要使用compareTo方法。只要是两个对象之间的比较就应该采用相应的方法,而不是通过Java默认机制来处理。

建议28:优先使用整型池

装箱动作是通过valueOf方法实现的。通过valueOf产生包装对象时,如果int参数在-128到127之间,则直接从整型池中获取对象,不在该范围的int类型则通过new生成包装对象。

整型池的存在不仅仅提高了系统性能,同时也节约了内存空间,这也是我们使用整型池的原因。也就是在声明包装对象的时候使用valueOf生成,而不是通过构造函数来生成的原因。提醒:在判断对象是否相等的时候,最好是用equals方法,避免用"=="产生非预期结果。

建议29:优先选择基本类型

自动装箱有一个重要的原则:基本类型可以先加宽,再转变成宽类型的包装类型,但不能直接转变成宽类型的包装类型。简单地说就是,int可以加宽转变成long,然后再转变成Long对象,但不能直接转变成包装类型,注意这里指的都是自动转换。

自动装箱(拆箱)虽然很方便,但引起的问题也非常严重——我们甚至不知道执行的是哪个方法。

注意: 重申,基本类型优先考虑;无论是从安全性、性能方面来说,还是稳定性方面来说,基本类型都是首选方案。

建议30:不要随便设置随机种子

看下面这段代码,其中使用了Random的有参构造方法。

public static void main(String[] args) {
        Random random = new Random(1000);
        for (int i = 1; i < 4; i++) {
            System.out.println("第"+i+"次:"+random.nextInt());
        }
    }

这段代码,在同一台机器上,不管运行多少次,所打印的随机数都是相同的。只要是在同一台硬件机器上,就永远都会打印出相同的随机数,问题何在?

原因:产生随机数的种子被固定了,在Java中,随机数的产生取决于种子,随机数和种子之间的关系遵从以下两个规则:

  • 种子不同,产生不同的随机数
  • 种子相同,即使实例不同也产生相同的随机数

Random类的默认种子(无参构造)是System.nanoTime()的返回值,这个值是距离某一个固定时间点的纳秒数,不同的操作系统和硬件有不同的固定时间点,也就是说不同的操作系统其纳秒值是不同的,而同一个操作系统纳秒值也会不同,随机数自然就不同了。

三、类、对象及方法

主要讲述关于Java类、对象、方法的种种规则、限制及建议

建议31:在接口中不要存在实现代码

public class Client {
    public static void main(String[] args) {
        B.s.doSomething();
    }
}

interface B {
    public static final S s = new S() {
        @Override
        public void doSomething() {
            System.out.println("我在接口中实现了");
        }
    };
}

interface S {
    public void doSomething();
}

这段代码中main方法里面,调用了B接口的常量s,在B接口没有任何显式实现类的情况下,竟然打印出了结果。

但是在一般的项目中,此类代码是严禁出现的,原因很简单:接口是一个契约,不仅仅约束着实现者,同时也是一个保证,保证提供的服务(常量、方法)是稳定、可靠的,如果把实现代码写到接口中,那接口就绑定了可能变化的因素,这就会导致实现不再稳定和可靠,是随时都可能被抛弃、被修改、被重构的。

建议32:静态变量一定要先声明后赋值

public class Client {
    static {
        i =100;
    }
    public static int i = 1;
    public static void main(String[] args) {
        System.out.println(i);
    }
}

上面的代码和下面的代码区别就是调换了变量i的声明和赋值位置

public class Client {
    public static int i = 1;
    static {
        i =100;
    }
    public static void main(String[] args) {
        System.out.println(i);
    }
}

第一段代码输出:1,第二段代码输出:100;第一段代码中变量i可是先使用(也就是赋值)后声明的。

这就要知道静态变量的产生了:静态变量是类加载时被分配到数据区(Data Area)的,它在内存中只有一个拷贝,不会被分配多次,其后的所有赋值操作都是值改变,地址保持不变。

静态变量是在类初始化时首先被加载的,JVM会去查找类中所有的静态声明,然后分配空间,注意这时候只是完成了地址空间的分配,还没有赋值,之后JVM会根据类中静态赋值(包括静态类赋值和静态块赋值)的先后顺序来执行。对于程序来说,就是先声明了int类型的地址空间,并把地址传递给了i,然后按照第一段代码类中的先后顺序执行赋值动作,首先执行静态块中i=100,接着执行i=1,那最后的结果就是i=1了。

如果有多个静态块对i继续赋值?谁的位置最靠后谁就有最终的决定权

注意:变量要先声明后使用,这不是一句废话。

建议33:不要覆写静态方法

覆写是针对非静态方法的,不能针对静态方法。

public class Client {
    public static void main(String[] args) {
        Base sub = new Sub();
        sub.doAnything();
        sub.doSomething();
    }
}

class Base{
    static void doSomething(){
        System.out.println("父类静态方法被调用");
    }
    void doAnything() {
        System.out.println("父类非静态方法被调用");
    }
}

class Sub extends Base {
    static void doSomething(){
        System.out.println("子类静态方法被调用");
    }

    @Override
    void doAnything() {
        System.out.println("子类非静态方法被调用");
    }
}

输出结果:

子类非静态方法被调用 父类静态方法被调用

我们知道一个实例对象有两个类型:表面类型(Apparent Type)和实际类型(Actual Type),表面类型是声明时的类型,实际类型是对象产生时的类型,比如我们的例子,变量sub的表面类型是Base,实际类型是Sub。对于非静态方法,它是根据对象的实际类型来执行的,也就是执行了Sub类中的doAnything方法。而对于静态方法来说就比较特殊了,首先静态方法不依赖实例对象,它是通过类名访问的;其次,可以通过对象访问静态方法,如果是通过对象调用静态方法,JVM则会通过对象的表面类型查找静态方法的入口,继而执行之。

在子类中构建与父类相同的方法名、输入参数、输出参数、访问权限(权限可以扩大),并且父类、子类都是静态方法,此种行为叫做隐藏,它和覆写有两点不同:

  • 表现形式不同。隐藏用于静态方法,覆写用于非静态方法。在代码上的表现是:@Override注解可以用于覆写,不能用于隐藏
  • 职责不同。隐藏的目的是为了抛弃父类静态方法,重现子类方法,例如我们的例子,Sub.doSomething的出现是为了遮盖父类的Base.doSomething方法,也就是期望父类的静态方法不要破坏子类的业务行为;而覆写则是将父类的行为增强或减弱,延续父类的职责。

静态方法不能覆写,虽然不能覆写,但是可以隐藏。通过实例对象访问静态方法属性不是好习惯,它给代码带来了坏味道。

建议34:构造函数尽量简化

public class Client {
    public static void main(String[] args) {
        new SimpleServer(1000);
    }
}

abstract class Server {
    final static int DEFAULT_PORT=40000;
    public Server(){
        int port = getPort();
        System.out.println("端口号:"+port);
    }

    protected abstract int getPort();
}

class SimpleServer extends Server {

    private int port = 100;

    public SimpleServer(int port) {
        this.port = port;
    }

    @Override
    protected int getPort() {
        return Math.random() >0.5?port:DEFAULT_PORT;
    }
}

上面代码的意图如下:

  1. 通过SimpleServer的构造函数接收端口参数
  2. 子类的构造函数默认调用父类的构造函数(隐式调用super())
  3. 父类构造函数调用子类的getPort方法获得端口号
  4. 父类构造函数建立端口监听机制
  5. 对象创建完毕,服务监听启动,正常运行。

不过上面的代码在多次运行的情况下,会出现"端口号:0"。这个0是从哪里来的呢?

要解释这个问题,首先要知道子类是如何实例化的。子类实例化时,会首先初始化父类(注意这里是初始化,可不是生成父类对象),也就是初始化父类的变量,调用父类的构造函数,然后才会初始化子类的变量,调用子类自己的构造函数,最后生成一个实例对象。上面程序的执行过程如下:

  1. 子类SimpleServer的构造函数接收int类型的参数:1000
  2. 父类初始化常变量,也就是DEFAULT_PORT初始化,并设置为40000
  3. 执行父类无参构造函数,也就是子类的有参构造中默认包含了super()方法
  4. 父类无参构造函数执行到"int port=getPort()"方法,调用子类的getPort方法实现
  5. 子类的getPort方法返回port值(注意,此时port变量还没有赋值,是0)或DEFAULT_PORT(此时已经是40000)了
  6. 父类初始化完毕,开始初始化子类的实例变量,port赋值100
  7. 执行子类构造函数,port被重新赋值为1000
  8. 子类SimpleServer实例化结束,对象创建完毕

所以知道了,在类初始化时getPort方法返回的port值还没有赋值,port只是获得了默认初始值(int类的实例变量默认初始值是0),因此Server永远监听的40000端口了。这个问题的产生从浅出说是由类元素初始化顺序导致的,从深处说是因为构造函数太复杂而引起的。

建议35:避免在构造函数中初始化其他类

建议36:使用构造代码块精炼程序

什么叫代码块(Code Block)?用大括号把多行代码封装起来,形成一个独立的数据体,实现特定算法的代码集合即为代码块,一般来说代码块是不能单独运行的,必须要有运行主体。在Java中一共有四种类型的代码块:

(1)普通代码块:就是在方法后面使用"{}"括起来的代码片段,它不能单独执行,必须通过方法名调用执行

(2)静态代码块:在类中使用static修饰,并使用"{}"括起来的代码片段,用于静态变量的初始化或对象创建前的环境初始化

(3)同步代码块:使用synchronized关键字修饰,并使用"{}"括起来的代码片段,它表示同一时间只能有一个线程进入到该方法块中,是一种多线程保护机制

(4)构造代码块:在类中没有任何的前缀或后缀,并使用"{}"括起来的代码片段

现在问题来了,构造函数和构造代码块是什么关系?构造代码块是在什么时候执行的?

public class Client {
    {
        System.out.println("执行构造代码块");
    }
    public Client(){
        System.out.println("执行无参构造方法");
    }

    public Client(String str){
        System.out.println("执行有参构造方法");
    }
    public static void main(String[] args) {
        new Client();
    }
}

上面的代码等同于下面:

public class Client {
    public Client(){
        System.out.println("执行构造代码块");
        System.out.println("执行无参构造方法");
    }

    public Client(String str){
        System.out.println("执行构造代码块");
        System.out.println("执行有参构造方法");
    }
    public static void main(String[] args) {
        new Client();
    }
}

每个构造函数的最前端都被插入了构造代码块,很显然,在通过new关键字生成一个实例时会先执行构造代码块,然后再执行其他代码,也就是说:构造代码块会在每个构造函数内首先执行(需要注意的是:构造代码块不是在构造函数之前运行的,它依托于构造函数的执行)。

构造代码块的应用场景:
(1)初始化实例变量:如果每个构造函数都要初始化变量,可以通过构造代码块来实现。

(2)初始化实例环境:一个对象必须在适当的场景下才能存在,如果没有适当的场景,则就需要在创建对象时创建此场景,例如在JEE开发中,要产生HTTP Request必须首先建立HTTP Session,在创建HTTP Request时就可以通过构造代码块来检查HTTP Session是否已经存在,不存在则创建之。

建议37:构造代码块会想你所想

上一个建议时说编译器会把构造代码块插入到每一个构造函数中,但是有一个例外的情况就是:如果遇到this关键字(也就是构造函数通过this调用自身其他的构造函数时)则不插入构造代码块。

在子类的构造代码块的处理上,编译器只是把构造代码块插入到super方法之后执行。

建议38:使用静态内部类提高封装性

Java中嵌套类分为两种:静态内部类和内部类。只有是静态内部类的情况下才能把static修饰符放在类前,其他任何时候static都是不能修饰类的。

静态内部类的两个优点:加强了类的封装性和提高了代码的可读性。

那静态内部类与普通内部类有什么区别呢?

(1)静态内部类不持有外部类的引用:在普通内部类中,我们可以直接访问外部类的属性、方法,即使是private类型也可以访问,这是因为内部类持有一个外部类的引用,可以自由访问。而静态内部类,则只可以访问外部类的静态方法和静态属性(如果是private权限也能访问,这是由其代码位置所决定的),其他则不能访问。

(2)静态内部类不依赖外部类:普通内部类与外部类之间是相互依赖的关系,内部类实例不能脱离外部类实例,也就是说它们会同生共死,一起声明,一起被垃圾回收器回收。而静态内部类是可以独立存在的,即使外部类消亡了,静态内部类还是可以存在的。

(3)普通内部类不能声明static的方法和变量:普通内部类不能声明static的方法和变量,注意这里说的是变量,常量(也就是final static修饰的属性)还是可以的,而静态内部类形似外部类,没有任何限制。

建议39:使用匿名类的构造函数

public class Client {
    public static void main(String[] args) {
        ArrayList l1 = new ArrayList();
        ArrayList l2 = new ArrayList() {
        };
        ArrayList l3 = new ArrayList() {{
        }};
        System.out.println(l1.getClass() == l2.getClass());
        System.out.println(l2.getClass() == l3.getClass());
        System.out.println(l1.getClass() == l3.getClass());
    }
}

这段代码可以编译,且输出结果都是false。

l1,就是声明了ArrayList的实例对象;

l2代表的是一个匿名类的声明和赋值,它定义了一个继承与ArrayList的匿名类,只是没有任何的覆写方法而已,其代码类似于:

class Sub extends ArrayList{
    
}
ArrayList l2 = new Sub();

l3代表的也是一个匿名类的定义,它的代码类似于:

class Sub extends ArrayList{
    {
        //初始模块
    }
}
ArrayList l3 = new Sub();

匿名类的构造函数是什么?初始化块就是它的构造函数。当然,一个类中的构造函数块可以是多个。也可能出现:List l3 = new ArrayList(){{} {} {}}

建议40:匿名类的构造函数很特殊

匿名类的构造函数特殊处理机制,一般类(也就是具有显式名字的类)的所有构造函数默认都是调用父类的无参构造的,而匿名类因为没有名字,只能由构造代码块代替,也就无所谓的有参和无参构造函数了,它在初始化时直接调用了父类的同参数构造,然后再调用了自己的构造代码块。

建议41:让多重继承成为现实

有时候我们确实需要继承多个类,比如希望有两个类的行为功能。Java中提供的内部类可以曲折地解决这个问题。假如我们定义一个父亲和母亲的接口,子女们都需要继承父母接口中的特性,并且有自己的特点。

public class Client {
    public static void main(String[] args) {
        Son son = new Son();
        Daughter daughter = new Daughter();
        System.out.println(son.kind()+"--儿子--"+son.strong());
        System.out.println(daughter.kind()+"--女儿--"+daughter.strong());
    }
}

interface Father{
    int strong();
}

interface Mother{
    int kind();
}

class FatherImpl implements Father {

    @Override
    public int strong() {
        return 8;
    }
}

class MotherImpl implements Mother{

    @Override
    public int kind() {
        return 8;
    }
}

class Son extends FatherImpl implements Mother {
    @Override
    public int strong() {
        return super.strong()+1;
    }

    @Override
    public int kind() {
        return new MotherSpecial().kind();
    }

    private class MotherSpecial extends MotherImpl{
        @Override
        public int kind() {
            return super.kind()-1;
        }
    }

}

class Daughter extends MotherImpl implements Father {
    @Override
    public int kind() {
        return super.kind()+1;
    }

    @Override
    public int strong() {
        return new FatherImpl(){
            @Override
            public int strong() {
                return super.strong()-1;
            }
        }.strong();
    }
}

多重继承指的是一个类可以同时从多于一个父类那里继承行为与特征,按照这个定义来说,我们的Son类和Daughter类都实现了从Father和Mother类那里所继承的功能,应该属于多重继承。

建议42:让工具类不可实例化

JDK自己的工具类如java.lang.Mathjava.util.Collection等都是经常用到的。工具类的方法和属性都是静态的,不需要生成实例即可访问,设置构造函数为private访问权限,表示除了类本身之外,谁都不能产生一个实例。但是Java有反射,修改构造函数的访问权限易如反掌,我们可以在构造函数中抛出异常来限制。

建议43:避免对象的浅拷贝

Object提供了一个对象拷贝的默认方法,super.clone方法,但是这是一种浅拷贝方式,也就是说它并不会把对象的所有属性全部拷贝一份,而是有选择性的拷贝,它的拷贝规则如下:

  1. 基本类型:拷贝其值,比如int、float等
  2. 对象:如果变量是一个实例对象,则拷贝地址引用,也就是说此时新拷贝出的对象与原有对象共享该实例变量,不受访问权限的限制。
  3. String字符串:这个比较特殊,拷贝的也是一个地址,是一个引用,但是在修改时,它会从字符串池中重新生成新的字符串,原有的字符串对象保持不变,在此处我们可以认为String是一个基本类型。

建议44:推荐使用序列化实现对象的拷贝

如果一个项目中有大量对象都是需要通过拷贝生成,那么可以使用序列化的方式来处理,在内存中通过字节流的拷贝来实现。也就是把对象写入到一个字节流中,再从字节流中读取出来,这样创建的新对象与原有对象之间不存在引用共享问题,也就相当于深拷贝了一个新对象,代码如下:

class CloneUtils{
    public static <T extends Serializable>T clone(T obj){
        // 拷贝产生的对象
        T cloneObj = null;

        try {
            // 读取对象字节数据,存入字节数组输出流中
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            ObjectOutputStream oos = new ObjectOutputStream(baos);
            // 将指定对象写入输出字节流中
            oos.writeObject(obj);
            oos.close();
            // 分配内存空间,写入原始对象,生成新对象
            ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray());
            ObjectInputStream ois = new ObjectInputStream(bais);
            cloneObj = (T)ois.readObject();
            ois.close();
        } catch (IOException e) {
            e.printStackTrace();
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
        return cloneObj;
    }
}

此工具类要求被拷贝的对象必须实现Serializable接口。用此方法进行对象拷贝时需要注意:

  1. 对象内部属性都是可序列化的
  2. 注意方法和属性的特殊修饰符

采用序列化拷贝还有一个更简单的方法,使用Apache下的commons工具包中的SerializationUtils类。

建议45:覆写equals方法时不要识别不出自己

对于任何非空引用x,x.equals(x)应该返回true

建议46: equals应该考虑null值情景

覆写equals要遵循对称性原则:对于任何引用x和y的情形,如果x.equals(y)返回true,那么y.equals(x)也应该返回true。

建议47:在equals中使用getClass进行类型判断

public class Client {
    public static void main(String[] args) {
        Employee employee = new Employee("张三", 100);
        Employee employee1 = new Employee("张三", 10001);
        Person person = new Person("张三");
        System.out.println(person.equals(employee));
        System.out.println(person.equals(employee1));
        System.out.println(employee.equals(employee1));
    }
}

class Person{
    private String name;

    public Person(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    @Override
    public boolean equals(Object obj) {
        if (obj instanceof Person) {
            Person p = (Person)obj;
            if (p.getName() == null || name ==null){
                return false;
            }else {
                return name.equalsIgnoreCase(p.getName().trim());
            }
        }
        return false;
    }
}

class Employee extends Person{
    private int id;

    public Employee(String name, int id) {
        super(name);
        this.id = id;
    }

    public int getId() {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }

    @Override
    public boolean equals(Object obj) {
        if (obj instanceof Employee){
            Employee employee = (Employee) obj;
            return super.equals(obj) && employee.getId() == id;
        }
        return false;
    }
}

这段代码的输出结果是true true false,person等于employee也等于employee1,这不是同一个类的两个实例竟然也会相等?person.equals(employee)是调用父类Person的equals方法进行判断的,这里面使用了instanceof关键字检查employee是否是Person的实例,由于Employee继承Person,所以这里employee instanceof Person为true(person instanceof Employee为false)。

还有person和employee、employee1相等,employee与employee1却不等,违反了equals的传递性原则。主要就是因为Person类使用了instanceof关键字,很容易让子类误判断。解决此类情况,就是用getClass来代替instanceof进行类型判断。

例如Person类中的equals方法:注意if条件

@Override
    public boolean equals(Object obj) {
        if (obj!=null && obj.getClass()==this.getClass()) {
            Person p = (Person)obj;
            if (p.getName() == null || name ==null){
                return false;
            }else {
                return name.equalsIgnoreCase(p.getName().trim());
            }
        }
        return false;
    }

建议48: 覆写equals方法必须覆写hashCode方法

覆写equals方法必须要覆写hashCode方法,以便维护hashCode方法的常规协定,该协定规定相等的对象必须具有相等的哈希码。

HashMap的底层处理机制是以数组的方式保存Map条目(Map Entry)的,这其中的关键是这个数组下标的处理机制:依据传入元素hashCode方法的返回值决定其数组的下标,如果该数组位置上有了Map条目,且与传入的键值相等则不处理,若不相等则覆盖;如果数组位置没有条目,则插入,并加入到Map条目的链表中。同理,检查键是否存在也是根据哈希码确定位置,然后遍历查找键值的。

建议49: 推荐覆写toString方法

通过println方法打印一个对象会调用toString方法,如果是原始类型就直接打印,如果是一个类类型,则打印出其toString方法的返回值。

建议50: 使用package-info类为包服务

建议51: 不要主动进行垃圾回收

System.gc这要停止所有的响应,才能检查内存中是否有可回收的对象。

四、字符串

建议52: 推荐使用String直接量赋值

public static void main(String[] args) {
        String str1 = "中国";
        String str2 = "中国";
        String str3 = new String("中国");
        String str4 = str3.intern();

        System.out.println(str1 ==str2);
        System.out.println(str1 ==str3);
        System.out.println(str1 ==str4);
    }

上面代码的输出结果是:

true
false
true

第一个true很简单,Java中有一个字符串池,创建一个字符串时,首先检查池中是否有字面值相等的字符串,如果有,就不再创建,直接返回池中该对象的引用。如果没有,则创建,然后放到池中,并返回新建对象的引用。

第二个false,这是因为直接声明一个String对象是不检查字符串池的,也不会把对象放到池中。

第三个true,因为intern方法会检查当前的对象在对象池中是否有字面值相同的引用对象,如果有则返回池中对象,如果没有则放置到对象池中,并返回当前对象。

对象放到池中会不会产生线程安全问题?

String类是一个不可变对象,其一是String类是final类,不可继承,不可能产生一个String的子类;其二是String类提供的所有方法中,如果有String返回值,就会新建一个String对象,不对原对象进行修改,也就保证了原对象是不可变的。

放到池中,是不是要考虑垃圾回收问题?

虽然Java的每个对象都保存在堆内存中,但是字符串池非常特殊,它在编译期已经决定了其存在JVM的常量池,垃圾回收器不会对它进行回收的。

建议53: 注意方法中传递参数的要求

建议54: 正确使用String、StringBuffer、StringBuilder

CharSequence接口有三个实现类与字符串有关:String、StringBuffer、StringBuilder。

String类是不可改变的量,也就是创建之后就不能再修改了。

StringBuffer是一个可变字符序列,它与String一样,在内存中保存的都是一个有序的字符序列(char类型的数组),不同点是StringBuffer对象的值是可以改变的。

StringBuilder与StringBuffer基本相同,都是可变字符序列,不同点是:StringBuffer是线程安全地,StringBuilder是线程不安全的。

三者的使用场景:

(1)使用String:在字符串不经常变化的场景中可以使用,例如常量的声明、少量的变量运算等。

(2)使用StringBuffer:在频繁进行字符串的运算(如拼接、替换、删除),并且运行在多线程的环境中,则可以考虑StringBuffer。

(3)使用StringBuilder:与StringBuffer相似,只不过是运行在单线程的环境中。

建议55: 注意字符串的位置

public static void main(String[] args) {
        String str1 = 1+2+"apples";
        String str2 = "apples:"+1+2;
        System.out.println(str1);
        System.out.println(str2);
    }

上面代码输出结果:

3apples
apples:12

Java对加号的处理机制:在使用加号进行计算的表达式中,只要遇到String字符串,则所有的数据都会转化为String类型进行拼接,如果是原始数据,则直接拼接,如果是对象,则调用toString方法的返回值然后拼接。

建议56: 自由选择字符串拼接方法

常用的字符串拼接有四种:+、concat、StringBuffer、StringBuilder。append方法最快,concat方法次之,加号最慢。这是为什么?

(1)"+"方法拼接字符串:虽然编译器对字符串的加号做了优化,它会使用StringBuilder的append方法进行追加,不过它最终是通过toString方法转化成String字符串的。str+="c"就等于str = new StringBuilder(str).append("c").toString()

(2)concat方法拼接字符串:观察concat源码,其中大部分操作都是原子性操作,不过最后return语句,会创建一个String对象;这就会增加了处理时间。

(3)append方法做的都是字符数组的处理,加长,然后数组拷贝,这些都是基本的数据处理,没有新建任何对象,所以速度也就快了。

建议57: 推荐在复杂字符串操作中使用正则表达式

字符串的操作,诸如追加、合并、替换、倒序、分割等,都是编码过程中经常用到的,而且Java也提供了append、replace、reverse、split等方法来完成这些操作,但更多的时候,需要使用正则表达式来完成复杂的处理。

// 正则表达式:\b表示单词的边界,\w表示字符或数字
    public static final String STRING = "\\b\\w+\\b";
    public static void main(String[] args) {
        Scanner input = new Scanner(System.in);
        while (input.hasNext()){
            String str = input.nextLine();
            Pattern pattern = Pattern.compile(STRING);
            Matcher matcher = pattern.matcher(str);
            int wordsCount = 0;
            while (matcher.find()){
                wordsCount++;
            }
            System.out.println(str+" 单词数:"+wordsCount);
        }
    }

这段代码是用来判断输入的语句有几个单词(不适用于中文)。

建议58: 强烈建议使用UTF编码

Java中的乱码问题,有时是从Web上接收的乱码,有时是从数据库中读取的乱码,有时是外部接口中接收到的乱码文件。

建议59:对字符串排序持一种宽容的心态

如果排序不是一个关键算法,使用Collator类即可。

五、数组和集合

下面围绕使用最多的三个数据集合(数组、ArrayList和HashMap)来阐述在开发过程中要注意的事项,并由此延伸至Set、Queue、Stack等集合。

建议60:性能考虑,数组是首选

在Java中数组确实没有List、Set、Map这些集合类用起来方便,但是在基本类型处理方面,数组还是占优势的,而且集合类的底层也都是通过数组实现的。

基本类型是在栈内存中操作的,而对象则是在堆内存中操作的,栈内存的特点是速度快,容量小,堆内存的特点是速度慢,容量大。

在实际测试中:对基本类型进行求和计算时,数组的效率是集合的10倍。

建议61:若有必要,使用变长数组

使用Arrays.copyOf(datas,newLen)可以产生一个newLen长度的新数组,并把原有的值拷贝进去。

建议62: 警惕数组的浅拷贝

建议63:在明确的场景下,为集合指定初始容量

经常使用ArrayList、HashMap等集合,一般都是直接用new跟上类名声明出一个集合来,然后使用add、remove等方法进行操作,而且因为它是自动管理长度的,所以不用特别费心超长的问题。

ArrayList其实是一个大小可变的数组,但它在底层使用的是数组存储,而且数组是定长的,要实现动态长度必然要进行长度的扩展。而当到达设置的容量临界时,扩容1.5倍。

为什么是扩容1.5倍?

因为一次扩容太大,占用的内存就越大,浪费的内存也就越多(1.5倍扩容,最多浪费33%的数组空间,而2.5倍则最多可能浪费60%的内存);而一次扩容太小(比如每次扩容1.1倍),则需要多次对数组重新分配内存,性能消耗严重。经测试验证,扩容1.5倍即满足了性能要求,也减少了内存消耗。

例如:

HashMap是按照倍数增加的。

建议64: 多种最值算法,适时选择

Java中我们可以通过编写算法的方式,也可以通过数组先排序再取值的方式来实现。

(1)自行实现,快速查找最大值

(2)先排序,后取值

一个数组,如果要查找仅次于最大值的元素,该如何处理?要注意,数组的元素是可以重复的,最大值可能是多个,所以单单一个排序然后取倒数第二个元素是解决不了问题的。

此时,需要一个特殊的排序算法,先要剔除重复数据,然后再排序。

注意: 最值计算时使用集合最简单,使用数组性能最优。

建议65:避开基本类型数组转换列表陷阱

开发过程中经常会使用Arrays和Collections这两个工具类在数组和列表之间转换,非常方便,但有时会出现一些奇怪的问题,来看如下代码:

public static void main(String[] args) {
        int[] data = {1,2,3,4,5};
        List<int[]> list = Arrays.asList(data);
        System.out.println(list.size());
    }

这段代码的意思是:将包含int类型的数组转化为list,输出list的元素数量。这里打印的结果是1!!!

data中确实是有5个元素,只是通过asList转化成列表后就只有1个元素了,这是为什么呢?其他四个元素去哪了?

public static <T> List<T> asList(T... a) {
        return new ArrayList<T>(a);
    }

这是asList方法的源码,其中要输入一个可变参数,返回一个固定长度的列表。

asList方法输入的是一个泛型变长参数,我们知道基本类型是不能泛型化的,也就是说8个基本类型不能作为泛型参数,要想作为泛型参数就必须使用其所对应的包装类型。那为什么传入int类型的数组,程序却没有报错呢?

在Java中,数组是一个对象,它是可以泛型化的,也就是说我们示例代码中是把一个int类型的数组作为了T的类型,所以转换后在List中就只有一个类型为int数组的元素了。

如果把int替换成Integer即可让输出元素数量变为5。

在基本类型数组转化成列表时,要小心asList方法的陷阱,避免出现程序逻辑混乱的情况。

注意原始类型数组不能作为asList的输入参数,否则会引起程序逻辑混乱

建议66:asList方法产生的List对象不可更改

asList方法返回的ArrayList是Arrays类的私有静态内部类,它仅仅实现了5个方法:

  1. size:元素数量
  2. toArray:转化为数组,实现了数组的浅拷贝
  3. get:获得指定元素
  4. set:重置某一元素值
  5. contains:是否包含某元素

对于我们经常使用的List.add和List.remove方法都没有实现,也就是说asList返回的是一个长度不可变的列表,数组是多长,转化成的列表也就是多长,换句话说此处的列表只是数组的一个外壳,不再保持列表动态变长的特性。

建议68:不同的列表选择不同的遍历方法

统计一个省的各科高考平均分,如何使用Java实现。

这是求平均分的算法,其中使用foreach方式遍历求和,再计算平均值。如何优化这段代码?

加号操作时最基本操作,没什么可优化的,剩下的就是遍历了,List的遍历是可以优化的吗?

List的遍历除了foreach还有传统的通过下标方式来遍历的,这两种遍历方法会产生性能上的差异。如果这里传入的参数是ArrayList,那么使用传统下标遍历会提高性能65%。

因为ArrayList数组实现了RandomAccess接口(随机存取接口),也就标志了ArrayList是一个可以随机存取的列表。在Java中,RandomAccess和Cloneable、Serializable一样,都是标志性接口,不需要任何实现;实现了RandomAccess则表示这个类可以随机存取,对我们的ArrayList来说也就是标志着其数据元素之间没有关联,即两个位置相邻的元素之间没有相互依赖和索引关系,可以随机访问和存储。

foreach语法就是iterator的变形用法,迭代器是23个设计模式中的一种,"提供一种方法访问一个容器对象中的各个元素,同时又无须暴露该对象的内部细节",也就是说对于ArrayList,需要先创建一个迭代器容器,然后屏蔽内部遍历细节对外提供hasNext、next方法。问题是ArrayList实现了RandomAccess接口,已表明元素之间本来没有关系,这里迭代器要强制建立一种关系,就会产生耗时。

如果传入的是LinkedList类呢?也要用下标遍历吗?

LinkedList是一个双向链表,它里面的两个元素之间本来是有关联的,使用foreach也就是迭代器方式就更高效了。传统遍历,在这里效率非常低,它每查找一个元素都是一个遍历,就没有性能了。

所以这个求平均分的方法需要进行改造:

现在无论是随机存取列表还是有序列表,它都可以提供快速的遍历。

注意: 列表遍历不是那么简单的,其中很有学问,适时选择最优的遍历方式,不要固化为一种。

建议68:频繁插入和删除时使用LinkedList

(1)插入元素

列表中使用最多的ArrayList,下面是它的插入算法:

public void add(int index, E element) {
    	//检查下标是否越界,代码不再拷贝
        rangeCheckForAdd(index);
		//若需要扩容,则增大底层数组的长度
        ensureCapacityInternal(size + 1);  
    	//给index下标之后的元素(包括当前元素)的下标加1,空出index位置,下面是参数解释
    	/*
    	 * @param      src      the source array.
     	 * @param      srcPos   starting position in the source array.
         * @param      dest     the destination array.
         * @param      destPos  starting position in the destination data.
         * @param      length   the number of array elements to be copied.
         */
        System.arraycopy(elementData, index, elementData, index + 1,
                         size - index);
        elementData[index] = element;
        size++;
    }

arraycopy方法,只要是插入一个元素,其后的元素就会向后移动一位,虽然arraycopy是一个本地方法,效率非常高,但是频繁的插入,每次后面的元素都要拷贝一遍,效率就变低了,特别是在头位置插入元素时。

使用LinkedList来应付频繁的插入是非常好的,它是一个双向链表,它的插入只修改相邻元素的next和previous引用。

public void add(int index, E element) {
        checkPositionIndex(index);

        if (index == size)
            linkLast(element);
        else
            linkBefore(element, node(index));
    }
/**
     * Inserts element e before non-null Node succ.
     */
    void linkBefore(E e, Node<E> succ) {
        // assert succ != null;
        // 拿到插入节点位置的前驱节点
        final Node<E> pred = succ.prev;
        // 构造插入的节点
        final Node<E> newNode = new Node<>(pred, e, succ);
        // 把插入节点位置的前驱改为插入的结点
        succ.prev = newNode;
        if (pred == null)
            first = newNode;
        else
            // 把插入节点位置的前驱节点的后继改为插入的结点
            pred.next = newNode;
        size++;
        modCount++;
    }

这样一个插入元素的过程,没有任何元素会有拷贝过程,只是引用地址改变了,那效率就当然高了。

经测试,LinkedList的插入效率比ArrayList快50倍以上。

(2)删除元素

ArrayList提供了删除指定位置上的元素、删除指定值元素、删除一个下标范围内的元素集等删除动作,三者的实现原理基本相似,都是找到索引位置,然后删除。

public E remove(int index) {
        rangeCheck(index);
		// 修改计数器+1
        modCount++;
    	// 记录要删除的元素值
        E oldValue = elementData(index);
		// 有多少个元素向前移动,多-1是因为index是索引
        int numMoved = size - index - 1;
        if (numMoved > 0)
            // index后的元素向前移动一位
            System.arraycopy(elementData, index+1, elementData, index,
                             numMoved);
    	// 列表长度减一,并且最后一位设为null
        elementData[--size] = null; // clear to let GC do its work
		// 返回删除的值
        return oldValue;
    }

注意,index位置后的元素都向前移动了一位,最后一个位置空出来了,这又是一次数组拷贝,和插入一样,如果数据量大,删除动作必然会暴露出性能和效率方面的问题。

LinkedList的remove方法:

public E remove(int index) {
        checkElementIndex(index);
        return unlink(node(index));
    }
/**
     * Unlinks non-null node x.
     */
    E unlink(Node<E> x) {
        // assert x != null;
        // 取得原始值
        final E element = x.item;
        // 拿到后继节点
        final Node<E> next = x.next;
        // 拿到前驱节点
        final Node<E> prev = x.prev;

        if (prev == null) {
            first = next;
        } else {
            // 后继节点替换删除节点在前驱节点中
            prev.next = next;
            x.prev = null;
        }

        if (next == null) {
            last = prev;
        } else {
            // 前驱节点替换删除节点在后继节点中
            next.prev = prev;
            x.next = null;
        }

        x.item = null;
        size--;
        modCount++;
        return element;
    }

这是双向链表的标准删除算法,没有任何耗时的操作,全部是引用指针的变更,效率自然高。

测试得知,处理大批量的删除动作,LinkedList比ArrayList快40倍以上。

(3)修改元素

写操作还有一个动作:修改元素值。在这一点上LinkedList输给了ArrayList,这是因为LinkedList是顺序存取的,因此定位元素必然是一个遍历过程,效率就低了。LinkedList这种顺序存取列表的元素定位方式会折半遍历,这是一个极耗时的操作。

建议69: 列表相等只需要关心元素数据

不管是ArrayList还是Vectory,只要是实现了List接口,所有的元素相等,并且长度也相等就表名两个List是相等的,与具体的容量类型无关。

注意:判断集合是否相等时只须关注元素是否相等即可。

建议70: 子列表只是原列表的一个视图

子列表只是原列表的一个视图,所有的修改动作都反映在原列表上

建议71:推荐使用subList处理局部列表

一个列表有100个元素,现在要删除索引位置为20-30的元素。这很简单,一个遍历很快就可以完成。

for(int i=20;i<30;i++){
    if(i<list.size()){
        list.remove(i);
    }
}

有没有一行代码就解决问题的方法呢?

用subList方法先取出一个子列表,然后清空。因为subList返回的List是原始列表的一个视图,删除这个视图中的所有元素,最终就会反映到原始字符串上,那么一行代码即解决问题了。

建议72:生成子列表后不要再操作原列表

subList的set、get、add等方法也会检测修改计数器,若生成子列表后,再修改原列表,这些方法也会抛出ConcurrentModificationException异常。

对于子列表操作,因为视图是动态省城的,生成子列表后再操作原列表,必然会导致"视图"的不稳定,最有效的方法就是通过Collections.unmodifiableList方法设置列表为只读状态。

注意: subList生成子列表后,保持原列表的只读状态。

建议73:使用Comparator进行排序

在Java中,要想给数据排序,有两种实现方式,一种是实现Comparable接口,一种是实现Comparator接口,这两种有什么区别?

  1. 实现Comparable接口,重写CompareTo()方法;实现Comparator接口,重写Compare()方法;

在Java中,为什么要有两个排序接口呢?

实现了Comparable接口的类表明自身是可比较的,有了比较才能进行排序;而Comparator接口是一个工具类接口,它的名字(比较器)就是它的作用:用作比较,它与原有类的逻辑没有关系,只是实现两个类的比较逻辑。

从这方面来说,一个类可以有很多的比较器,只要有业务需求就可以产生比较器,有比较器就可以产生N多种排序,而Comparable接口的排序只能说是实现类的默认排序算法,一个类稳定、成熟后其compareTo方法基本不会改变,也就是说一个类只能有一个固定的、由compareTo方法提供的默认排序算法。

注意: Comparable接口可以作为实现类的默认排序法,Comparator接口则是一个类的扩展排序工具。

建议74:不推荐使用binarySearch对列表进行检索

binarySearch()方法用的是二分法查找,使用的前提就是要排序。

建议75:集合中的元素必须要做到compareTo和equals同步

  • indexOf依赖equals方法查找,binarySearch则依赖compareTo方法查找
  • equals是判断元素是否相等,compareTo是判断元素在排序中的位置是否相同

既然一个是决定排序位置,一个是决定相等,那我们就应该保证当排序位置相同时,其equals也相同,否则就会产生逻辑混乱。

注意: 实现了compareTo方法,就应该覆写equals方法,确保两者同步。

建议76:集合运算时使用更优雅的方式

遍历可以实现并集、交集、差集等运算,但这不是最优雅的处理方式。下面看如何更优雅、快速、方便的集合操作。

(1)并集:list1中就是两个列表的并集元素

(2)交集:list1.retainAll(list2)就是求两个集合的共有元素。list1中会包含list1和list2共有的元素了;注意retainAll方法会删除list1中没有出现在list2中的元素

(3)差集:list1.removeAll(list2)

(4)无重复的并集:什么是无重复的并集?并集是集合A加集合B,那如果集合A和集合B有交集,就需要确保并集的结果中只有一份交集,此为无重复的并集。操作如下:

//删除在list1中出现的元素
list2.removeAll(list1);
//把剩余的list2元素加到list1中
list1.addAll(list2);

建议77:使用shuffle打乱列表

Collections.shuffle(tagClouds);这一个方法,即可打乱一个列表的顺序,不用我们费心费力的遍历、替换元素了。

shuffle这个方法,它可以用在什么地方呢?

  • 可以用在程序的"伪装"上
  • 可以用在抽奖程序中
  • 可以用在安全传输方面

建议78:减少HashMap中元素的数量

HashMap偶尔会出现内存溢出的问题(如OutOfMemory错误),而且这经常是与HashMap有关的,比如我们使用缓冲池操作数据时,大批量的增删查改操作就可能会让内存溢出。

public static void main(String[] args) {
        Map<String, String> map = new HashMap<>();
        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<393217;i++){
            map.put("key"+i,"value"+i);
        }
    }

这段代码就是模拟HashMap内存溢出的,该程序只是向Map中放入了近40万个键值对元素(为什么是393217个?),执行结果为:

溢出的原因?

难道是String字符串太多了?不对,字符串对象加起来最大就10MB,而且这里空闲了7MB内存。不应该报内存溢出呀?或者是put方法有缺陷,产生了内存泄漏?不可能,这里还有7MB内存可用,应该要用尽了才会出现内存泄漏。

使用ArrayList进行同样插入操作,但是它不会报内存溢出。这说明与HashMap内部的处理机制有极大的关系。

HashMap在底层也是以数组方式保存元素的,其中每一个键值对就是一个元素,也就是说HashMap把键值对封装成了一个Entry对象,然后再把Entry放到了数组中。HashMap底层的数组变量名叫table,它是Entry类型的数组,保存的是一个一个的键值对。对我们的例子来说,HashMap比ArrayList多了一次封装,把String类型的键值对转换成Entry对象后再放入数组,这就多了40万个对象,这应该是问题产生的第一个原因。

我们知道HashMap也是支持扩容的,if(size++ >= threshold) {resize(2 * table.length);},在插入键值对时,会做长度校验,如果大于或等于阀值(threshold遍历),则数组长度增加一倍。默认的阀值是当前长度与加载因子的乘积threshold = (int)(newCapacity * loadFactor);,默认的加载因子(loadFactor)是0,75,也就是说只要HashMap的size大于数组长度的0.75倍是,就开始扩容。

经计算得知(查找2的N次幂大于40万的最小值即为数组的最大长度,再乘以0.75就是最后一次扩容点,计算的结果是N=19),在Map的size为393216时,符合扩容条件,于是393216个元素准备开始搬家,要扩容,那么首先要申请一个长度为1048476(当前长度的两倍)的数组,但是问题是此时剩余的内存只有7MB了,不足以支撑此运算,于是就报内存溢出了。这就是第二个原因,也是最根本的原因。

综合来说,HashMap比ArrayList多了一层Entry的底层对象封装,多占用了内存,并且它的扩容策略是2倍长度的递增,同时还会依据阀值判断规则进行判断,因此相对于ArrayList来说,它就会先出现内存溢出。

注意: 尽量让HashMap中的元素少量并不简单。

建议79:集合中的哈希码不要重复

在一个列表中查找某值是非常耗费资源的,随机存取的列表是遍历查找,顺序存储列表是链表查找,或者是Collections的二分法查找,但这都不够快,毕竟都是遍历,最快的还是要数以Hash开头的集合查找。

HashMap是根据hashCode定位它在数组table中的位置,table有下面的特点:

  • table数组的长度永远是2的N次幂
  • table数组中的元素是Entry类型
  • table数组中的元素位置是不连续的

HashMap的put方法如下:

代码中,HashMap每次增加元素的时候都会计算元素key的hashCode,然后使用hash方法再次对hashCode进行抽取和计数,同时兼顾哈希码的高位和低位信息产生一个唯一值,也就说hashCode不同,hash方法返回值也不同。之后再通过indexFor方法与数组长度做一次运算,即可计算出它在数组中的位置。简单地说,hash方法和indexFor方法就是把哈希码转化为数组的下标。

HashMap中,null值也是可以作为key值得,它的位置永远在table数组中的第一位。

哈希运算是存在哈希冲突问题的,也就是说两个不同的Entry可能会产生相同的哈希码,HashMap是如何处理这种冲突问题的呢?答案是通过链表,每一个键值对都是一个Entry,其中每一个Entry都有一个next变量,也就是说它会指向下一个键值对——很明显,这应该是一个单向链表。该链表是由addEntry方法完成的,这个方法包含了两个业务逻辑:如果新加入的键值对的hashCode是唯一的,那直接插入的数组中,它Entry的next值则为null;如果新加入的键值对的hashCode与其他元素冲突,则替换掉数组中的当前值,并把新加入的Entry的next变量指向被替换掉的元素,于是一个链表就生出了。

HashMap的存储主线还是数组,遇到哈希冲突的时候则使用链表解决。了解了HashMap是如何存储的,查找也就一目了然了:使用hashCode定位元素,若有哈希冲突,则遍历对比,换句话说在没有哈希冲突的情况下,HashMap的查找则是依赖hashCode定位的,因为是直接定位,那效率当然高!如果哈希码相同,它的查找效率就和ArrayList没区别了,遍历对比,性能会大打折扣。

注意: HashMap中的hashCode应避免冲突

建议80:多线程使用Vector或HashTable

Vector是ArrayList的多线程版本,HashTable是HashMap的多线程版本。

public static void main(String[] args) {
        final List<String> tickets = new Vector<>();
        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();
    }

上面这段代码,一运行就报Exception in thread "Thread-1" java.util.ConcurrentModificationException at java.util.Vector$Itr.checkForComodification(Vector.java:1210) at java.util.Vector$Itr.next(Vector.java:1163) at com.tianhao.luo.Client$2.run(Client.java:27);为什么线程安全,还报这个错呢?

因为混淆了线程安全和同步修改异常,基本上所有的集合类都有一个叫做快速失败的校验机制,当一个集合在被多个线程修改访问时,就可能出现ConcurrentModificationException异常,这是为了确保集合方法一致而设置的保护措施,它实现的原理就是modCount修改计数器:如果在读列表时,modCount发生变化则抛出异常。这就是同步修改异常。

线程同步是,一个线程进入需要同步的方法中时,别的线程只能等待;这是为了保护集合中的数据不被脏读、脏写而设置的。

建议81:非稳定排序推荐使用List

Set与List的最大区别就是Set中的元素不可以重复(这个重复指的是equals方法的返回值相等),其他方面则没有太大的区别。在Set的实现类中有一个比较常用的类需要了解:TreeSet,该类实现了类默认排序为升序的Set集合,如果插入一个元素,默认会按照升序排列(当然是根据Comparable接口中的compareTo的返回值确定排序位置了)。

SortedSet接口(TreeSet实现了该接口)只是定义了在给集合加入元素时将其进行排序,并不能保证元素修改后的排序结果。因此TreeSet适用于不变量的集合数据排序,比如String、Integer等类型,但不适用于可变量的排序,特别是不确定何时元素会发生变化的数据集合。

注意: 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必须是一个枚举类型。

(4)Queue:队列,它分为两类,一类是阻塞式队列,队列满了以后再插入元素则会抛出异常,主要包括:ArrayBlockingQueue、PriorityBlockingQueue、LinkedBlockingQueue,其中ArrayBlockingQueue是一个以数组方式实现的有界阻塞队列,PriorityBlockingQueue是依照优先级组建的队列,LinkedBlockingQueue是通过链表实现的阻塞队列;另一类是非阻塞队列,无边界的,只要内存运行,都可以持续追加元素,我们最经常使用的是PriorityQueue类。

还有一种队列,是双端队列,支持在头、尾两端插入和移除元素,它的主要实现类是:ArrayDeque、LinkedBlockingQueue、LinkedList。

(5)数组:数组与集合的最大区别就是数组能够容纳基本类型,而集合就不行,更重要的一点就是所有的集合底层存储的都是数组。

(6)工具类:数组的工具类是java.util.Arraysjava.lang.reflect.Array,集合的工具类是java.util.Collections,有了这两个工具类,操作数组和集合会易如反掌。

六、枚举和注解

枚举和注解都是在Java1.5中引入的,枚举改变了常量的声明方式,注解耦合了数据和代码。

建议83:推荐使用枚举类定义常量

enum Season{
    Spring,Summer,Autumn,Winter;
}

这就是枚举声明常量,枚举常量与我们经常使用的类常量和静态常量相比有什么优势?

(1)枚举常量更简单

如果要用接口定义Season:

interface Season{
    int Spring=0;
    int Summer =1;
    int Autumn =2;
    int Winter =3;
}

这里定义了春夏秋冬四个季节,类型都是int,这与Season枚举的排序值是相同的。首先对比一下两者的定义,枚举类型只需要定义每个枚举项,不需要定义枚举值,而接口常量(或类常量)则必须定义值,否则编译通不过,即使我们不需要关注其值是多少也必须定义;其次,虽然两者被引用的方式相同(都是"类名.属性",如Season.Spring),但是枚举表示的是一个枚举项,字面含义是春天,而接口常量却是一个int类型,虽然其字面含义也是春天,但是运算中我们势必要关注其int值。

(2)枚举常量属于稳态型,枚举常量能够避免校验问题,在编译期间限定类型,不允许发生越界的情况。

(3)枚举具有内置方法:如获得所有的枚举项values()、获得排序值的ordinal()、比较方法compareTo()等,大大简化了常量的访问。

(4)枚举可以自定义方法

不过枚举类型是不能有继承的,也就是说一个枚举常量定义完毕之后,除非修改重构,否则无法做扩展,而接口常量和类常量则可以通过继承进行扩展。

注意: 在项目开发中,推荐使用枚举常量代替接口常量或类常量。

建议84:使用构造函数协助描述枚举项

一般来说,我们经常使用的枚举项只有一个属性,即排序号,但是除了排序号外,枚举还有一个(或多个)属性:枚举描述,它的含义是通过枚举的构造函数,声明每个枚举项必须具有的属性和行为,这是对枚举项的描述或补充,目的是使枚举项表述的意义更加清晰准确。

enum Season{
    Spring("春"),Summer("夏"),Autumn("秋"),Winter("冬");
    private String desc;

    Season(String desc) {
        this.desc = desc;
    }

    public String getDesc() {
        return desc;
    }
}

建议:在枚举定义中为每个枚举项定义描述,特别是在大规模的项目开发中,大量的常量项定义使用枚举项描述比接口常量或类常量中增加注释的方式友好得多,简洁得多。

建议85:小心switch带来的空值异常

有这样一段代码:

public class Client {
    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;
        }
    }
    public static void main(String[] args) {
        doSports(null);
    }
}

enum Season {
    Spring("春"), Summer("夏"), Autumn("秋"), Winter("冬");
    private String desc;

    Season(String desc) {
        this.desc = desc;
    }

    public String getDesc() {
        return desc;
    }
}

这里打印出的结果是:

Exception in thread "main" java.lang.NullPointerException
	at com.tianhao.luo.Client.doSports(Client.java:11)
	at com.tianhao.luo.Client.main(Client.java:30)

我们的预期结果应该是会打印出"输入错误",因为在switch中没有匹配到指定值,所以会打印出default的代码块。不过却报了空指针异常,怎么会有空指针呢?这就和枚举和switch的特性有关了。

目前Java中switch语句只能判断byte、short、char、int类型(JDK7已经允许使用String类型),这是Java编译器的限制。问题是为什么枚举类型也可以跟在switch后面呢?

因为编译时,编译器判断出switch语句后的参数是枚举类型,然后就会根据枚举的排序值继续匹配,也就是说switch语句是先计算season变量的排序值,然后与枚举常量的每个排序值进行对比的。在例子中season变量是null值,无法执行ordinal方法,于是报空指针异常了。

解决方法很简单,在doSports()方法中判断输入参数是否为null即可。

建议86:在switch的default代码块中增加AssertionError错误

由于把所有的枚举项都列举完了,不可能有其他值,所以就不需要default代码块了,这是普遍的认识,但问题是我们的switch代码与枚举之间没有强制约束关系,也就是说两者只是在语义上建立了联系,并没有一个强制约束。输入不匹配的项,编译虽然不会出错,但是运行期会发生非预期错误,为了避免出现这类错误,建议在default后直接抛出一个AssertionError错误,其含义就是:"不要跑到这里来,一跑到这里就会出现问题"。

建议87:使用valueOf前必须进行校验

每个枚举都是java.lang.Enum的子类,都可以访问Enum类提供的方法,比如hashCode、name、valueOf等,其中valueOf方法会把一个String类型的名称转变为枚举项,也就是在枚举项中查找出字面值与该参数相等的枚举项。

当我们输入的是非枚举项时,会抛出非受检IllegalArgumentException异常。valueOf方法的源码是:

valueOf方法先通过反射从枚举类的常量声明中查找,若找到就直接返回,若找不到则抛出无效参数异常。valueOf本意是保护编码中枚举安全性,使其不产生空枚举对象,简化枚举操作,但是却又引入了一个我们无法避免的IllegalArgumentException异常。

问题清楚了,有两个方法可以解决此问题:

(1)使用try....catch捕捉异常

(2)扩展枚举类:由于Enum类定义的方法基本上都是final类型的,所以不希望被覆写,那我们可以学习String和List,通过增加一个contains方法来判断是否包含指定的枚举项,然后再继续转化。

建议88:用枚举实现工厂方法模式更简洁

工厂方法模式是"创建对象的接口,让子类决定实例化哪一个类,并使一个类的实例化延迟到其子类"。

枚举实现工厂方法模式有两种方法:

(1)枚举非静态方法实现工厂方法模式:我们知道每个枚举项都是该枚举类的实例对象,那么是不是定义一个方法可以生成每个枚举项的对应产品来实现此模式?

create是一个非静态方法,也就是说只有通过FordCar和BuickCar枚举项才能访问。生成一辆汽车:Car car = CarFactory.BuickCar.create()

(2)通过抽象方法生成产品:枚举类型虽然不能继承,但是可以用abstract修饰其方法,此时就表示该枚举是一个抽象枚举,需要每个枚举项自行实现该方法;也就是说枚举项的类型是该枚举的一个子类:

为什么要使用枚举类型的工厂方法模式?

  1. 避免错误调用的发生
  2. 性能好,使用便捷
  3. 降低类间耦合

建议89:枚举项的数量限制在64个以内

为了更好地使用枚举,Java提供了两个枚举集合:EnumSet和EnumMap,这两个集合的使用方法都比较简单,EnumSet表示其元素必须是某一枚举的枚举项,EnumMap表示Key值必须是某一枚举的枚举项,由于枚举类型的实例数量固定并且有限,相对于EnumSet和EnumMap的效率会比其他Set和Map要高。

注意: 枚举项数量不要超过64,否则建议拆分

建议90:小心注解继承

Java从1.5版本开始引入注解,其目的是在不影响代码语义的情况下,增加代码的可读性,并且不改变代码的执行逻辑。

现在我们要说的是一个我们不常用的元注解(Meta-Annotation):@Inherited,它表示一个注解是否可以自动被继承,我们来看一下它应该如何使用。

思考一个例子,比如描述鸟类,它有颜色、体型、习性等属性,我们以颜色为例,定义一个注解来修饰一下,代码如下:

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Inherited
@interface Desc {
    enum Color {
        White,Green,Yellow;
    }

    Color c() default Color.White;
}

该Desc注解前面增加了三个元注解:Retention表示的是该注解的保留级别,Target表示的是该注解可以标注在什么地方,@Inherited表示该注解会被自动继承。我们把这个注解标注在类上,代码如下:

@Desc(c = Desc.Color.White)
abstract class Bird{
    //鸟的颜色
    public abstract Desc.Color getColor();
}

// 麻雀
class Sparrow extends Bird {
    private Desc.Color color;

    // 默认是绿色的鸟
    public Sparrow() {
        color = Desc.Color.Green;
    }
    //构造函数定义鸟的颜色
    public Sparrow(Desc.Color color) {
        this.color = color;
    }

    @Override
    public Desc.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 class TestBird {
    public static void main(String[] args) {
        Bird bird = BirdNest.Sparrow.reproduce();
        Desc.Color color = bird.getColor();
        System.out.println("鸟的颜色:"+color);
    }
}

那么会打印出什么结果呢?因为采用了工厂方法模式,它最主要的问题就是bird变量到底采用了哪个构造函数来生成,是无参构造还是有参构造?如果我们单独看子类Sparrow,它没有被添加任何注解,那么工厂方法中bd变量就应该是null了,应该调用的是无参构造。是不是如此呢?运算结果是:鸟的颜色:White

白色,这是我们添加在父类Bird类上的颜色,为什么?就是因为我们在注解上增加了@Inherited注解,它表示的意思是我们只需要把注解@Desc加到父类Bird上,它的所有子类都会自动从父类继承@Desc注解,不需要显式声明,这与Java类的继承有点不同,若Sparrow类继承了Bird,则必须使用extends关键字声明,而Bird上的注解@Desc继承自Bird却不用显式声明,只要声明@Desc注解是可自动继承的即可。

采用@Inherited元注解有利有弊,利的地方是一个注解只要标注到了父类,所有的子类都会自动具有与父类相同的注解,整齐、统一而且便于管理,弊的地方是单单阅读子类代码,我们无从知道为何逻辑会被改变,因为子类没有明显标注该注解。总体来说,使用@Inherited元注解的弊大于利,特别是一个类的继承层次较深时,如果注解较多,则很难判断出哪个注解对于子类产生了逻辑劫持。

建议91:枚举和注解结合使用威力更大

注解要在interface前加上@字符,而且不能继承,不能实现,这经常会给我们的开发带来一些障碍。

我们分析一个ACL(Access Control List,访问控制列表)设计案例,看看如果避免这些障碍,ACL有三个重要元素:

  • 资源,有哪些信息是要被控制起来的
  • 权限级别,不同的访问者规划在不同的级别中
  • 控制器(也叫鉴权人),控制不同的级别访问不同的资源

鉴权人是整个ACL的设计核心,我们从组主要的鉴权人开始,代码如下:

interface Identifier {
    String REFUSE_WORD = "您无权访问";
    //鉴权
    public boolean identify();
}

这是一个鉴权人接口,定义了一个常量和一个鉴权方法。接下来应该实现该鉴权方法,但问题是我们的权限级别和鉴权方法之间是紧耦合,若分拆为两个类显得有点啰嗦,怎么办?我们可以直接定义一个枚举来实现,代码如下:

enum CommonIdentifler implements  Identifier {
    //权限级别
    Reader,Author,Admin;
    //实现鉴权
    @Override
    public boolean identify() {
        //获取当前用户权限的方法
        CommonIdentifler user = test.getControl();
        //鉴权
        if (this.equals(user)){
            return true;
        }else {
            return false;
        }
    }
}

定义了一个通用鉴权者,使用的是枚举类型,并且实现了鉴权者接口。现在就剩下资源定义了,这很容易定义,资源就是我们写的类、方法等,之后再通过配置来决定哪些类、方法允许什么级别的访问,这里的问题是:怎么把资源和权限级别关联起来呢?使用xml配置文件?是一个方法,但对于示例程序来说显得太繁重,如果使用注解会更简洁些,不过这需要我们首先定义出权限级别的注解,代码如下:

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@interface Access {
    //什么级别可以访问,默认是管理员
    CommonIdentifler level() default CommonIdentifler.Admin;
}

该注解是标注在类上面的,并且会保留到运行期。我们定义一个资源类,代码如下:

@Access(level = CommonIdentifler.Author)
class Foo {

}

Foo类只能是作者级别的人访问。场景都定义完毕了,那我们看如果模拟ACL的实现,代码如下:

class test {

    public static CommonIdentifler getControl(){
        return CommonIdentifler.Author;
    }
    public static void main(String[] args) {
        //初始化商业逻辑
        Foo foo = new Foo();
        //获取注解
        Access access = foo.getClass().getAnnotation(Access.class);
        //没有Access注解或者鉴权失败
        if (access == null || !access.level().identify()) {
            //没有Access注解或者鉴权失败
            System.out.println(access.level().REFUSE_WORD);
        }else {
            System.out.println("您能访问到该资源");
        }

    }
}

这段代码,简单、易读,而且如果我们是通过ClassLoader类来解释该注解的,那么会使我们的开发更加简洁,所有的开发人员只要增加注解即可解决访问控制问题。

建议92:注意@Override不同版本的区别

@Override注解用于方法的覆写上,它在编译期有效,也就是Java编译器在编译时会根据该注解检查方法是否真的是覆写,如果不是就报错,拒绝编译。该注解可以很大程度地解决我们的误写问题,比如子类和父类的方法名少写了一个字符,或者是数字0和字母o未区分开来等。

七、泛型和反射

泛型可以减少强制类型的转换,可以规范集合的元素类型,还可以提高代码的安全性和可读性,正是因为有这些优点,自从Java引入泛型后,项目的编码规则上便又多了一条:优先使用泛型。

反射可以"看透"程序的运行情况,可以让我们在运行期知晓一个类或实例的运行情况,可以动态地加载和调用,虽然有一定的性能忧患,但它带给我们的便利远远大于其性能缺陷。

建议93:Java的泛型是类型擦除的

Java的泛型在编译期有效,在运行期被删除,也就是说所有的泛型参数类型在编译后都会被清除掉。我们来看一个例子,代码如下:

public class Foo1 {
    
    //arrayMethod接收数组参数,并进行重载
    public void arrayMethod(String[] strArray){}
    public void arrayMethod(Integer[] intArray){}
    
    //listMethod接收泛型List参数,并进行重载
    public void listMethod(List<String> stringList){}
    public void listMethod(List<Integer> integerList){}
}

程序很简单,编写了四个方法,arrayMethod方法接收String数组和Integer数组,这是一个典型的重载,listMethod接收元素类型为String和Integer的List变量。现在的问题是,这段程序是否能编译?如果不能,问题出现在什么地方?

这段代码是无法编译的,编译时报错信息如下:
'listMethod(List<String>)' clashes with 'listMethod(List<Integer>)'; both methods have same erasure
此错误的意思是说listMethod(List<\Intege>)方法在编译时擦除类型后的方法是listMethod(List<\E>),它与另外一个方法重复,通俗的说就是方法签名重复。这就是Java泛型擦除引起的问题:在编译后所有的泛型类型都会做相应的转化,转化规则如下:

  • List、List、List擦除后的类型为List
  • List[]擦除后的类型为List[]
  • List<? extends E>、List<? super E>擦除后的类型为List
  • List<T extends Serializable & Cloneable>擦除后为List

明白这些擦除规则,再看如下代码:

    public static void main(String[] args) {
        ArrayList<String> list = new ArrayList<>();
        list.add("abc");
        String s = list.get(0);
        System.out.println(s);

        ArrayList list1 = new ArrayList();
        list1.add("abc");
        String str = (String)list1.get(0);
        System.out.println(str);
    }

经过编译器的擦除处理后,上面的代码与下面的程序是一致的。Java编译后的字节码中已经没有泛型的任何信息了,也就是说一个泛型类和一个普通类在经过编译后都指向了同一个字节码文件,比如Foo<\T>类,经过编译后将只有一份Foo.class类,不管是Foo<\String>还是Foo<\Integer>引用的都是同一字节码。Java之所以这样处理,有两个原因:

  1. 避免JVM的大换血。C++的泛型生命期延续到了运行期,而Java是在编译器擦除掉的,我们想想,如果JVM也把泛型类型延续到运行期,那么JVM就需要进行大量的重构工作了。
  2. 版本兼容。在编译器擦除可以更好地支出原生类型(Raw Type),在Java1.5或1.6平台上,即使声明一个List这样的原生类型也是可以正常编译通过的,只是会产生警告信息而已。

明白了Java的泛型是类型擦除的,我们就可以解释类似如下的问题了:
(1)泛型的class对象是相通的
每个类都有一个class属性,泛型化不会改变class属性的返回值,例如:

        ArrayList<String> ls = new ArrayList<>();
        ArrayList<Integer> li = new ArrayList<>();
        System.out.println(li.getClass() == li.getClass());

以上代码返回值是true,因为List<\String>和List<\Integer>擦除后的类型都是List,没有任何区别。

(2)泛型数组初始化时不能声明泛型类型

List<String>[] listArray = new List<String>[];

因为,可以声明一个带有泛型参数的数组,但是不能初始化该数组,因为执行了类型擦除操作,List<\Object>[]与List<\String>[]就是同一回事了,编译器拒绝如此声明。

(3)instanceof不允许存在泛型参数

以下代码不能通过编译,原因一样,泛型类型被擦除了:

        List<String> list2 = new ArrayList<>();
        System.out.println(list2 instanceof List<String>);

建议94:不能初始化泛型参数和数组

泛型类型在编译期被擦除,我们类初始化时将无法获得泛型的具体参数,比如这样的代码:

public class Foo_94<T> {
    private T t = new T();
    private T[] tArray = new T[5];
    private List<T> list = new ArrayList<T>();
}

这段代码有什么问题?t、tArray、list都是类变量,都是通过new声明了一个类型,看起来非常相似啊!但这段代码是编译通不过的,因为编译器在编译时需要获得T类型,但是泛型在编译期类型已经被擦除了,所以new T()和new T[5]都会报错(可能会有疑惑:泛型类型可以擦除为顶级类Object,那T类型擦除成Object不就可以编译了吗?这样也不行,泛型只是Java语言的一部分,Java语言毕竟是一个强类型、编译型的安全语言,要确保运行期的稳定性和安全性就必须要求在编译器上严格检查)。可为什么new ArrayList<\T>()却不会报错呢?

这是因为ArrayList表面是泛型,其实已经在编译期转型为Object了,我们看一下ArrayList的源码就清楚了,代码如下:

注意看elementData的定义,它容纳了ArrayList的所有元素,其类型是Object数组,因为Object是所有类的父类,数组又允许协变,因此elementData数组可以容纳所有的实例对象。元素加入时间向上转型为Object类型(E类型转为Object),取出时向下转型为E类型(Object转为E类型),如此处理而已。

类的成员变量是在类初始化前初始化的,所以要求在初始化前它必须具有明确的类型,否则就只能声明,不能初始化。

建议95:强制声明泛型的实际类型

Arrays工具类有一个方法asList可以把一个变长参数或数组转变为列表,但是它有一个缺点:它所生成的List长度是不可改变的,而这在我们的项目开发中有时会很不方便。如果你期望生成的列表长度是可变的,那就需要自己来写一个数组的工具类了,代码如下:

public static <T> List<T> asList(T...t){
        ArrayList<T> list = new ArrayList<>();
        Collections.addAll(list,t);
        return list;
    }

这与Arrays.asList的调用方式相同,我们传入一个泛型对象,然后返回相应的List,代码如下:

    public static void main(String[] args) {
        //正常用法
        List<String> list = ArrayUtils_95.asList("A", "B");
        //参数为空
        List list1 = ArrayUtils_95.asList();
        //参数为数字和字符串的混合
        List list2 = ArrayUtils_95.asList(1, 2, 3, 4.5);
        System.out.println(list+"xxx"+list1+"xxx"+list2);
    }

这里面的三个变量需要说明:

(1)变量list
变量list是一个常规用法,没有任何问题,泛型实际的参数类型是String,返回的结果也就是一个容纳String元素的List对象

(2)变量list1
我们无法从代码中推断出list1列表中到底容纳的是什么元素(因为它传递的参数是空,编译器也不知道泛型的实际参数类型是什么),不过,编译器会很聪明地推断出最顶层类Object就是其泛型类型,也就是说list1的完整定义如下:List<\Object> list1 = ArrayUtils.asList();如此一来,编译时就不会给出"unchecked"警告了。现在新的问题出现了:如果list1是一个Integer类型的列表,而不是Object列表,因为后续的逻辑会把Integer类型加入到list1中,那该如何处理呢?

强制类型转化(把asList强制转化为List<\Integer>)?行不通,虽然Java的泛型是编译擦除式的,但是List<\Object>和List<\Integer>没有继承关系,不能进行强制转换。

重新声明一个List<\Integer>,然后读取List<\Object>元素,一个一个地向下转型过去?麻烦,而且效率又低。

最好的解决方法是强制声明泛型类型,代码如下:

List<\Integer> list1 = ArrayUtils_95.<\Integer>asList();asList方法要求的是一个泛型参数,那我们就在输入前定义这是一个Integer类型的参数,当然,输出也是Integer类型的集合了。

(3) 变量list2

list2有两种类型的元素:整数类型和浮点类型,那它生成的List泛型化参数应该是什么呢?是Integer和Float的父类Number?你太高看编译器了,它不会如此推断,当它发现多个元素的实际类型不一致时就会直接确认泛型类型是Object,而不会去追索元素类的公共父类是什么,但是对于list2,我们更希望它的泛型参数是Number,都是数字!参照list1,代码修改如下:

List<\Number> list2 = ArrayUtils.<\Number>asList(1,2,3,4.5)Number是Integer和Float的父类,先把三个输入参数向上转型为Number,那么返回的结果也就是List<\Number>类型了。

通过强制泛型参数类型,我们明确了泛型方法的输入、输出参数类型,问题是我们要在什么时候明确泛型类型呢?一句话:无法从代码中推断出泛型类型的情况下,即可强制声明泛型类型。

建议96:不同的场景使用不同的泛型通配符

Java泛型支持通配符,可以单独使用一个"?"表示任意类,也可以使用extends关键字表示某一个类(接口)的子类型,还可以使用super关键字表示某一个类(接口)的父类型,但问题是什么时候该用extends,什么时候该用super呢?

(1)泛型结构只参与"读"操作 就限定上界(extends关键字)
阅读如下代码,看我们的业务逻辑操作是否还能继续:

    public static <E> void read(List<? super E> list) {
        for (Object obj:list) {
            //业务逻辑操作
        }
    }

从List列表中读取元素的操作(比如一个数字列表中的求和计算),你觉得read方法能继续写下去吗?
当然是不能,我们不知道list到底存放的是什么元素,只能推断出是E类型的父类(当然,也可以是E类型,下同,不再赘述),但是问题是E类型的父类型是什么?无法再推断,只有运行时才知道,那么编码期就完全无法操作了。当然,你可以把它当做是Object类来处理,需要时再转换成E类型——这完全违背了泛型的初衷。
在这种情况下,"读"操作如果期望从List集合中读取数据就需要使用extends关键字了,也就是要界定泛型的上界,代码如下:

    public static <E> void read1(List<? extends E> list) {
        for (E e:list){
            //业务逻辑处理
        }
    }

此时,已经推断出List集合中取出的是E类型的元素。具体是什么类型的元素就要等到运行时才能确定,但是它一定是一个确定的类型,比如read(Arrays.asList("A"))调用该方法时,可以推断出List中的元素类型是String,之后可以对List中的元素进行操作了,如加入到另外的List<\E>集合中,或者作为Map<\E,V>的键等。

(2)泛型结构只参与"写"操作则限定下界(使用super关键字)
先看如下代码是否可以编译:

    public static void write(List<? extends Number> list) {
        //加入一个元素
        list.add(123);
    }

编译失败,失败的原因是list中的元素类型不确定,也就是编译器无法推出泛型类型到底是什么,是Integer类型?是Double?还是Byte?这些都符合extends关键字的定义,由于无法确定实际的泛型类型,所以编译器拒绝了此类操作。
在这种情况下,只有一个元素能add进去:null值,这是因为null是一个万用类型,它可以是所有类的实例对象,所以它可以加入到任何列表中。
Object是否也可以?不可以,因为他不是Number子类,而且即使把list变量修改为List<? extends Object>类型也不能加入,原因很简单,编译器无法推断出泛型类型,加什么元素都是无效的。
在这种"写"操作的情况下,使用super关键字限定泛型类型的下界才是正道,代码如下:

    public static void write(List<? super Number> list) {
        list.add(123);
        list.add(3.14);
    }

不管它是Integer类型的123,还是Float类型的3.14,都可以加入到list列表中,因为它们都是Number类型,这就保证了泛型类的可靠性。
对于是要限定上界还是限定下界,JDK的Collections.copy方法是一个非常好的例子,他实现了把源列表中的所有元素拷贝到目标列表中对应的索引位置上,代码如下:

源列表是用来提供数据的,所以src变量需要限定上界,带有extends关键字。目标列表是用来写入数据的,所以dest变量需要界定上界,带有super关键字。

如果一个泛型结构既用作"读"操作又作"写"操作,那该如何进行限定呢?不限定,使用确定的泛型类型即可,如List<\E>。

"读"操作如果不限定上限,就不知道这些数据是什么类型;"写"操作如果不限定下界,就不知道输入的数据类型。

建议97:警惕泛型是不能协变和逆变的

什么叫协变和逆变?
在编程语言的类型框架中;协变和逆变是指宽类型和窄类型在某种情况下(如参数、泛型、返回值)替换或交换的特性,简单地说,协变是用一个窄类型替换宽类型,而逆变则是用宽类型覆盖窄类型。其实,在Java中协变和逆变我们已经用了很久了,只是没发觉而已。看如下代码:

public class Base_97 {
    public Number doStuff() {
        return 0;
    }

}

class Sub extends Base_97 {
    @Override
    public Integer doStuff() {
        return 0;
    }
}

子类的doStuff方法返回值的类型比父类方法要窄,此时doStuff方法就是一个协变方法,同时根据Java覆写定义来看,这又属于覆写。那逆变是怎么回事呢?代码如下:

class Base{
    public void doStuff(Integer i){
        
    }
}

class Sub1 extends Base{
    public void doStuff(Number i) {
        
    }
}

子类的doStuff方法的参数类型比父类要宽,此时就是一个逆变方法,子类扩大了父类方法的输入参数,但根据覆写定义来看,doStuff不属于覆写,只是重载而已。由于此时的doStuff方法已经与父类没有任何关系了,只是子类独立扩展出来的一个行为,所以是否声明为doStuff方法名意义不大,逆变已经不具有特别的意义了。我们重点关注一下协变,先看如下代码是否是协变:

Base base = new Sub();

base变量是否发生了协变?是的,发生了协变,base变量是Base类型,它是父类,而其赋值确是子类实例,也就是用窄类型覆盖了宽松类型。这也叫多态,两者同含义,在Java世界里"重复发明"轮子的事情多了去了。

泛型既不支持协变,也不支持逆变。

(1)泛型不支持协变

数组和泛型很相似,一个是中括号,一个是尖括号,那我们就以数组为参照对象,看如下代码:

    public static void main(String[] args) {
        //数组支持协变
        Number[] n = new Integer[10];
        //编译不通过,泛型不支持协变
        List<Number> ln = new ArrayList<Integer>();
    }

ArrayList是List的子类型,Integer是Number的子类型,里氏替换原则在此处行不通,原因就是Java为了保证运行期的安全性,必须保证泛型参数类型是固定的,所以它不允许一个泛型参数可以同时包含两种类型,即使是父子类关系也不行。

泛型不支持协变,但可以使用通配符模拟协变,代码如下:

//Number的子类型(包括Number类型)都可以是泛型参数类型
List<? extends Number> ln = new ArrayList<Integer>();

"? extends Number"表示的意思是,允许Number所有的子类(包括自身)作为泛型参数类型,但在运行期只能是一个具体类型,或者是Integer类型,或者是Double类型,或者是Number类型,也就是说通配符只是在编码期有效,运行期必须是一个确定类型。

(2)泛型不支持逆变

Java虽然可以允许逆变存在,但在对类型赋值上是不允许逆变的,你不能把一个父类实例对象赋值给一个子类类型变量,泛型自然也不允许这种情况发生了,但是它可以使用supper关键字来模拟实现,代码如下:

//Integer的父类型(包括Integer)都可以是泛型参数类型
List<? super Integer> li = new ArrayList<Number>();

"? super Integer"的意思是可以把所有的Integer父类型(自身、父类或接口)作为参数,这里看着就像是把一个Number类型的ArrayList赋值给了Integer类型的List,其外观类似一个使用宽类型覆盖一个窄类型,它模拟了逆变的实现。

建议98:建议采用的顺序是List、List<?>、List

posted @ 2019-12-31 14:38  春刀c  阅读(390)  评论(0编辑  收藏  举报