Java类型------以类型为切面探索
引言
本文从类型和相关语言特性角度出发,以文字总结了Java推出的大部分面向开发者的与类型相关的特性。并浅显的从实现角度解释了类的加载过程。 除文章引用外,其他内容属个人见解,作者水平有限,如有错误请多指正,非常感谢。 ------Feellmoose
基础类型
在开始阅读之前我希望大家重新思考,类型是什么?注意,现在我们不从class
的角度分析,而是采用更加底层的视角。正如我们给其他工具分类一样,给数据分类是为了更好的存储和使用数据。我们把它看作一种计算机存储空间的分配和利用方式,大部分语言利用计算机存储空间的方式是非常相似的。
按数据大小分类
在C
,C++
,Java
等语言中,每种类型所占的空间大小是固定的(如int32
类型占用4byte
等),这是一种非常规整的存储方式,按照最小单位字节进行存储并且分配,同一类型占用的空间都是一致的,虽然数据类型占内存的位数实际上与操作系统的位数和编译器(不同编译器支持的位数可能有所不同)都有关,但是我们为了编程的规范性和程序的通用性,一般每种语言每种类型所使用的空间大小是固定的。这样我们就可以很方便的对内存划分区域,并将每块区域用一地址标识,在需要时通过地址来获取对对应的数据。
在Java
中int
由于使用了统一的JDK
平台避免了不同的操作系统和编译器带来的问题,所以int
在Java
中统一为4byte
。
一个形象的比喻:每块数据都是一个柜子,为了取用方便我们希望将数据存储在柜子里,并由地址来确定数据实际存储的位置。这就是数据类型和地址所起到的作用。
按数据功能分类
除整型外,还存在无符号类型字符类型和浮点数等类型,在Java
等语言中还存在布尔类型,这使得按功能划分的类型系统更加完备,但与之对应的是Java为了维持基本类型系统的简单性抛弃了无符号类型。
同时int
和flout
同为4byte
,但由于使用的存储方式不同,代表着flout
实际上可以存储值的范围更大,但相应的在计算时会丢失精度并且计算复杂度更高。
更加方便地操作数据
但是只是用数据来进行更加复杂的编程是远远不够的,程序员迫切需要一种支持大量数据的和一种支持更加复杂的数据的数据组织方式,这就是数组array
和结构体struct
存在的原因。
有了上述两种组织数据组织的方式,程序员可以快速地批量创建和管理大量具有复杂结构的数据。
Java抛弃了使用结构体来组织数据,因为我们拥有更加强大的工具------类。但是我们依旧可以见到数组的身影,但是与C语言有所不同的是操作数组的方式有所不同,Java中数组本质上也是对象。在Java日常使用中对集合和数组的选择是无法避免的,两者各有优势,大部分框架也对两者分别做了支持,但是在大部分对性能没有极致要求的情况下,拥有更加强大性质和功能的集合似乎更胜一筹。
总结
- 程序中需要处理许多数据,对于不同数据都有其对应的数据类型,其实就是在内存中开辟一个存储空间来存放数据,不同数据所开辟的内存大小也会不一样。
- 在日常使用中
Java
基本类型共有八种,基本类型可以分为三类:字符类型,布尔类型以及数值类型。 Java
中的数值类型不存在无符号的,它们的取值范围是固定的,不会随着机器硬件环境或者操作系统的改变而改变。
引用类型
引用类型就是类class
,类是Java
程序加载和运行的基本单元,运行所有的Java
程序都离不开类。谈到类,就必须谈到面向对象编程。
面向对象编程(OOP)
早在1975年,《人月神话》就提到面向对象编程是最有可能成为软件设计的银弹的概念之一。如今,面向对象设计和编程发展得如日中天,我们暂且不讨论是否有新的抽象概念产生代替面向对象设计,但面向对象设计让我们看到:这种合适的抽象对于软件设计的影响和提升是如此深刻而广泛的。
我们可以简单把面向对象编程理解为:层次化类型,强制的模块化和隐藏模块内部进行编程,但是面向对象编程和面向对象设计的潜力远远不止于此。
软件开发的根本任务是打造构成抽象软件实体的复杂概念,次要任务是用编程语言表达这些抽象实体,在空间与时间限制内将其映射为机器语言 ------《人月神话》
面向对象是一种用抽象能力解决复杂问题的方法。
简短回顾一下OOP
出现的历史:
- 计算机只可以解释用二进制数编写的机器语言,为了让计算机执行预期的工作,最终必须有使用机器语言编写的命令群
- 为了改善这种低效的编程,汇编语言就应运而生了,汇编语言将无含义的机器语言用人类容易理解的符号表示出来
- 随后,用更贴近人类的表达形式来编写程序的高级语言被发明出来,从面向计算机转变成面向开发者
- 软件需求越来越复杂,提出了结构化编程,提倡只使用循序、选择和重复这三种结构来表达逻辑,同时废弃
Goto
; - 为了方便维护程序,需要提高子程序的独立性,减少全局变量,一种是使用局部变量,一种是按值传递;出现了以结构化为基础的结构化编程语言,如
C
- 结构化语言未解决的问题是:全局变量与可重复性差;
OOP
打破了这个限制
面向对象包含三个基础概念:
- 封装,类
Class
是面向对象的最基本的结构,与其对应的概念是实例Instance
(隐藏和汇总) - 继承,系统地整理物的种类的共同点和不同点的结构
- 多态,让向相似的类发送消息的方法通用的结构(统一调用端逻辑)
OOP中的数据类型不仅有着状态(动态数据:属性),还有着与状态相关的行为(让向相似的类发送消息的方法通用的结构:方法)
使用OOP复用技术
OOP
为我们提供代码方面良好的的重用性和设计上重用性
- 软件本身的重用,即准备通用性高的软件构件群进行重用,诸如类库、框架和组件等都属于这种技术
- 思想或技术窍门的重用,即对软件开发或维护时频繁出现的固定手法进行命名,形成模式,以供更多人重用,如设计模式
OO的封装隐藏思路是唯一提升软件设计水平的途径
面向对象在整个开发周期中都需要运用,投入很大,但是收益在后续维护中才会体现,所以很多人不喜欢,但是这是非常有用的 ------《人月神话》
面向对象设计更加容易实现易于维护和重用的软件结构:
- 去除重复(灵活的代码复用机制)
- 提高构件的独立性(高内聚低耦合)
- 避免依赖关系发生循环(避免强耦合和出现先有蛋还是现有鸡的问题)
面向对象的一般实现
每个时代都有不同的编程语言占主流,在汇编为主流的时代,最基本的结构为硬件寄存器结构;在C语言时代则是指针与地址结构;而在编程语言进一步进化的今天,最基本的知识则是理解内存的使用方法。
多个线程,线程是程序运行的基本单位,一个程序一般对应一个进程,一个进程通常可以由个线程组成。能够同时运行多个线程的环境称为多线程环境,通过并发处理多个线程,可以高效利用 CPU
资源。
内存模型:栈区,静态区,堆区。对于面向对象的语言而言,类信息位于静态区,主要保存类的基本信息,以及类的方法,同时内部使用一个虚函数表维持对于每个方法的引用。在程序运行时,动态实例化的对象位于堆区,每个对象拥有自己独立的变量内存,但使用静态区公共的类方法。而实际的变量位于栈区,通过指针(引用)维持对于堆区对象的引用。
进一步封装数据和函数
类相较于普通数据结构和函数的优点:
- 严格的访问限制,类型内部对外不可见,只暴露必要方法和数据(封装)
- 可复用的复杂数据结构
- 可以更加方便的将数据状态(属性)与函数(方法)绑定,提供根据不同状态产生不同行为的能力
- 可重复利用和设计的数据结构和方法(继承与多态)
六大设计原则
六大设计原则是面向对象设计的一种思想,它可以指导软件设计的过程,使得软件能够更好地满足一定的可维护性,可扩展性,可复用性,可读性,稳定性和灵活性。
六大设计原则分别是:
- 单一职责原则:一个类或模块只负责一个功能或职责,避免过多的耦合和变化(类和方法,接口)
- 开闭原则:一个类或模块对扩展开放,对修改关闭,即在不修改原有代码的情况下,可以通过继承或实现接口来增加新的功能(对扩展开放,对修改关闭)
- 里氏替换原则:一个子类或实现类可以替换其父类或接口,且不影响原有的程序逻辑和行为,即子类或实现类要遵守父类或接口的约定和规范(基类和子类之间的关系)
- 依赖倒置原则:高层模块不应该依赖低层模块,两者都应该依赖于抽象,即要面向接口或抽象类编程,而不要面向具体的实现类编程(依赖抽象接口,而不是具体对象)
- 接口隔离原则:一个接口应该尽可能小,只包含相关的方法,避免出现臃肿的接口,也避免让实现类实现不需要的方法(接口按照功能细分)
- 迪米特原则(最少知道原则):一个类或模块应该尽可能少地与其他类或模块发生相互作用,只与直接的朋友类通信,减少不必要的依赖和耦合(类与类之间的亲疏关系)
此外,请不要滥用继承。在使用继承前注意子类和父类必须严格符合的继承关系,否则使用合成或者聚合的方式进行复用,而非继承。
设计模式
设计模式是前人为了创建便于功能扩展和重用的软件而研究出的技术窍门集。
具体来说,就是不依赖于编程语言和应用程序的应用领域,对在各种情况下反复出现的类结构进行命名,形成模式。每个模式描述了一个在我们周围不断重复发生的问题,以及该问题的核心解决方案。
使用设计模式是为了可重用代码,让代码更容易理解,并保证代码可靠性。
简单来说,设计模式分为三大类:创建型模式(5种),结构型模式(7种),行为型模式(11种)。
总结
Java
是面向对象编程的语言,在Java
的类型体系中所有的类class
又被称作引用类型,因为所有类型和其对象通过引用从内存中获取。- 面向对象编程需要实现三个概念分别是:封装继承和多态。
- 面向对象设计是非常有用的软件设计方法,能够轻松地进行较难的软件开发的综合技术,提供了更低的程序设计门槛,在后期更加易于维护。
虚拟机实现
内存模型
刚才我们了解了面向对象语言的内存模型可以大致分为:栈区,静态区,堆区。
Java
也是基于这三大区域进行实现的,Java 8
后其内存区域可以进一步划分为:虚拟机栈,元数据空间,本地方法栈,堆。
- 堆
Heap
:用于存储对象实例和数组等动态创建的数据。堆内存由JVM
自动分配和回收,是Java
程序最主要的内存区域。 - 栈
Stack
:用于存储方法调用时的局部变量、方法参数和返回值等数据。栈内存由JVM
自动分配和回收,每个线程都有自己的独立栈空间。 - 元数据空间
Metaspace
:用于存储类信息、常量池、静态变量和编译后的代码等数据。 - 本地方法栈
Native Method Stack
:用于执行本地方法的栈空间。
元数据空间
元空间包括:类的元数据,字节码和运行时常量池等,元空间在类加载时被分配,在类卸载时被释放;
- 类的字节码:
class
文件 - 类的元数据:根据字节码文件加载得到的类的元数据
Klass
对象等,类的元数据描述了类的结构,包括它的方法、字段、父类等。注意Klass
对象是C++
对象,存储在方法区中,提供类的元数据;而class
对象通过klass
对象加载,存储在堆中,直接提供反射的相关操作。常量池,即类文件中的字面量和符号引用等内容,也属于类的元数据。 - 运行时常量池:在类加载到内存(包括类和方法区)后,
JVM
为它们分配的一个动态结构,通过缓存的引用句柄来获取常量池的数据,这里的缓存的目的不是为了加快速度而是为了实现符号引用的懒加载。
堆与实例
所有新建的实例会经历一下几个过程:在堆上分配内存,初始化零值,设置对象头,并通过<init>
方法初始化。
在HotSpot
虚拟机中,实例在内存中存储的布局可以分为对象头Header
、实例数据Instance Data
和对齐填充Padding
。
- 对象头中负责存储
markword
,klass
类型指针,数组长度,其中klass
类型指针指向类的元数据,数组长度只有数组对象会存储。 - 实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录起来。
- 第三部分对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。对象的大小必须是8字节的整数倍,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
虚拟机栈
上述我们讲述的元数据空间和堆是线程公有的内存空间,而虚拟机栈是线程私有的,栈空间存储的数据只能由当前线程访问,所以它是线程安全的。
栈与栈帧
Java
虚拟机栈的生命周期与线程相同(随线程而生,随线程而灭)Java
虚拟机栈描述的是Java
方法执行的内存模型,每个方法执行的同时会创建一个栈帧。栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。- 在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。
在编译程序代码的时候,栈帧中需要多大的局部变量表内存,多深的操作数栈都已经完全确定了。因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。
局部变量表
- 局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。并且在
Java
编译为Class
文件时,就已经确定了该方法所需要分配的局部变量表的最大容量。 - 局部变量表存放了编译期可知的各种基本数据类型,对象引用和
returnAddress
(指向了一条字节码指令的地址) - 局部变量表的容量以变量槽为最小单位,每个变量槽都可以存储32位长度的内存空间;对于64位长度的数据类型,虚拟机会以高位对齐方式为其分配两个连续的
Slot
空间
方法出口,当一个方法开始执行后,只有2种方式可以退出这个方法 :
- 方法返回指令:执行引擎遇到一个方法返回的字节码指令,这时候有可能会有返回值传递给上层的方法调用者,这种退出方式称为正常完成出口。
- 异常退出:在方法执行过程中遇到了异常,并且没有处理这个异常,就会导致方法退出。
无论采用任何退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息。一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。
本地方法栈
本地方法栈与虚拟机栈非常相似,其区别是虚拟机栈为虚拟机执行Java
字节码,而本地方法栈则是为虚拟机使用到的Native
方法。
Java
虚拟机规范中对本地方法栈中方法使用的语言、使用方法与数据结构并没有任何强制规定,因此具体的虚拟机可以根据需要自行实现它,HotSpot
虚拟机直接就把本地方法栈和虚拟机栈合二为一。
编译
为了实现跨平台Java
源码通过编译成字节码,然后通过不同平台的虚拟机解释执行,从而实现 一次编译,到处运行的跨平台的效果。
Java
语言的编译期分为前端编译和后端编译两个阶段:
前端编译,是指把.java
文件转变成.class
文件的过程,包括词法分析、语法分析、语义分析与中间代码生成,主要有下面几个步骤:
后端编译,在部分商用虚拟机中,Java
程序最初是通过解释器进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为热点代码。为了提高热点代码的执行效率,在运行时, 虚拟机将会把这些代码编译成与本地平台相关的机器码。完成这个任务的后端编译器称为即时编译器(JIT
编译器)。
类加载机制
一个类从被加载进内存,到卸载出内存,完整的生命周期包括:加载,验证,准备,解析,初始化,使用,卸载。
这七个阶段按序开始,但不意味着一个阶段结束另一个阶段才能开始。也就是说,不同的阶段往往是穿插着进行的,加载阶段中可能会激活验证的开始,而验证阶段又有可能激活准备阶段的赋值操作等,但整体的开始顺序是不会变的。
类加载器
Java中默认的类加载器有三种分别是:BootStrap ClassLoader
,Extensions ClassLoader
,Application ClassLoader
BootStrap ClassLoader
加载器执行优先级最高,使用C
语言编写,用于加载JRE
核心类库下的jar
包。Extensions ClassLoader
使用Java
实现,负责加载JRE
扩展目录下的jar
包。Application ClassLoader
加载项目中引用的其他jar
包,和用户自己编写的类文件。Custom ClassLoader
自定义加载器,可以加载指定目录下的类。
调用加载器的一般顺序:双亲委派模型,或者叫父委派模型。即加载任务向父加载器传递,由顶级加载器先开始加载,若无法加载则不加载,下层加载器接下来会根据全限定名判断类型是否加载过,若没有加载过会尝试加载,每个类型最终只会被加载一次,若最终没有合适的加载器负责加载则会抛出异常。
破坏双亲委派:
- 自定义类加载器,并重写
loaderClass()
方法:这样可以自定义加载多个相同全限定名的类,一般用来应对同一个虚拟机上多个容器内相同类库版本类库不同的情况。 - 向下委派子加载器:在
Java
中通过SPI
机制可以实现向下委派子加载器,如在加载JDBC
类库中的Driver
驱动时,由于存在各个数据库对Driver
的不同实现,就需要手动设置类加载器。这时BootStrap ClassLoader
不加载类,而一般向下委派由Application ClassLoader
完成类型加载。
加载
- 通过一个类的全限定名获取对应于该类的二进制字节流
- 将这个二进制字节流转储为方法区的运行时数据结构
- 于内存中生成一个
class
对象,用于表示该类的类型信息(解析和反射)
验证
- 验证阶段的目的是为了确保加载的
.class
文件中的字节流是符合虚拟机运行要求的,不能威胁到虚拟机自身安全 - 验证阶段保证了虚拟机的安全性,整个验证又分为四个阶段:文件格式验证、元数据验证、字节码验证,符号引用验证
准备
- 准备阶段实际上是为
static
变量赋系统初值即基本数据类型的零值,对常量static final
值初始化的过程。
解析
- 解析字段,接口和方法,将符号引用转换成直接引用,直接引用会直接入驻常量池,而符号引用则需要通过解析阶段来实际指向运行时常量池中的直接引用的地址。
初始化
- 初始化时,虚拟机会调用编译器为类生成的
<clinit>
方法执行对类变量的初始化语句。<clinit>
方法是在编译时,编译器会将代码中所有的静态代码块和静态赋值语句按顺序合并生成的。
实例化
Java
对象在被创建时,会进行实例化操作。该部分操作封装在<init>
方法中,并且子类的<init>
方法中会首先对父类<init>
方法的调用。 Java
对象实例化过程中对实例域的初始化赋值操作全部在<init>
方法中进行,<init>
方法显式的调用父类的<init>
方法, 实例域的声明以及实例初始化语句块同样的位置关系会影响编译器生成的<init>
方法的字节码顺序,<init>
方法以构造方法作为结束。
<init>
操作时是线程安全的,所以一般推荐由构造函数进行初始化。
类卸载
由JVM
自带的三种类加载加载的类在虚拟机的整个生命周期中是不会被卸载的,结束程序运行,则类的生命周期结束。由用户自定义的类加载器所加载的类才可以被卸载。他们何时结束生命周期,取决于代表它的klass
对象何时结束生命周期被回收。
垃圾回收
在Java
中内存空间由JVM
进行管理,程序员无需手动释放对象,一切都交由GC管理,但是依旧可能出现内存溢出的情况,为了避免这些情况的发生,除了Java
对于大部分情况的优化之外,我们还需要理解GC
的原理。
GC
可以针对部分元数据和堆区进行,对于Java
程序员来说内存空间全部由JVM
维护管理,为了合理的利用内存,GC
需要做3件事:
- 分配内存,为每个新建的对象分配空间
- 确保还在使用的对象的内存不被回收
- 释放不再使用的对象所占用的空间
分配内存
- 指针碰撞,默认采用的是指针碰撞的方式。如果堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离。
- 空闲列表,如果堆中的内存不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。
定义垃圾
为了完成快速而又灵活的GC
策略,Hotspot
不断引入新的GC
,最经典的GC
规则有以下三种:
- 引用计数算法,是通过在对象头中分配一个空间来保存该对象被引用的次数。如果该对象被其它对象引用,则它的引用计数加1,如果删除对该对象的引用,那么它的引用计数就减1,当该对象的引用计数为0时,那么该对象就会被回收。但是这样会有问题,就是当两或以上个对象互相引用时,该对象就不会被回收。
- 可达性分析算法,基本思路是通过一些被称为
GC Roots
的对象作为起点,从这些节点开始向下搜索,搜索走过的路径被称为引用链,当一个对象到GC Roots
没有任何引用链相连时,则证明该对象是不可用的。这样就可以解决相互依赖的问题。
一般使用以下四种对象作为 GC Roots
:
- 虚拟机栈变量表中引用的对象
- 类静态属性引用的对象
- 常量引用的对象
- 本地方法栈中
JNI
引用的对象
回收算法
为了简单高效节能的回收对象,Java
诞生了以下几种经典的思想:
- 标记-清除法:先把内存区域中的垃圾进行标记,然后一起清理掉。优点:快捷高效,清理不需要额外空间,缺点:清理后的空间不连续
- 标记-复制算法:先把内存区域中的垃圾进行标记,将存活的对象全部复制到新空间,将旧空间对象全部清理掉。优点:清理后的空间连续,缺点:清理前需要较大剩余的空间,需要
STW
- 标记-整理算法:先把内存区域中的垃圾进行标记,将垃圾回收,将存活的对象全部整理到连续的空间。优点:清理后的空间连续,清理不需要额外空间,缺点:需要
STW
- 分代收集算法:对象存活周期的不同将内存划分为几块。一般是把
Java
堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用标记-清理或者标记 -整理算法来进行回收
细致分代:
Eden
区,新生对象首先会在Eden
区分配,当Eden
区没有足够空间进行分配时,虚拟机会发起一次Minor GC
,Minor GC
相比Major GC
更频繁,回收速度也更快。Minor GC
后,Eden
区被清空,其中绝大部分对象会被回收,存活对象将会进到Survivor form
区,当Survivor from
区满,直接进入Old
区Survicour
区,是Eden
区和Old
区的过渡区,也会被Minor GC
回收,from
区存活对象进入to
区,然后from
和to
区职责互换,这时一个标记-复制的过程,满则进入Old
区,如此反复,只有经历16次Minor GC
还能在新生代中存活的对象,才会被送到老年代。Survivor
的存在意义就是减少被送到老年代的对象,进而减少Major GC
的发生。设置两个Survivor
区最大的好处就是使用标记-复制解决内存碎片化Old
区占据着2/3的堆内存空间,只有在Major GC
的时候才会进行清理,每次GC
都会触发Stop-The-World
。内存越大,STW
的时间也越长,所以内存也不仅仅是越大就越好。由于复制算法在对象存活率较高的老年代会进行很多次的复制操作,效率很低,所以老年代这里采用的是标记-整理算法
各代实际比例见下图(S0
,S1
分别为from
或to
)
总结
在Java
中内存空间由JVM
进行管理,程序员无需手动释放对象,一切都交由GC管理
- 分配内存:指针碰撞,空闲列表
- 定义垃圾:引用计数算法,可达性分析算法
- 回收算法:标记-清除法,标记-复制算法,标记-整理算法,分代收集算法
更加完整的系统
上述我们已经了解到了面向对象的三大特征,但是随着深入探索,我们发现普通类的堆积反而造成大量不规范代码的产生,问题如下:
- 我们需要更加严格的封装来解决编码不规范问题和访问限制不充分带来的安全问题
- 我们需要更加完善的对不同类型的代码复用
- 我们需要更加自由的特性或者抽象,来解决类型繁多冗杂带来的编码困难问题,进一步优化缩短代码
- 对现有语法的拓展
而Java
等语言做出的选择是建立更加完整的类型系统,其包括:接口,内部类,泛型,匿名方法等
接口
接口提供了一种抽象类型行为和特征的手段,可以更加严格的对类的行为进行封装,但同时允许多继承。严格意义上接口中只有方法的特征而没有方法的实现,这些方法必须被实现其的类所实现,而这些实现可以具有不同的行为。
接口是解决Java
无法使用多继承的一种手段,是一种更加特殊和抽象的类。通过限制只允许行为的多继承和禁止实例化,Java
避免了多类型继承的混乱,但是接口在实际中更多的作用是制定和实现标准。
接口带来的优点:
- 更加严格的封装和其带来的更加严格的使用和编码规范,使得程序具有更高的灵活性和解耦性
- 接口多继承带来的优点:允许不同类型相同行为的复用,允许以多种行为共同描述类型
- 更加易于设计和享受其带来的诸多优点
内部类
内部类Inner Class
就是直接定义在类里的类,内部类可以直接访问外部类的属性和方法,在处理某种包含关系、定义枚举值或者在进行某些特殊设计的时候非常有用。
- 成员内部类:在定义内部类时,若直接嵌套内部类,则内部类为成员内部类。在创建内部类的实例时,每个实例都将存储外部实例的引用,成员内部类的实例必须通过外部类的实例创建。
- 静态内部类:若添加
static
关键字则为静态内部类,则代表内部类非外部实例所有,可以直接通过外部类创建静态内部类的实例。 - 局部内部类:在方法内定义的类,只在作用域内生效,与局部变量类似。
- 匿名内部类:匿名类,匿名类
Anonymous Class
是一种未命名的特殊类,虽然它是唯一一种没有构造器的类,却可以更加灵活的满足某些的需求,当我们需要灵活的实现一个特殊的行为时,只需要在创建实例时重写某些方法。
其中,成员内部类,静态内部类,匿名类的实例都将存储外部实例的引用。
泛型
纵观编程语言的发展历史,方法的继承和多态进一步完成了对程序过程的复用。那么我们是否也可以将数据类型进行重复利用,即只写一遍代码,而兼容所有类型呢?
Java
的在早期版本给出的答案是:对于通用的操作,可以将子类全部转为父类,然后对父类进行对应的操作即可。这样当然是一种解决思路,但是却带来了类型转换的开销和对后续带来的难以避免的受检异常和类型转换异常。
泛型又称作参数化类型,和我们在使用方法时类似,只要我们在使用时声明类型参数,就可以用传入的类型替代类型参数,从而获得完整的类或者方法。这样我们只写一遍代码,就而直接兼容了现有的甚至之后可能出现的所有类型,这无疑就是我们苦苦追求的对不同类型的复用。
1.4
版本后Java
推出泛型,1.5
版本Java
推出钻石符泛型,为了兼容之前的代码(主要是兼容已经在数以万记的机器上运行的字节码文件),其底层依旧是按类型转换进行的,但是新的编译器可以通过泛型在编译阶段检查程序是否存在错误。
在广义上讲,所谓泛型就是在类定义时不明确类型,在使用时明确类型,这样就可以只写一遍代码,而兼容所有类型。为了优化运行时性能其他语言大多使用单态化等技术手段,即通过编译器对每个使用到的类型都编译出一份代码,Java
选择了运行时类型转换+编译期检查牺牲了性能从而换取了更高的灵活性和稳定性。
相较于其他后起之秀,Java
保留了基本类型但无法支持基本类型泛型的做法广受诟病,这意味着Java
泛型之可以只用在类上,如果想让方法或者类型进一步继续基本类型,就只能手动单态化。不过一般在使用时推荐使用包装类,即避免了多写一堆方法的麻烦,又增加了null
的状态,Java
的List
等官方工具就是实际使用中的典范。
泛型产生带来的核心思想的转变:在没有泛型前,接口是方法集。在有泛型后,接口是类型集,这也和我们之前的讨论:接口其实是一种特殊的类不谋而合。
匿名方法
Lambda
表达式的历史远远早于面向对象的出现,但是其对于代码可读性和编程效率的提升逐渐在实践中被认可。
Java
在早期作为一款面向对象的产品,并没有将函数视作头等公民,在1.8
版本中(2014年)Java
终于引入了Lambda
和Stream API
等更加强大的特性,将代码的质量进一步提升。同时模式匹配等各种新特性的出现,让Java
这颗"老树"重新焕发出一丝活力。
我们刚刚讨论过匿名类,与之类似的,匿名函数也是一种未命名的特殊函数,在Java
中需要提前定义一个函数接口,其实现可以使用Lambda
表达式定义。值得注意的是Java
中的Lambda
并不是匿名内部类的语法糖,它基于Java1.7
的invokedynamic
指令,在运行时使用ASM
生成类文件来实现的。我们需要注意Java
的基本类型中并不存在函数基本类型,与之相对的我们可以把所有的函数接口看作函数类型,其中实现的函数就直接代表类型本身。
有了上述的匿名方法和Java
定义的Lambda
表达式,加上强大的stream API
我们可以更加轻松的处理大量数据,并更加优雅而简短的编写代码。
枚举(类)
Java
枚举是一种特殊的类,用来表示固定的常量,比如颜色和时间单位等,Java
中的枚举相较于其他语言更加强大,Java
支持在枚举中实现接口,定义字段和编写字段,这使得封装在枚举中依旧可用,这使得在Java
中枚举更易使用,并且更容易成为某些特殊设计模式的宠儿。
注解(类)
可以使用注解对方法,类和属性等进行标注,从而进行扩展。
使用注解可以大大减少了编码量,提高可读性和编码效率。注解已经成为了Java
实际开发中必不可少的一部分。
反射
Java
早在Java1.1
就提供了在程序运行时,获取类的所有方法和属性并调用任意对象的任意方法或者获取其属性的能力,这一能力非常强大,许多框架都是基于反射开发的。
Java
文件需要编译成.class
文件才能被JVM
加载使用,加载后对象的.class
数据在JVM
里就是一个Class
对象,反射提供给我们通过Class
对象获取类及其信息的能力和根据字节码文件操作JVM
内各种对象的能力。
应用场景
- 动态拓展:假设有同一组类是实现相同的接口,并且类的加载方式不限制。当我们需要那种具体类实现的功能时,只需加载
.class
文件,并获取对应的类对象。可以由Class
或者Constructor
实例化对象instance
;根据接口定义,可以获取类对象里的某一方法Method
,并配合instance
反射调用功能方法 Spring
的IOC
就是基于反射机制实现JDK
的动态代理JavaBean
能让一些工具可视化的操作软件组件。这些工具通过反射动态的载入并取得Java
组件(类) 的属性
类型体系
Java反射提供了以下核心类:
Class
:代表Java
中的类或接口。通过Class
类,我们可以获取类的构造函数、方法、字段等信息Constructor
:代表类的构造函数。通过Constructor
类,我们可以创建对象Method
:代表类的方法。通过Method
类,我们可以调用方法Field
:代表类的字段。通过Field
类,我们可以访问和修改字段的值
所有的信息都需要通过class
对象获取,所以我们称class
对象为反射的入口。
注意事项
在使用反射时,我们需要注意以下几点:
- 性能开销:反射的操作相比普通的
Java
代码会有一定的性能开销。因此,在性能要求较高的场景下,应尽量避免过度使用反射 - 访问权限:通过反射可以访问和修改类的私有成员,但这可能违反了类的封装性。在使用反射时,应注意尊重类的访问权限
- 异常处理:使用反射时,可能会抛出
ClassNotFoundException
、NoSuchMethodException
等异常。在使用反射的代码中,要适当地处理这些异常
虚拟机与反射
Java
反射的原理基于Java的运行时数据区域Runtime Data Area
和类加载机制。当JVM
加载一个类时,它将类的字节码文件加载到内存中,首先在方法区创建一个Klass
对象来表示该类,我们可以通过反射查看Klass
类并拿到一个Class
对象,Klass
对象和Class
对象为互相指向的关系,Class
对象包含了类的完整信息,包括类的构造函数、方法、字段等。刚才我们已经看到,对象的对象头内存储了一个指向Klass
的指针,并且Klass
对象和Class
对象为互相指向的关系,那么我们自然就可以通过对象直接拿到Class
对象了。同时我们也可以通过类型信息直接拿到Class
对象。
通过反射,我们可以通过Class
对象来获取类的信息,并在运行时进行操作。反射提供了一系列的方法来获取Class
对象、获取构造函数、获取方法、获取字段等。
总结
-
更加严格的封装:接口
-
对不同类型代码的复用:泛型
-
更加自由的特性或者抽象:内部类,匿名方法
-
拓展:注解,反射
不属于类型范畴的的语法糖
- 代码块某些书籍或文章介绍会直接忽略代码块的介绍,这是一个比较简单的概念,在日常编写代码中有很多灵活的使用方式。构造代码块,在类型中定义的空大括号为构造代码块,所有的构造代码块会依次在构造实例前执行静态代码块,在类型中定义的带有
static
关键字的空大括号为构造代码块,每个代码块在类型加载时按顺序依次执行一次 - 可变参数:在使用多个参数时,可以使用可变参数语法,即用
Type... type
来代表多个参数。底层是基于数组实现的,有一定的性能开销。一般情况下依旧建议使用逗号分隔。 - ...
- 代码块某些书籍或文章介绍会直接忽略代码块的介绍,这是一个比较简单的概念,在日常编写代码中有很多灵活的使用方式。构造代码块,在类型中定义的空大括号为构造代码块,所有的构造代码块会依次在构造实例前执行静态代码块,在类型中定义的带有
应对方法
样板代码
什么是样板代码,下面举一个简单的例子:
Java在设计之初最没有想到的可能就是虽然已经提供了如此之多的访问控制符,但对属性的访问权限控制依旧出现了问题:非常重要的一对权限访问和修改没有分隔开,虽然JavaBean通过将这两种权限抽象成setter和getter方法解决了这一问题,但是其他语言无疑提供了更优雅的解决方式。Java提供了几种不同的解决方案来解决这个问题:生成器或lombok。
但是在语言中样板代码还有更多,这种情况下该如何解决呢?
- 利用子程序,将重复的流程抽象出来统一调用
- 利用设计模式,这正是面向对象的强大之处,重复工作往往意味着从一开始你的设计就出现了问题
- 利用框架,一个成熟的框架已经替程序员考虑了更多问题,典型如spring面向切面编程就是减少样板编码的方式之一
- 生成器或lombok
当然少量的样板编码并不会对编程带来影响,反而相较于其他方式有着更高的可读性和更高的效率,希望大家懂得理性判断和取舍。
1995-2023
Java
从微末起步,逐渐发展为当今数字世界中很大一部分资产所依赖的基础,是用于构建许多服务和应用程序的可靠平台。面向未来的创新产品和数字服务也仍然依赖 Java
。
sun
微系统于1995年正式发布Java
,其对Java
语言的解释是:Java
编程语言是个简单、面向对象、分布式、解释性、健壮、安全、与系统无关、可移植、高性能、多线程和动态的语言。
sun
微系统在推出Java时就将其作为开放的技术。全球的Java
开发公司被要求所设计的Java
软件必须兼容。"Java
语言靠群体的力量而非公司的力量"是sun
微系统的口号之一,并获得了广大软件开发商的认同。
相较于其他语言,Java
的稳定性和简单性才是其立足之本,其面向对象和跨平台的独特优势至今仍在发挥作用,而其开放的特质,吸引了无数开发者参与其中,这也造就了Java
开放而广阔的生态。Java
为其他语言的发展指明了方向,创造了新的可能性,其设计思想值得我们深究。
如今更多基于JVM
平台的语言诞生对Java
构成了挑战,其中最有名的几门语言比如Kotlin
,Scala
有着更加高昂的学习成本,但有着更高的开发效率,更好的安全性和较Java
来说更时髦的语法。
软件工程的焦油坑在将来很长一段时间内会继续地使人们举步维艰,无法自拔。软件系统可能是人类创造中最错综复杂的事物,只能期待人们在力所能及的或者刚刚超越力所能及的范围内进行探索和尝试。这个行业需要:进行持续的发展;学习使用更大的要素来开发;新工具的使用;经论证的工程管理方法的最佳应用;良好判断的自由发挥以及能够使我们认识到自己不足和容易犯错的------上帝所赐予的谦卑。------ Frederick P.Brooks.Jr