《大话Java性能优化》面向对象及基础类型相关部分

3.1 面向对象及基础类型

3.1.1 采用Clone()方式创建对象

Java语言里面的所有类都默认继承自java.lang.Object类,在java.lang.Object类里面有一个clone()方法,JDK API的说明文档里面解释了这个方法会返回Object对象的一个拷贝。我们需要说明两点:一是拷贝对象返回的是一个新对象,而不是一个对象的引用地址;二是拷贝对象与用new关键字操作符返回的新对象的区别是,这个拷贝已经包含了一些原来对象的信息,而不是对象的初始信息,即每次拷贝动作不是一个针对全新对象的创建。

当我们使用new关键字创建类的一个实例时,构造函数中的所有构造函数都会被自动调用。但如果一个对象实现了Cloneable接口,那么我们可以通过调用它的clone()方法,注意,clone()方法不会调用任何构造函数。

代码3-1所示是工厂模式的一个典型实现,工厂模式是采用工厂方法代替new操作的一种模式,所以工厂模式的作用就相当于创建实例对象的new操作符。

代码清单3-1 创建新对象

public static CreditgetNewCredit()

{

    return new Credit();//创建一个新的Credit对象

}

如果我们采用clone()方法的方式创建对象,那么原有的信息可以被保留,因此创建速度会加快。如清单3-2所示,改进后的代码使用了clone()方法。

代码清单3-2 使用了clone()方法

private static CreditBaseCredit = new Credit();

public static CreditgetNewCredit()

{

    return (Credit)BaseCredit.clone();

}

 

3.1.2 避免对boolean判断

Java里的boolean数据类型被定义为存储8位(1个字节)的数值形式,但只能是true或是false。

有些时候我们出于写代码的习惯,经常容易导致习惯性思维,这里指的习惯性思维是想要对生成的数据进行判别,这样感觉可以在该变量进入业务逻辑之前有一层检查、判定。对于大多数的数据类型来说,这是正确的做法,但是对于boolean变量,我们应该尽量避免不必要的等于判定。如果尝试去掉boolean与true的比较判断代码,大体上来说,我们会有2个好处。

n  代码执行的更快(生成的字节码少了5个字节);

n  代码整体显得更加干净。

例如代码清单3-3和3-4所示,我们针对这个判定进行了代码解释,这两个类只有一个差距,即是否调用了等号表达式进行了一致性判定,如代码string.endswith ("a") == true。

代码清单3-3 boolean示例1

boolean method (stringstring) {

    return string.endswith ("a") ==true;//判断是否以a结尾

}

代码清单3-4 boolean示例2

boolean method (stringstring) {

  return string.endswith ("a");

}

 

3.1.3 多用条件操作符

我们在编写代码的过程中很喜欢使用if-else用于判定,这种思维来源于C语言学习的经历。大多数中国学生都是从谭老师的C语言书籍[1]了解计算机领域知识的,我们在高级语言程序设计过程中,如果有可能,尽量使用条件操作符"if (cond) return; else return;"这样的顺序判断结构,主要原因还是因为条件操作符更加简捷,代码看起来会少一点。其实JVM会帮助我们优化代码,但是个人感觉能省就省吧,代码过多让人看着不爽。代码清单3-5和3-6所示是示例代码,对比了两者的区别。

代码清单3-5 if示例1

//采用if-else的方式

public intmethod(boolean isdone){

        if (isdone) {

            return 0;

        } else {

            return 1;

        }

}

代码清单3-6 if示例

public intmethod(boolean isdone) {

   return (isdone ? 0 : 1);

}

上面两个例子,我们可以看到有一定差距,代码行数缩短了50%。其实现代JVM已经在编译时做了类似的处理,但是从代码整洁度考虑,作者觉得还是推荐多采用代码清单3-6的方式实现。

3.1.4 静态方法替代实例方法

在Java中,使用static关键字描述的方法是静态方法。与静态方法相比,实例方法的调用需要消耗更多的系统资源,这是因为实例方法需要维护一张类似虚拟函数导向表的结构,这样可以方便地实现对多态的支持。对于一些常用的工具类方法,我们没有必要对其进行重载,那么我们可以尝试将它们声明为static,即静态方法,这样有利于加速方法的调用。

如代码清单3-7所示,我们分别定义了两个方法,一个是静态方法,一个是实例方法,然后在main函数进程里分别调用10亿次两个方法,计算两个方法的调用总计时间。

代码清单3-7 静态方法示例

public static voidstaticMethod(){

}

//实例方法

public voidinstanceMethod(){

 

}

 

@Test

public static voidmain(String[] args){

  long start = System.currentTimeMillis();

   //循环10亿次,创建静态方法

for(inti=0;i<1000000000;i++){

 staticVSinstance.staticMethod();

}

System.out.println(System.currentTimeMillis()- start);

 

 start = System.currentTimeMillis();

staticVSinstance si1 =new staticVSinstance();

        //循环10亿次,创建实例方法

for(intj=0;j<1000000000;j++){

si1.instanceMethod();

 }

System.out.println(System.currentTimeMillis()- start);

}

清单3-7代码中申明了一个静态方法staticMethod()和一个实例方法instanceMethod(),运行程序,统计了两个方法调用若干次后的耗时,程序输出如下,单位是毫秒,方法内部没有实现任何代码。请读者注意,由于机器差别,所以运行的结果可能也会有所不同。

代码清单3-8 程序运行输出

733   764

总的来说,静态方法和实例方法的区别主要体现在两个方面:

n 在外部调用静态方法时,可以使用"类名.方法名"的方式,也可以使用"对象名.方法名"的方式。而实例方法只有后面这种方式。也就是说,调用静态方法可以无需创建对象。

n 静态方法在访问本类的成员时,只允许访问静态成员(即静态成员变量和静态方法),而不允许访问实例成员变量和实例方法;实例方法则无此限制。

从上面的例子我们可以这么总结,如果你没有必要去访问对象的外部,那么就让你的方法成为静态方法。静态方法会被更快地调用,因为它不需要一个虚拟函数导向表,该表用来告诉你如何区分方法的性质,调用这个方法不会改变对象的状态。

3.1.5 有条件地使用final关键字

在Java中,final关键字可以被用来修饰类、方法和变量(包括成员变量和局部变量)。我们在使用匿名内部类的时候可能会经常用到final关键字,例如Java中的String类就是一个final类。

如代码清单3-9所示,由于final关键字会告诉编译器,这个方法不会被重载,所以我们可以让访问实例内变量的getter/setter方法变成“final”。

代码清单3-9 非final类

public void setsize(int size) {

  _size = size;

}

private int _size;

代码清单3-10 final类

//告诉编译器该方法不会被重载

final public voidsetsize (int size) {

    _size = size;

}

private int _size;

总的来说,使用final方法的原因有两个[2]。第一个原因是把方法锁定,以防任何继承类修改它的含义。第二个原因是提高效率。在早期的Java实现版本中,会将final方法转为内嵌调用。但是如果方法过于庞大,可能看不到内嵌调用带来的任何性能提升。JDK6以后的Java版本已经不再需要使用final方法进行这些优化了。

3.1.6 避免不需要的instanceof操作

instanceof关键字是Java的一个二元操作符,和==、>、<是属于同一类表达式。由于instanceof是由字母组成的,所以它也是Java的保留关键字。instanceof的作用是测试它左边的对象是否是它右边的类的实例,返回boolean类型的数据,即如果左边的对象的静态类型等于右边的,我们使用的instanceof表达式的返回值会返回true。

代码清单3-11 instanceof示例

void method (dog dog,faClass faclass) {

    dog d = dog;

   if (d instanceof faClass) // 这里永远都返回true.

      system.out.println("dog is a faClass");

    faClass faclass = faclass;

    if (faclass instanceof object) // alwaystrue.

      system.out.println("uiso is anobject");

}

上述代码里面对dog类型的变量都做了判定,由于已经确定类继承自基类,所以我们可以删除不需要的instanceof操作。当然,这样的操作修改还是需要基于实际的业务逻辑,有些时候为了保证数据准确性、安全性,还是需要层层检查的。如代码清单3-12所示,代码可以被精简成这样。

代码清单3-12 instanceof示例

void method () {

   dog d;

    system.out.println ("dog is an faclass");

    system.out.println ("uiso is an faclass");

}

另外,绝大多数情况下都不推荐使用instanceof方法,还是好好利用多态特性吧,这是面向对象的基本功能。

3.1.7 避免子类中存在父类转换

我们知道在Java语言里所有的类都是直接或者间接继承自Object类。我们可以说,Object类是所有Java类的祖先,因此每个类都使用Object作为超类,所有对象(包括数组)都实现这个类的方法。在不明确是否提供了超类的情况下,Java会自动把Object作为被定义类的超类。

我们可以使用类型为Object的变量指向任意类型的对象。同样,所有的子类也都隐含的“等于”其父类。那么,程序代码中就没有必要再把子类对象转换为它的父类了。

代码清单3-13 避免父类转换示例

class oriClass {

  string _id = "unc";

}

class dog extendsoriClass{

    void method () {

     dog dog = new dog();

     oriClass animal = (oriClass)dog;  //已经确定继承自oriClass类了,因此没有必要再转对象类型

     object o = (object)dog;

  }

}

代码清单3-14 避免父类转换示例

class dog extendsoriClass {

  //去掉了转换父类操作

  void method () {

     dog dog = new dog();

     unc animal = dog;

    object o = dog;

}

}

 

3.1.8 建议多使用局部变量

调用方法时传递的参数以及在调用中创建的临时变量都被保存在栈(Stack)里面,因此读写速度较快。其他变量,例如静态变量、实例变量,它们都在堆(heap)中被创建,也被保留在那里,所以读写相对于保存在栈里面的数据来说,它的速度较慢。

Java类的成员变量有两种,一种是被static关键字修饰的变量,叫类变量或者静态变量,另一种没有static修饰,称为实例变量。在语法定义上的区别,静态变量前要加static关键字,而实例变量前则不加。

静态变量(类变量)被所有对象共有,如果其中一个对象将它的值改变,那么其他对象得到的就是改变后的结果。实例变量属于对象私有,如果某一个对象将其值改变,也不会影响到其他对象。此外,静态变量和实例变量都属全局变量。

程序运行过程当中,实例变量属于某个对象的属性,必须创建实例对象,其中的实例变量才会被分配空间,才能使用这个实例变量。静态变量不属于某个实例对象,而是属于类,所以也称为类变量,只要程序加载了类的字节码,不用创建任何实例对象,静态变量就会被分配空间,静态变量就可以被使用了。总之,实例变量必须创建对象后才可以通过这个对象来使用,静态变量则可以直接使用类名来引用。

代码3-15演示了分别创建100万次实例变量和静态变量所消耗的时间,从测试结果来看,使用局部变量和静态变量的操作时间对比较为明显。

代码清单3-15 局部变量和静态变量之间的对比测试

public classvariableCompare {

public static int b =0;

public static voidmain(String[] args){

int a = 0;

long starttime =System.currentTimeMillis();

for(inti=0;i<1000000;i++){

    a++;//在函数体内定义局部变量

}

System.out.println(System.currentTimeMillis()- starttime);

starttime =System.currentTimeMillis();

for(inti=0;i<1000000;i++){

    b++;//在函数体内定义局部变量

}

System.out.println(System.currentTimeMillis()- starttime);

}

}

以上两段代码的运行时间分别为0和15,单位是毫秒。由此结果可见,局部变量的访问速度远远高于类的成员变量。

3.1.9 运算效率最高的方式——位运算

在Java语言中的所有运算中,位运算是最为高效的。位运算表达式由操作数和位运算符组成,实现对整数类型的二进制数进行位运算。位运算符可以分为逻辑运算符(包括~、&、|和^)及移位运算符(包括>>、<<和>>>)。因此,可以尝试使用位运算方式代替部分算术运算,来提高系统的运行速度。最典型示例的就是对于整数的乘除运算优化。

代码清单3-16实现了位运算与算术运算的对比,两个运算方式输出的结果是一样的,但是耗时差距达到了8倍。

代码清单3-16 位运算与算术运算对比试验

public classyunsuanClass {

public static voidmain(String args[]){

long start =System.currentTimeMillis();

long a=1000;

    //执行1000万次算术运算

for(inti=0;i<10000000;i++){

a*=2;

a/=2;

}

System.out.println(a);

  System.out.println(System.currentTimeMillis() - start);

start =System.currentTimeMillis();

   //执行1000万次位运算

for(inti=0;i<10000000;i++){

a<<=1;

a>>=1;

}

System.out.println(a);

System.out.println(System.currentTimeMillis()- start);

}

}

两段代码执行了完全相同的功能,在每次循环中,整数1000乘以2,然后除以2。第一个循环耗时546,第二个循环耗时63,单位是毫秒(ms)。

我们的程序内部进行位运算时,需要注意以下几点。

n >>>和>>的区别是:在执行运算时,>>>运算符的操作数高位补0,而>>运算符的操作数高位移入原来高位的值。

n 右移一位相当于除以2,左移一位(在不溢出的情况下)相当于乘以2;移位运算速度高于乘除运算。

n 若进行位逻辑运算的两个操作数的数据长度不相同,则返回值应该是数据长度较长的数据类型。

n 按位异或可以不使用临时变量完成两个值的交换,也可以使某个整型数的特定位的值翻转。

n 按位与运算可以用来屏蔽特定的位,也可以用来取某个数型数中某些特定的位。

n 按位或运算可以用来对某个整型数的特定位的值置l。

个人建议,由于移位操作需要一定的底层编程技术能力,所以对于刚开始接触程序设计的读者来说,除非是在一个非常大的循环内,性能因素至关重要,而且你很清楚你自己在做什么,才建议使用这种方法,否则提高性能所带来的程序易读性的降低就不划算了。

3.1.10 用一维数组代替二维数组

JDK很多类库是采用数组方式实现的数据存储,比如ArrayList、Vector等,数组的优点是随机访问性能非常好。

一维数组和二维数组的访问速度不一样,二维数组的访问速度要优于一维数组,但是,二维数组比一维数组占用更多的内存空间,大概是10倍左右。在性能敏感的系统中要使用二维数组,如果内存不足,尽量将二维数组转化为一维数组再进行处理,以节省内存空间。

如代码清单3-17所示,我们演示了一维数组和二维数组比较的示例程序。

代码清单3-17 一维数组和二维数组对比

public class arrayTest{

     public static void main(String[] args){

         long start =System.currentTimeMillis();

         int[] arraySingle = new int[1000000];

         int chk = 0;

        //构建1亿个数组元素,并赋值

         for(int i=0;i<100;i++){

             for(intj=0;j

                 arraySingle[j] = j;

             }

         }

       //遍历1亿个数组元素,并赋值给局部变量

         for(int i=0;i<100;i++){

             for(intj=0;j

                 chk = arraySingle[j];

             }

         }

        System.out.println(System.currentTimeMillis() - start);

 

         start = System.currentTimeMillis();

         int[][] arrayDouble = newint[1000][1000];

         chk = 0;

        //构建对应于1亿个一维数组的二维数组

         for(int i=0;i<100;i++){

             for(intj=0;j

                 for(intk=0;k

                     arrayDouble[i][j]=j;

                 }

             }

         }

      //遍历这些二维数组

         for(int i=0;i<100;i++){

            for(int j=0;j

                 for(intk=0;k

                     chk = arrayDouble[i][j];

                 }

             }

         }

        System.out.println(System.currentTimeMillis() - start);

 

         start = System.currentTimeMillis();

         arraySingle = new int[1000000];

         int arraySingleSize =arraySingle.length;

         chk = 0;

      //遍历一维数组

         for(int i=0;i<100;i++){

             for(intj=0;j

                 arraySingle[j] = j;

             }

        }

         for(int i=0;i<100;i++){

             for(intj=0;j

                 chk = arraySingle[j];

             }

         }

         System.out.println(System.currentTimeMillis()- start);

 

         start = System.currentTimeMillis();

         arrayDouble = new int[1000][1000];

         int arrayDoubleSize =arrayDouble.length;

         int firstSize = arrayDouble[0].length;

         chk = 0;

        //遍历二维数组

         for(int i=0;i<100;i++){

             for(intj=0;j

                 for(intk=0;k

                     arrayDouble[i][j]=j;

                 }

             }

         }

         for(int i=0;i<100;i++){

             for(intj=0;j

                 for(intk=0;k

                     chk = arrayDouble[i][j];

                 }

             }

         }

        System.out.println(System.currentTimeMillis() - start);

     }

}

第一段代码操作的是一维数组的赋值、取值过程,第二段代码操作的是二维数组的赋值、取值过程,第三段代码是一维数组遍历、赋值过程,第四段代码是二维数组的遍历、赋值过程。输出时间分别是374、312、297、266毫秒。从3-17所示代码的运行结果来看,二维数组的速度有一定优势,但是请注意,这是JVM牺牲了内存空间换取的性能,请读者自己做出选择。

3.1.11 布尔运算代替位运算

虽然位运算的速度远远高于算术运算,但是在条件判断时,使用位运算替代布尔运算是非常非常错误的选择。在条件判断时,Java会对布尔运算做相当充分的优化。假设有表达式a、b、c进行布尔运算“a&&b&&c”,根据逻辑与的特点,只要在整个布尔表达式中有一项返回false,整个表达式就返回false,因此,当表达式a为false时,该表达式将立即返回false,而不会再去计算表达式b和c。若此时,表达式a、b、c需要消耗大量的系统资源,这种处理方式可以节省这些计算资源。同理,当计算表达式“a||b||c”时,只要a、b或c,3个表达式其中任意一个计算结果为true时,整体表达式立即返回true,而不去计算剩余表达式。简单地说,在布尔表达式的计算中,只要表达式的值可以确定,就会立即返回,而跳过剩余子表达式的计算。如果使用位运算(按位与、按位或)代替逻辑与和逻辑或,虽然位运算本身没有性能问题,但是位运算总是要将所有的子表达式全部计算完成后,再给出最终结果。因此,从这个角度看,使用位运算替代布尔运算会使系统进行很多无效计算。代码清单3-18演示了位运算与布尔运算的对比实验。

代码清单3-18 位运算与布尔运算的比较

public classOperationCompare {

     public static void booleanOperate(){

         long start =System.currentTimeMillis();

         boolean a = false;

         boolean b = true;

         int c = 0;

         //下面循环开始进行位运算,表达式里面的所有计算因子都会被用来计算

         for(int i=0;i<1000000;i++){

            if(a&b&"Test_123".contains("123")){

                 c = 1;

             }

         }

        System.out.println(System.currentTimeMillis() - start);

     }

 

     public static void bitOperate(){

        long start =System.currentTimeMillis();

         boolean a = false;

         boolean b = true;

         int c = 0;

         //下面循环开始进行布尔运算,只计算表达式a即可满足条件

         for(int i=0;i<1000000;i++){

            if(a&&b&&"Test_123".contains("123")){

                 c = 1;

             }

         }

         System.out.println(System.currentTimeMillis()- start);

    }

 

     public static void main(String[] args){

         OperationCompare.booleanOperate();

         OperationCompare.bitOperate();

     }

}

上面的示例代码运行结果显示布尔计算大大优于位运算(位运算用了63毫秒,布尔运算几乎没有耗时),但是,这个结果不能说明位运算比逻辑运算慢,因为在所有的逻辑与运算中,都省略了表达式“"Test_123".contains("123")”的计算,而所有的位运算都没能省略这部分系统开销。

3.1.12 提取表达式优化

在大部分情况下,由于计算机运算单元(CPU)的不断发展,我们现在用的CPU大多是多核的,有些可能还会附带GPU,这样我们可以把图像计算、数据挖掘算法这类需要消耗大量计算资源的程序放到GPU上运行,这是题外话了,不多展开。这样,我们知道在程序高速运行过程当中,少量的重复代码并不会对性能构成太大的威胁,但是如果你希望将系统性能发挥到极致,还是有很多地方可以优化的。比如代码清单3-19所示的代码,我们通过采用局部变量的方式,避免了重复的计算,虽然计算量相对于CPU来说很微小,但是总是还是可以节省一点时间的。

代码清单3-19 提取表达式实验

public classduplicatedCode {

     public static void beforeTuning(){

         long start =System.currentTimeMillis();

         double a1 = Math.random();

         double a2 = Math.random();

         double a3 = Math.random();

         double a4 = Math.random();

         double b1,b2;

     //开始循环运算

         for(int i=0;i<10000000;i++){

             b1 = a1*a2*a4/3*4*a3*a4;

             b2 = a1*a2*a3/3*4*a3*a4;

         }

        System.out.println(System.currentTimeMillis() - start);

     }

 

     public static void afterTuning(){

         long start =System.currentTimeMillis();

         double a1 = Math.random();

         double a2 = Math.random();

         double a3 = Math.random();

         double a4 = Math.random();

         double combine,b1,b2;

     //计算公式被移到了外面

         for(int i=0;i<10000000;i++){

             combine = a1*a2/3*4*a3*a4;

             b1 = combine*a4;

             b2 = combine*a3;

         }

        System.out.println(System.currentTimeMillis() - start);

     }

 

     public static void main(String[] args){

         duplicatedCode.beforeTuning();

         duplicatedCode.afterTuning();

     }

}

两段代码的差别是提取了重复的攻势,使得这个公式的每次循环计算只执行一次。分别耗时202ms和110ms,可见,提取复杂的重复操作是相当具有意义的。这个例子告诉我们,在循环体内,如果能够提取到循环体外的计算公式,最好提取出来,尽可能让程序少做重复的计算。

3.1.13 不要总是使用取反操作符(!)

取反操作符(!)表示异或操作,使用起来很方便,但是也要注意的是,它降低了程序的可读性,所以建议不要经常使用。

代码清单3-20 取反操作符示例

boolean method (booleana, boolean b) {

if (!a)

return !a;

else

return !b;

}

 

3.1.14 不要重复初始化变量

默认情况下,调用类的构造函数时,Java会把变量初始化为一个确定的值,例如,所有的对象被设置成Null,整数变量设置成0,float和double变量设置成0.0,逻辑值设置成false。当一个类从另一个类派生时,这一点尤其应该注意,因为用new关键字创建一个对象时,构造函数链中的所有构造函数都会被自动调用。

这里需要注意,当我们给成员变量设置初始值,又需要调用其他方法的时候,最好放在一个方法里面。比如initXXX()中,因为直接调用某方法赋值可能会因为类尚未初始化而抛空指针异常。

此外,如果不初始化变量,那么当我们直接调用变量的时候,系统会给对象或变量随机赋一个值,这样容易产生不必要的错误。

3.1.15 变量初始化过程思考

由于智能化的GUI工具,例如Eclipse、IntelliIDEA这样的工具存在,所以程序员很少会去主动思考Java程序对于成员变量的声明及初始化顺序,一般都是按照自己的习惯方式进行编码,如果出错了GUI工具也会自动提醒,点一下左侧的修复按钮就会自动修复代码。

我们思考一个问题,为什么抽象类不能用final关键字声明?如果读者看过闫宏博士的《Java与模式》书,那么可能已经知道了答案,因为一个类一旦被修饰成了final,那么意味着这个类是不能被继承的,并且Java中定义抽象类不能被实例化。如果一个抽象类可以是final类型的,那么这个类就不能被继承,也不能被实例化,那么它也就没有存在的意义了。即从语言的角度来讲,一个类既然是抽象类,那么它就是为了被其他类继承,所以给它标识为final是没有意义的。

我们来看一系列的示例代码,通过这些代码可以帮助读者理解成员变量的初始化过程。

如代码清单3-21所示,我们首先定义局部变量variableA的值为1,然后再申明变量,由于main主函数里面会实例化类varClass,所以variableA会被赋值为1。

代码清单3-21 普通局部变量

public class varClass {

    {

        variableA = 1;

    }

 

    private int variableA;

 

public static voidmain(String[] args){ 

3-24Class test1 = new3-24Class (); 

System.out.println(test1.variableA); 

}

}

程序运行输出为1,如果我们想要打印出variableA的值,我们必须等到成员变量被申明后才能这么做,代码清单3-22所示的代码,GUI工具会提醒我们,“cannot reference a fieldbefore it is defined”,也就是无法在申明之前调用它的引用地址。

代码清单3-22 普通局部变量尝试输出

public class errorClass

{

variableA = 1;

System.out.println(variableA);//这一行会抛出错误,必须注释后才能在GUI工具里面运行这个类

 

private final intvariableA;

 

public static voidmain(String[] args){ 

errorClass test1 = new errorClass(); 

       System.out.println(test1.variableA); 

}

}

如果把variableA申明为final呢?如代码清单3-23所示。

代码清单3-23 final局部变量

public class finalClass{

    {

        variableA = 1;

    }

 

    private final int variableA;

 

    public static void main(String[]args){ 

        finalClass test1 = newfinalClass(); 

       System.out.println(test1.variableA); 

    }

}

输出依然是1,但是如果我们尝试对final关键字申明的variableA变量赋值,那么又会产生错误“The final fieldClass1.variableA cannot be assigned”,即既然是不可变的变量,又怎么允许在申明之后的代码块里面继续变更变量值呢。

如果我们对3-22代码稍作修改,定义privateint variable=2,程序的输出会变成2,这是因为代码的分析、执行是从上至下的,实质上我们可以看出,JVM将变量variable的申明和赋值分为了两个步骤,即先执行申明操作,然后顺序地执行第一步赋值操作,即赋值为1,然后执行第二步赋值操作,赋值为2,这样最终类实例会打印出第二个值,即2。

如果我们依然相打印出1呢?我们可以通过申明变量为静态变量的方式来达到这样的目的,如代码清单3-23所示。

代码清单3-23 static变量

public classstaticClass {

//static

    {

        variableA = 1;

    }

 

    private static int variableA = 2;

 

    public static void main(String[]args){ 

        staticClass test1 = newstaticClass(); 

        System.out.println(test1.variableA); 

    }

}

如果希望输出为2,那么可以释放3-23代码中注释的针对代码块的static关键字申明,这样可以确保程序先执行静态代码块,也就确保了赋值2的定义被后续执行。

这一个小章节讲述的是成员变量、代码块的申明过程、执行顺序,我们在很多场景下可能不会去思考这类问题,但是作者觉得还是有必要思考的,这样可以帮助程序员理清思路,也许,下一个语言是由你创造的呢?

3.1.16 对象的创建、访问过程

n 对象的创建

创建一个对象通常是需要new关键字,当虚拟机遇到一条new指令时,首先检查这个指令的参数是否在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果那么执行相应的类加载过程。

类加载检查通过后,虚拟机将为新生对象分配内存。为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。分配的方式有两种,一种叫指针碰撞,假设Java堆中内存是绝对规整的,用过的和空闲的内存各在一边,中间放着一个指针作为分界点的指示器,分配内存就是把那个指针向空闲空间的那边挪动一段与对象大小相等的距离。另一种叫空闲列表,如果Java堆中的内存不是规整的,虚拟机就需要维护一个列表,记录哪个内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。采用哪种分配方式是由Java堆是否规整决定的,而Java堆是否规整是由所采用的垃圾收集器是否带有压缩整理功能决定的。另外一个需要考虑的问题就是对象创建时的线程安全问题,有两种解决方案:一是对分配内存空间的动作进行同步处理;另一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存(TLAB),哪个线程要分配内存就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时才需要同步锁定。

内存分配完成后,虚拟机需要将分配到的内存空间初始化为零值。这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就可以直接使用。接下来虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息等,这些信息存放在对象的对象头中。

上面的工作都完成以后,从虚拟机的角度来看一个新的对象已经产生了。但是从Java程序的角度,还需要执行init方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

n 对象的内存布局

在HotSpot虚拟机中,对象在内存中存储的布局可分为三个部分,即对象头、实例数据和对齐填充。

对象头包括两个部分:第一部分用于存储对象自身的运行时数据,如哈希码、GC分代年龄、线程所持有的锁等。官方称之为“Mark Word”。第二个部分为是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

实例数据是对象真正存储的有效信息,也是程序代码中所定义的各种类型的字段内容。

对齐填充并不是必然存在的,仅仅起着占位符的作用。Hotpot VM要求对象起始地址必须是8字节的整数倍,对象头部分正好是8字节的倍数,所以当实例数据部分没有对齐时,需要通过对齐填充来对齐。

n 对象的访问定位

Java程序通过栈上的reference数据来操作堆上的具体对象。主要的访问方式有使用句柄和直接指针两种:

n 句柄:Java堆将会划出一块内存来作为句柄池,引用中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。

n 直接指针:Java堆对象的布局要考虑如何放置访问类型数据的相关信息,引用中存储的就是对象地址。

两个方式各有优点,使用句柄最大的好处是引用中存储的是稳定的句柄地址,对象被移动时只会改变句柄中实例的地址,引用不需要修改、使用直接指针访问的好处是速度更快,它节省了一次指针定位的时间开销。

3.1.17 在switch语句中使用字符串

对于switch语句,开发人员并不陌生。大部分编程语言中都有类似的语法结构,用来根据某个表达式的值选择要执行的语句块。对于switch语句中的条件表达式类型,不同编程语言所提供的支持是不一样的。对于Java语言来说,在Java 7之前,switch语句中的条件表达式的类型只能是与整数类型兼容的类型,包括基本类型char、byte、short和int,与这些基本类型对应的封装类Character、Byte、Short和Integer,还有枚举类型。这样的限制降低了语言的灵活性,使开发人员在需要根据其他类型的表达式来进行条件选择时,不得不增加额外的代码来绕过这个限制。为此,Java 7放宽了这个限制,额外增加了一种可以在switch语句中使用的表达式类型,那就是很常见的字符串,即String类型。

Java 7新特性并没有改变switch的语法含义,只是多了一种开发人员可以选择的条件判断的数据类型。但是这个简单的新特性却带来了重大的影响,因为根据字符串进行条件判断在开发中是很常见的。

考虑这样一个应用场景,在程序中需要根据用户的性别来生成合适的称谓。判断条件的类型可以是字符串,不过这在Java 7之前的switch语句中是行不通的,之前只能添加额外的代码先将字符串转换成整数类型。而在Java7种就可以根据字符串进行条件判断,代码如清单3-24所示。

代码清单3-24 Switch新特性

public class Title {

   public String generate(String name,Stringgender){

        String title = "";

        switch(gender) {

            case "男":

                title = name + "先生";

                break;

            case "女":

                title = name + "女士";

                break;

            default:

                title = name;

        }

        return title;

    }

}

在switch语句中,表达式的值不能是null,否则会在运行时抛出NullPointException。在case子句中也不能使用Null,否则会出现编译错误。

根据siwtch语句的语法要求,其case子句的值是不能重复的。这个要求对字符串类型的条件表达式同样适用。不过对于字符串来说,这种重复值得检查还有一个特殊之处,那就是Java代码中的字符串可以包含Unicode转义字符。重复值的检查是在Java编译器对Java源代码进行相关的词法转换之后才进行的。这个词法转换过程中包括了对Unicode转义字符的处理。也就是说,有些case子句的值虽然在源代码中看起来是不同的,但是经词法转换后是一样的,这就会造成编译错误。如下面的代码是无法通过编译的。这是因为其中的switch语句中的两个case字句所使用的值在经过词法转换之后会变成一样的。

代码清单3-25 错误的switch

public class Title {

    public String generate(String name,Stringgender){

        String title = "";

        switch(gender) {

            case "男":

                title = name + "先生";

                break;

            case "\u7537":

                title = name + "女士";

                break;

            default:

                title = name;

        }

        return title;

    }

}

代码清单3-25所示代码,eclipse会提示错误:Duplicate case。

通过以上代码的演示,大家应该清楚了新特性的作用。实际上,这个新特性是在编译器这个层次上实现的。而在Java虚拟机和字节代码这个层次上,还是只支持在switch语句中使用与整数类型兼容的类型。这么做的目的是为了减少这个特性所影响的范围,以降低实现的代价。在编译器层次实现的含义是,虽然开发人员在Java源代码的switch语句中使用了字符串类型,但是在编译的过程中,编译器会根据源代码的含义来进行转换,将字符串类型转换成与整数类型兼容的格式。不同的Java编译器可能采用不同的方式来完成这个转换,并采用不同的优化策略。举个例子,如果switch语句中只包含一个case子句,那么可以简单地将其转换成一个if语句。如果switch语句中包含一个case子句和一个default子句,那么可以将其转换成一个if-else语句。而对于复杂的情况,即switch语句中包含多个case子句的情况,也可以转换成Java 7之前的switch语句,只不过使用字符串的哈希值作为switch语句的表达式的值。

为了探究OpenJDK中的Java编译器使用的是什么样的转换方式,需要一个名为JAD的工具。这个工具可以把Java的类文件反编译成Java的源代码。在对编译生成Title类的class文件使用了JAD之后,所得到的内容如清单3-26所示。

代码清单3-26 编译后的类代码

public class Title

{

public Stringgenerate(String name,String gender)

{

    String title = “”;

    String s = gender;

    byte byte0 = -1;

    switch(s.hashCode())

    {

       case 30007:

            if(s.equals(“\u7537”))

byte0 = 0;

  break;

       case 22899:

          if(s.equals(“\u5973”))

             byte0 = 0;

break;

}

switch(byte0)

 {

 case 0://’\0’

title = (new StringBuilder()).append(name).append(“\u5148\u751F”).toString();

          break;

case 1://’\001’

title = (new StringBuilder()).append(name).append(“\u5973\u58EB”).toString();

         break;

       default:

         title = name;

         break;

}

return title;

}

}

从上面的代码可以看出,原来用在switch语句中的字符串被替换成了对应的哈希值,而case子句的值也被换成了原来字符串常量的哈希值。经过这样的转换,Java虚拟机所看到的仍然是与整数类型兼容的类型。在这里值得注意的是,在case子句对应的语句块中仍然需要使用String的equals方法来进行字符串比较。这是因为哈希函数在映射的时候可能存在冲突,多个字符串的哈希值可能是一样的。进行字符串比较是为了保证转换之后的代码逻辑与之前完全一样。

Java 7引入的这个新特性虽然为开发人员提供了方便,但是比较容易被误用,造成代码的可维护性差的问题。提到这一点就必须要说一下Java SE 5.0中引入的枚举类型。switch语句的一个典型的应用就是在多个枚举值之间进行选择。在Java SE 5.0之前,一般的做法是使用一个整数来为这些枚举值编号,比如0表示“男”,1表示“女”。在switch语句中使用这个整数编码来进行判断。这种做法的弊端有很多,比如不是类型安全的、没有名称空间、可维护性差和不够直观等。Joshua Bloch最早在他的“Effective Java”一书中提出了一种类型安全的枚举类型的实现方式。这种方式在J2SE 5.0中被引入到标准库,就是现在的enum关键字。

Java语言中的枚举类型的最大优势在于它是一个完整的Java类,除了定义其中包含的枚举值之外,还可以包含任意的方法和域,以及实现任意的接口。这使得枚举类型可以很好地与其他Java类进行交互。在涉及多个枚举值的情况下,都应该优先使用枚举类型。

在Java 7之前,也就是switch语句还不支持使用字符串表达式类型时,如果要枚举的值本身都是字符串,使用枚举类型是唯一的选择。而在Java 7中,由于switch语句增加了对字符串条件表达式的支持,一些开发人员会选择放弃枚举类型而直接在case子句中用字符串常量来列出各个枚举值。这种方式虽然简单和直接,但是会带来维护上的麻烦,尤其是这样的switch语句在程序的多个地方出现的时候,在程序中多次出现字符串常量总是一个不好的现象,而使用枚举类型就可以避免这种情况。

3.1.18 数值字面量的改进

在编程语言中,字面量(literal)指的是在源代码中直接表示的一个固定的值。绝大部分编程语言都支持在源代码中使用基本类型字面量,包括整数、浮点数、字符串和布尔值等。少数编程语言支持复杂类型的字面量,如数组和对象等。Java语言只支持基本类型的字面量。Java7中对数值类型字面量进行了增强,包括对整数和浮点数字面量的增强。

在Java源代码中使用整数字面量的时候,可以指定所使用的进制。在Java 7之前,所支持的进制包括十进制、八进制和十六进制。十进制是默认使用的进制。八进制是用在整数字面量之前添加“0”来表示的,而十六进制则是用在整数字面量之前添加“0x”或“0X”来表示的。Java 7中增加了一种可以在字面量中使用的进制,即二进制。二进制整数字面量是通过在数字前面添加“0b”或“0B”来表示的。

代码清单3-27 二进制示例1

import staticjava.lang.System.out;

public classBinaryIntegralLiteral {

    public void display(){

        out.println(0b001001);//输出9

        out.println(0B001110);//输出14

    }

    public static void main(String[] args){

        BinaryIntegralLiteral b = newBinaryIntegralLiteral();

        b.display();

    }

}

这种新的二进制字面量的表示方式使得在源代码中使用二进制数据变得更加简单,不再需要先手动将数据转换成对应的八/十/十六进制的数值。

如果Java源代码中有一个很长的数值字面量,开发人员在阅读这段代码时需要很费力地分辨数字的位数,以知道其所代表的数值大小。在现实生活中,当遇到很长的数字的时候,我们采取的是分段分隔的方式。比如数字500000,我们通常会写成500,000,即每三位数字用逗号分隔。利用这种方式就可以很快知道数值的大小。这种做法的理念被加入到了Java 7中,不过用的不是逗号,而是下画线“_”。

在Java 7中,数值字面量,不管是整数还是浮点数,都允许在数字之间插入任意多个下画线。这些下画线不会对字面量的数值产生影响,其目的主要是方便阅读。

代码清单3-28 二进制示例2

import staticjava.lang.System.out;

public classBinaryIntegralLiteral {

    public void display(){

        out.println(0b001001);//输出9

        out.println(0B001110);//输出14

        out.println(1_500_500);//输出1500500

        out.println(5_6.3_4);//输出56.34

        out.println(89_3___1);//输出8931

}

 

public static voidmain(String[] args){

        BinaryIntegralLiteral b = newBinaryIntegralLiteral();

        b.display();

}

}

虽然下画线在数值字面量中的应用非常灵活,但有些情况是不允许出现的。最基本的原则是下画线只能出现在数字中间,也就是说前后都必须是数字。所以“_100”、“120_”、“ob_101”、“0x_da0”这样的使用方式都是非法的,无法通过编译。这样限制的动机在于降低实现的复杂度。有了这个限制之后,Java编译器只需要在扫描源代码的时候,将所发现的数字中间的下画线直接删除就可以了。这样就和没有使用下画线的形式是相同的。如果不添加这个限制,那么编译器就需要进行语法分析才能做出判断。比如“_100”可能是一个整数字面量100,也可能是一个变量名称。这就要求编译器的实现做出更加复杂的改动。

3.1.19 优化变长参数的方法调用

J2SE 5.0中引入的一个新特性就是允许在方法声明中使用可变长度的参数。一个方法的最后一个形式参数可以被指定为代表任意多个相同类型的参数。在调用的时候,这些参数是以数组的形式来传递的。在方法体中也可以按照数组的方式来引用这些参数。如下代码中可以对多个整数进行求和,可以用类似sum(1,2,3)这样的形式来调用此方法。

代码清单3-29 变长参数示例

public intsum(int…args){

int result = 0;

for(int value:args){

  result += value;

}

return result;

}

可变长度的参数在实际开发中可以简化方法的调用方式。但是在Java 7之前,如果可变长度的参数与泛型一起使用后会遇到一个麻烦,就是便一起产生的警告过多,比如下面代码。

代码清单3-30 Java 6变长参数示例

public staticT useVarargs(T…args){

    return args.length > 0?args[0]:null;

}

如果参数传递的是不可具体化(non-reifiable)的类型,如List这样的泛型类型,会产生警告信息。每一次调用该方法,都会产生警告信息。比如在Java 7之前的编译器上编译代码VarargsWarninguseVarargs(new ArrayList()),编译器会给出警告信息。如果希望禁止这个警告信息,需要使用@SuppressWarning(“unchecked”)注解来声明。这其中的原因是可变长度的方法参数的实际值是通过数组来传递的,而数组中存储的是不可具体化的泛型类对象,自身存在类型安全问题。因此编译器会给出相应的警告信息。这样的警告信息在使用Java标准类库中的java.util.Arrays类的asList和java.util.Collections类的addAll方法中也会遇到。建议开发人员每次使用方法时都抑制编译器的警告信息,这个不是一个好主意。

为了解决这个问题,Java 7引入了一个新的注解@SafeVarargs。如果开发人员确信某个使用了可变长度参数的方法,在与泛型类一起使用时不会出现类型安全问题,就可以用这个注解进行声明。在使用了这个注解之后,编译器遇到类似的问题,就不会再给出相关的警告信息。

@SafeVarargs注解只能用在参数长度可变的方法或构造方法上,且方法必须声明为static或final,否则会出现编译错误。一个方法是用@SafeVarargs注解的前提是,开发人员必须确保这个方法的实现中对泛型类型参数的处理不会引发类型安全问题。

3.1.20 针对基本数据类型的优化

Java7对基本类型的包装类做了一些更新,以更好地满足日常的开发需求。第一个更新是在基本类型的比较方面,Boolean、Byte、Short、Integer、Long和Character类都添加了一个比较两个基本类型值的静态compare方法,比如Long类的compare方法可以用来比较两个Long类型的值。这个compare方法只能简化进行基本类型数值比较时的代码。在Java7之前,如果需要对两个int数值x和y进行比较,一般的做法是使用代码“Integer.value(x).compareTo(Integer.value(y))”,而在Java7直接使用“Integer.compare(x,y)”。

字符串内部化(string interning)技术可以提高字符串比较时的性能,是一种典型的空间换时间的做法。在Java中包含相同字符的字符串字面量引用的是相同的内部对象。String类也提供了intern方法来返回与当前字符串内容相同的但已经包含在内部缓存中的对象引用。在对被内部缓存的字符串进行比较时,可以直接使用“==”操作符,而不需要用更加耗时的equals方法。

Java7把这种内部化机制扩大到了-128到127之间的数字。根据Java语言规范,对于-128到127范围内的short类型和int类型,以及\u0000到\u007f范围内的char类型,它们对应的包装类对象始终指向相同的对象,即通过“==”进行判断时的结果为true。为了满足这个要求,Byte、Short、Integer类的valueOf方法对于-128到127范围内的值,以及Character类的valueOf方法对于0到127范围内的值,都会返回内部缓存的对象。如果希望缓存更多的值,可以通过Java虚拟机启动参数“java.lang.Integer.Integer-Cache.high”来进行设置。例如,使用“-Djava.lang.Integer.IntegerCache.high=256”之后,数值缓存的范围就变成了-128到256。

3.1.21 空变量

显式地赋空变量是否有助于程序的性能。赋空变量是指简单地将null值显式地赋值给这个变量,相对于让该变量的引用失去其作用域。

代码清单 3-31 局部作用域

public static StringscopingExample(String string) {

  StringBuffer sb = new StringBuffer();

  sb.append("hello ").append(string);

  sb.append(", nice to see you!");

  return sb.toString();

}

如清单3-31所示,当该方法执行时,运行时栈保留了一个对StringBuffer对象的引用,这个对象是在程序的第一行产生的。在这个方法的整个执行期间,栈保存的这个对象引用将会防止该对象被当作垃圾。当这个方法执行完毕,变量sb也就失去了它的作用域,相应地运行时栈就会删除对该StringBuffer对象的引用。于是不再有对该StringBuffer对象的引用,现在它就可以被当作垃圾收集了。栈删除引用的操作就等于在该方法结束时将null值赋给变量sb。

既然Java虚拟机可以执行等价于赋空的操作,那么显式地赋空变量还有什么用呢?对于在正确的作用域中的变量来说,显式地赋空变量的确没用。但是让我们来看看另外一个版本的 scopingExample方法,如代码清单3-32所示,这一次我们将把变量sb放在一个错误的作用域中。

代码清单 3-32 静态作用域

static StringBuffer sb = new StringBuffer();

public static StringscopingExample(String string) {

  sb = new StringBuffer();

  sb.append("hello ").append(string);

  sb.append(", nice to see you!");

  return sb.toString();

}

现在sb是一个静态变量,所以只要它所在的类还装载在Java虚拟机中,它也将一直存在。该方法执行一次,一个新的StringBuffer将被创建并且被sb变量引用。在这种情况下,sb变量以前引用的StringBuffer对象将会死亡,成为垃圾收集的对象。也就是说,这个死亡的StringBuffer对象被程序保留的时间比它实际需要保留的时间长得多,如果再也没有对该scopingExample方法的调用,它将会永远保留下去。

即使如此,显式地赋空变量能够提高性能吗?我们会发现我们很难相信一个对象会或多或少对程序的性能产生很大影响,直到清单3-33所示,它包含了一个大型对象。

代码清单 3-33 仍在静态作用域中的对象

private static Object bigObject;

 

public static voidtest(int size) {

  long startTime = System.currentTimeMillis();

  long numObjects = 0;

  while (true) {

    //bigObject = null; //explicit nulling

    //SizableObject could simply be a largearray, e.g. byte[]

    //In the JavaGaming discussion it was aBufferedImage

    bigObject = new SizableObject(size);

    long endTime = System.currentTimeMillis();

    ++numObjects;

    // We print stats for every two seconds

    if (endTime - startTime >= 2000) {

      System.out.println("Objects createdper 2 seconds = " + numObjects);

      startTime = endTime;

      numObjects = 0;

    }

  }

}

这个例子有个简单的循环,创建一个大型对象并且将它赋给同一个变量,每隔两秒钟报告一次所创建的对象个数。现在的Java虚拟机采用generational垃圾收集机制,新的对象创建之后放在一个内存空间(取名 Eden)内,然后将那些在第一次垃圾收集以后仍然保留的对象转移到另外一个内存空间。在 Eden,即创建新对象时所在的新一代空间中,收集对象要比在“老一代”空间中快得多。但是如果 Eden 空间已经满了,没有空间可供分配,那么就必须把 Eden 中的对象转移到老一代空间中,腾出空间来给新创建的对象。如果没有显式地赋空变量,而且所创建的对象足够大,那么 Eden 就会填满,并且垃圾收集器就不能收集当前所引用的这个大型对象。所产生的后果是,这个大型对象被转移到“老一代空间”,并且要花更多的时间来收集它。

通过显式地赋空变量,Eden 就能在新对象创建之前获得自由空间,这样垃圾收集就会更快。实际上,在显式赋空的情况下,该循环在两秒钟内创建的对象个数是没有显式赋空时的5倍――但是仅当您选择创建的对象要足够大而可以填满 Eden 时才是如此, 在 Windows 环境、Java虚拟机 1.4 的默认配置下大概需要 500KB。那就是一行赋空操作产生的 5 倍的性能差距。但是请注意这个性能差别产生的原因是变量的作用域不正确,这正是赋空操作发挥作用的地方,并且是因为所创建的对象非常大。



[1]    即谭浩强教授,他编著的《C程序设计》发行了1100万册。

[2]    这段话摘自《Java编程思想》第四版第143页。

你可能感兴趣的:(Core,JAVA)