Java之美
更详细的请从这点击下载
一、引言
曾经有朋友跟我说,“八年架构,最后凭基础”,如此简单的一句话,却蕴含深意。架构设计本是一个高阶话题,对个人的专业性,业务的判断能力,未来发展趋势都有一定的要求。一个好的架构,不仅是完成功能和代码的复用(功能性),也不只是对未来业务的扩展提供便利(维护性),更重要的是他应该有强约束性,有牢固的框架,不会随意被破坏。前面一直在讲架构,回归主题,Java语言如何设计,使得我们能写出优秀的的架构来(下面很多涉及JVM实现,一下内容均已Hotspot为参考)。
二、基础
Java是面向对象语言,面向对象语言,讲得最多的是对象,我们就从类的加载过程开始讲起。
1、类的加载过程
Java类的加载过程是针对JVM来说的,也可以看做是JVM中类的注册过程。一般把Java类加载过程分如下几部来说:加载、验证、准备、解析、初始化。这个网上或者书上说得都比较多,我就直接引用了《类加载机制》这篇文章,图文并茂。当然也有喜欢看源码的,那就《JVM源码分析之Java类的加载过程》。
博客中一些补充,注意准备和初始化阶段:
- 1 . 准备,是接连加载class结构之后的,内存空间已经分配,但是静态属性还未首次初始化,所以,准备就是把这段空间整理清空。
- 2 . 初始化,更准确的讲是执行静态属性赋值和静态语句块,也是对应字节码方法执行。
看完博客文章,留给大家最重要的classloader概念,下面我增加下知识。在这之前,先理解java世界里,所有结构都是类(基础类型出现仅仅是为了优化执行性能而考虑的)。所以不论你看到的枚举、接口、注解等其他形式的语法结构体,在java中一律是类结构,也就是需要经过ClassLoader加载才可使用。
一个类被加载的时机简单来讲,就是类指针(java.lang.Class实例)被用到的时候,顾有如下场景:
- 1 . new一个对象时
- 2 . set或者get一个类的静态字段(getstatic/putstatic;除去那种被final修饰放入常量池的静态字段)
- 3 . 调用一个类的静态方法(invokestatic)
- 4 . 反射某个类实例时(非类实例方式,如:Class.forName("Person"))
- 5 . 虚拟机启动加载的主类(对应BootStrap Loader)
类最终被哪一个classloader加载,也不是随机的,而类加载采用双亲委托机制:
- 1 . 加载器“继承关系”:BootStrap=>ExtClassLoader=>AppClassLoader=>自定义的ClassLoader(此处继承,不完全指Java类对象继承关系,及extends关系指定,而是调用getParent()返回的链路)
- 2 . 加载器优先在自己的类缓存区查找类,若找不到,则按照加载器“继承关系”依次向上寻找。
- 3 . 若到BootStrap还没有查找到对应的类,则从自己字节码路径列表中开始寻找类字节码(每个加载器都有指定字节码寻找路径)来加载类,发现仍然没找到,则按照加载器“继承关系”依次向上寻找对应字节码加载类。
- 4 . 若最后到BootStrap仍然没有加载出类,则抛出异常ClassNotFoundException。
-
补充:
双亲委托机制:通俗的说,就是类加载来两遍,先找加载的类(先加载类是防止重复加载),找不到,再找加载的字节码,都是从下往下找,找不到就抛ClassNotFoundException。
上面这段有很多需要二外补充一些知识:
-
1 . 各个加载器路径设定
BootStrap加载主要是加载“%JAVAHOME%/jre/lib”或者-Xbootclasspath/a:或-Xbootclasspath/p:参数指定的路径以及“%JAVAHOME%/jre/classes”中的类。
ExtClassLoader加载“%JAVA_HOME%/jre/lib/ext”路径下所有类或者java.ext.dirs系统参数制定或者-Dext.dirs参数指定。
AppClassLoader主要-classpath参数指定的路径的所有类。
CustomClassLoader通常是自行添加addURL路径信息。
-
2 . 类字节码.class、.jar、.ear、.war等文件路径,可以重复的分配到类加载器中,所以选择加载器起点会显得非常重要,尤其对于有自定义加载器的框架,如Tomcat,大部分的业务类是用WebappClassLoader加载,而基础库类可能是AppClassLoader甚至是更高级别的类加载的,所以我们不要使用xxx.getClass().getClassLoader()方式来获取加载器,通常采用Thread.currentThread().getContextClassLoader()方式来获取会更加的安全。
对于加载器来实现资源加载,是同样的道理,选择合适的加载器开始,因为其他资源文件与字节码文件都是资源,查找顺序跟类加载一样,层层往上查找(Spring加载xml顺序变化,可能导致加载失败,为什么,大家可以思考下?)。 -
3 . 类加载过程是现场安全的(锁机制,JVM只有一个)。
-
4 . 类的唯一识别是 ClassLoader id + PackageName + ClassName,所以同一个字节码被两个classloader加载出来后,是两个类,完全不相等(Tomcat单进程实现多应用,就是用classloader来作隔离,比如有3个应用,每个应用都有一个静态变量a,a=1,a=2,a=3,如果不隔离,值很有可能就串了。)
2、对象创建过程
说明:java对象创建过程实际是分两部的:堆内存分配与准备(在c++中)和 实例对象初始化过程(在java世界jvm中)
之所以我不讲对象初始化过程是想大家区分我们常讲得类的实例初始化过程,我这里讲对象创建过程分两步,一个是堆内存分配与准备,另一个才是实例对象初始化过程。
Java是运行在虚拟机中的,虚拟机是c/c++编写的,所以最终Java对象内存实际还是由c语言到堆中申请的,当我们看到如下语句,实际发生了一些列事情:
Object obj = new Object();
第一阶段内存分配与准备(c++中)
-
第一步:分配内存
new指令(字节码仍然是new)被jvm识别,根据类类型(Object)到堆中malloc一段class_size连续内存。此过程也有多种实现,考虑内存使用率和gc难度,通常有两类做法:一类是指针碰撞,一类是空闲列表。具体做法我就不讲了,有兴趣的同学可以自己去查阅资料。 -
第二步:内存清零
非常简单的理解,malloc后通常的做法就是立马memset把内存清理掉,写过c的同学会比较熟悉。这也是为啥定义java对象对没有初始化的属性,默认值不是null就是0或者false。 -
第三步:类信息头设置
因为内存是要给Java对象使用,所以类的信息,哈希码,以及GC需要的一些设置,类的状态标记等等,都会在内存头中设置(64位为一般16个字节),内存头并不暴露给Java层面,只在JVM中识别。Java对象在内存中的存储布局分为3个区域:对象头、实例数据、对齐填充。
ps:站在操作系统看,也是为了JVM执行性能考虑,或者内存更合理的管理,Java对象并不都是创建在堆中,也有创建在栈中的(不需要靠GC来回收),栈中的内存通常我们叫堆外内存。TLAB(Thread-local allocation buffer)的出现,或者基于DirectByteBuffer的实现,都是JVM优化后的产物。
至此,Java对象内存才真正准备好,才进入第二个阶段,也就是我们常说的对象初始化过程。
第二阶段对象初始化(jvm中)
下面我们来看一段代码
class A {
public A() {
func();
}
public void func() {}
}
class B extends A {
public String name = "123";//-------①
public B() {
name = "456";//-----------------②
}
{
name = "789";//-----------------③
}
public void func() {
name = "000";//-----------------④
}
}
试着回答一下问题。
a、当new B实例时,A的构造函数是否被调用,为什么?会
super()一定会调,不写也要调(继承的时候默认是不用写super父类的构造函数的),如果写必须写在子类的第一行。写其他地方都报错。
b、当new B实例时,①②③④处的赋值语句是否都会执行,执行顺序如何?4132,先父类后子类。
总结下类的加载顺序,从当前类构造函数开始,层层往上调用,先初始化父类构造语句(执行顺序:属性,语句块,方法),再子类,最后层层出栈,完成整个初始化过程。具体过程可用javap查看。
ps:
1、构造函数调用可重载的方法,且子类重载,若子类使用初始化属性,如上语句,若name = "000";改成String str = name + "suffix";将会发生什么?NullPointerException?为什么?好好思考,为啥有如此漏洞的设计,Java语言不应该规避么?
答:因为func方法属于父类的方法,在执行的时候,子类的属性name还没有执行,所以报空指针错误。
2、是不是所有Java对象构造都需要走构造函数,什么场景不需要?为什么这么设计?
答:不是所有的java对象都走构造函数?不是,例如clone()函数不走构造函数。
3、Java源文件单一同名公开类
这个问题拿来说相比上面讲得类加载和初始化要简单多了,但是确是一个佐证语言设置规范性问题。网上搜搜可能你能找到很多答案:一个Java源文件中最多只能有一个public类,当有一个public类时,源文件名必须与之一致,否则无法编译,如果源文件中没有一个public类,则文件名与类中没有一致性要求。至于main()不是必须要放在public类中才能运行程序。
首先说明,并不是语言本身无法做到,而是加上这样的特性,反而让编译器实现变得复杂,工程组织更加的混乱。比如启动命令启动类指定,多个类之间的import,很多都需要jvm做额外映射。保留这样的特性也局部内部类需要,方便写一些小类族(实际你还是可以分开源文件写,而且更加清晰)或者方便单个源文件编译,所以不允许使用任何可见修饰符(但是默认就是protected的,但是不能显示的写出来),而这样的写法完全可以说是“偷懒”的需求(因为可以多源文件来实现),还会带来包名下类重复问题。
4、成员属性访问规则
属性访问,都是通过内存寻址来做到,所以一般术语叫做偏移量(采用get和set访问属性,即非偏移量方式,直接访问属性,通常就叫偏移量)。Java是面向对象支持继承多态的语言,成员属性可以也由此变得复杂。先让我们看看如下代码:
class A {
private int a = 100;
protected int b = 101;
public int c = 102;
public int d = 103;
//ge/set
}
class B extends A {
public int a = 200;
protected int b = 201;
public int c = 202;
}public class X { public static void main(String args[]) { A a = new B(); System.out.println("a.b = " + a.b);//---------① 101 System.out.println("a.c = " + a.c);//---------② 102 System.out.println("a.d = " + a.d);//---------③ 103 System.out.println("----------- " );////ge/set
System.out.println("a.b = " + a.getB());//---------① 201
System.out.println("a.c = " + a.getC());//---------② 202
System.out.println("a.d = " + a.getD());//---------③ 103
System.out.println("**************** " );//
B b = (B)a;
System.out.println("b.a = " + b.a);//---------④ 200
System.out.println("b.b = " + b.b);//---------⑤ 201
System.out.println("b.c = " + b.c);//---------⑥ 202
System.out.println("a.d = " + b.d);//---------⑦ 103
System.out.println("----------- " );//
System.out.println("b.a = " + b.getA());//---------④ 200
System.out.println("b.b = " + b.getB());//---------⑤ 201
System.out.println("b.c = " + b.getC());//---------⑥ 202
System.out.println("a.d = " + b.getD());//---------⑦ 103 }}
由此可见,偏移量是根据当前编译期变量类型决定,而不是根据实际运行时类型来决定,根据当前变量类型定义类开始,开始查找属性,若没找到,再向父类寻找,依次类推。知道这点后,大家以后就应该注意了,如果你偏要继承自某个数据型类,请确保不要重写可重载的属性,或者说大家尽量定义属性为private的,而是采用访问方法(get、set)来设置或者取值。
ps: A a=new B();a.x a.getX 是有区分的,a.x 返回的A的属性值,a.getX 是B的属性值,get set就是解决这个偏移量的问题。
5、内部类
内部类有四种形态:成员内部类、局部内部类、静态内部类、匿名内部类。尽管分类只有这四种,但是里面的知识非常的多,局部内部类我们不再讨论,这种方式对于大型工程并不是一种明智做法。
来看下下面的代码,然后试着回答后面的问题。
public class X {
public static class A {
private int a = 100;
public void func() {
System.out.println("this is an inner class A");
}
public void print() {
func();
}
}
public class B {
private int a = 101;
public void func() {
System.out.println("this is an inner class B");
}
public void print() {
func();
}
}
public void func() {
System.out.println("this is the class X");
}
public void test1() {
A a = new A() {};
a.print();
}
public static void test2() {
A a = new A() {};
a.print();
}
public void test3() {
B b = new B() {};
b.print();
}
public static void main(String args[]) {
X x = new X();
x.test1();
x.test2();
x.test3();
System.out.println("X.A.a="+new X.A().a);
System.out.println("X.B.a="+x.new B().a);
}
}
- 1 . 代码中定义了几个内部类? 5个内部类
- 2 . 内部类A和B的区别是什么?
- 3 . 内部类test1中和test2中区别是什么?
- 4 . 内部类B.print中调用func();将执行哪一句语句?
Java采用内部类形式实现友元类(C++概念),在注重封装性的同时,又为相关的行为类提供访问私有域的可能。这句话可能比较难理解,下面看下内部类合理的使用场景,从中可以总结如上结论。
静态内部类则为类实例工厂控制机制提供封装技术。常用场景有单例(内部类SingletonHolder模式)、Builder、Factory等。
非静态内部类,采用组合而非继承关系,实现类行为扩展。常用的比如线程回调、网络回调、事件回调等等。
内部类可以看做是静态内部类定制场景。看看如下代码就明白了。
public class X {
public static class A {
private int a = 100;
private final X this$0;
public A(X x) {//构造注入依赖
this$0 = x;
}
public void func() {
System.out.println("this is an inner class A");
}
public void print() {
func();
this$0.func();
}
}
public void func() {
System.out.println("this is the class X");
}
public static void main(String args[]) {
X x = new X();
System.out.println("X.A.a="+new X.A(x).a);
}
}
可以说Java设计师詹姆斯·高斯林考虑是非常精细的,如果每次要采用如上方式定义内部类,太麻烦,书写也是非常不方便的,这样可以想象接口、抽象类、或者其他可派生类要实现回调会是多么痛苦。
当然内部类在封装性和代码组织结构上,也是有一定的严谨性的,所以可以使用于更多关联类紧密的设计场景中。
ps:非静态内部类为什么不能定义静态方法?那为什么可以定义static final的属性?
6、接口和抽象类
接口和抽象类对于初学者是两个截然不同的东西,因为语法上的区别就比较大,但是写过一定代码量的工程师发现这两个东西会有混用场景。下面内容我就先不写了,直接应用网上博客《接口与抽象类》
博客中设计层次的分析,我补充下,抽象类使用场景更合适在架构层面,因为他更佳具备约束性,能够定义非公开的虚方法,能实现规则约束(实现部分方法行为和属性定义),这些的场景,接口无法实现。尽管接口所有的方法也是虚方法(字节码中可以提现),但是他必须是公开的,是属于边界约束。
三、深入
1、基础类型的设计
基础类型的设计在Java中并不是必须的,没有他们,你可以完成任何场景代码和设计,而有了他们,确是极大的性能(内存和执行命令)优化的效果。通过前面的学习,基本可以总结如下几点:
- 去除堆内存申请步骤,减少堆内存到栈拷贝指令,提高执行效率
- 减轻GC的压力
即便使用装箱类型(于基本类型对应的对象类型),对于常用的如Integer,也有一些优化动作,来看看如下代码:
{
Integer a = 100;
Integer b = 100;
System.out.println("100 == 100 结果是:" + (a == b));
}
{
Integer a = 300;
Integer b = 300;
System.out.println("300 == 300 结果是:" + (a == b));
}
因为这些小值数据会在正系统中平凡使用,如果将这些数据定义成常亮比较省事,而且减少了创建和释放的管理成本。被优化的有Integer、Short、Byte、Character、Long类型,具体请看源码。
当然,由于基础类和包装类两套方案,每次都要调用方法来装箱和拆箱,会让代码非常的复杂,所以编译器做了一系列的隐式转换,但是我们要注意拆包隐式转换的异常:
public class X {
public void func(int i) {
System.out.println("The value is " + i);
}
public static void main(String args[]) {
X x = new X();
Integer i = null;
x.func(i);//1、函数参数隐式拆包
List<Integer> list = new ArrayList<Integer>();
list.add(i);
int a = list.get(0);//2、容器取值隠式拆包
int b = i;//3、赋值隐式拆包
}
}
以上场景,均会出现NullPointerException。
2、final的使用
Java中final这个关键词似乎很多地方都可以看到,但是意义却都不一样,那我们就从一些简单的来说:
- a、加载类定义前面,表示类不可派生。这个比较好理解,不多说了
- b、加载方法前面,表示方法不可重载。这个也比较好理解,不用多说。
-
c、加载成员属性前面,表示属性在构造期间只允许被赋值一次。这里强调两点,第一点,必须在构造期间,第二必须赋值且仅仅赋值一次。前面学习过对象初始化过程,所以对于定义赋值,构造函数,甚至是语句块中,只要给予一次赋值即可。
public class TA { public final String name = "1";//----① { name = "2";//--------------------② } public TA() { name = "3";//--------------------③ } }
如上代码,只要在①②③处任何一处赋值即可,切记,不能同时存在,否则报错“无法为最终变量name分配值”。这种设计将运用在不可变更的数据对象上,一旦生产,则不可修改(自己回忆,哪些场景需要这样的设计)。
-
d、加在静态属性前面,变成常量或者仅可修改一次的静态变量。这里必须说明清楚,不同的写法将表达不一样的效果:
public class TA { public static final String STR_TEST_1 = "此时是常量,将被分配在常量区,与类无关"; public static final String STR_TEST_2; static { STR_TEST_2 = "此时是类的静态变量,但是仅在类加载时可被赋值一次"; } }
所有的解释都在代码中(接口中为什么能定义static final常量?能否定义STRTEST2的方式?)。
-
e、加载String前面在一定程度优化成常量替换代码。如下代码,在dalvik中优化力度更大。
public class X { static String getHello() { return "hello"; } public static void main(String args[]) { { String a = "hello2"; final String b = "hello";//将转换为常量 String d = "hello"; String c = b + 2; String e = d + 2; System.out.println((a == c)); System.out.println((a == e)); } { String a = "hello2"; final String b = getHello();//无法转换为常量 String d = "hello"; String c = b + 2; String e = d + 2; System.out.println((a == c)); System.out.println((a == e)); } } }
ps:补充下额外知识,接口中定义常量可以简写如下,不要以为你定义了一个成员属性,那你就大错特错了。
public interface TI {
public String STR_TEST_1 = "此时是常量,将被分配在常量区,与类无关";
}
ps:Java命令行常用:javac编译、javap看字节码、java执行程序。更多的细节自己去学习,此类知识仍然属于Java基础知识的范畴,希望每个使用Java的同学,重视Java本身的设计,合理使用Java语法来完成架构的设计,保证其完整性、扩展性和约束性。