[toc]
0 导读
-
摘要:
- 本书是布鲁斯 • 埃克尔时隔 15 年,继 Thinking in Java 之后又一力作,基于 Java 的 3 个长期支持版(Java 8、11、17),讲解 Java 核心语法,并对 Java 的核心变化进行详述。全书内容通俗易懂,配合示例讲解逐步深入,并结合实际开发需要,从语言底层设计出发,有效帮读者规避一些常见的开发陷阱。
- 主体部分共 22 章,内容包含对象、操作符、控制流、初始化和清理、复用、多态、接口、内部类、集合、函数式编程、流、异常、代码校验、文件、字符串、泛型等。
- 本书适合各个层次的 Java 开发者阅读,同时也可作为面向对象程序设计语言以及 Java 语言的参考教材。
-
这是一本2022年的新书
-
Java 是一门派生语言。当时的情况是,早期的语言设计师不想用 C++ 来开发项目,于是创建了一门和 C++ 极为相似的新编程语言,不过也做出了一些改进。这种新编程语言最主要的改动是加入了虚拟机和垃圾收集机制,本书后续章节会对这两点进行详细介绍。
-
语言设计缺陷:PL都有缺陷,但是术业有专攻,合适场景用合适的语言
-
普及程度:java已然大量普及
-
java的发布节奏
-
- 每隔6个月发布一个新版本,使用整数作为版本号
-
- 发布的版本会包含一些试用功能,让用户可以体验和指出问题。而这种 6 个月的版本节奏,其主要目的可能就在于让用户尽早发现功能试用的相关问题。不过,由于无法保证这些功能之后能够长期存在,一旦这些功能出于某些原因没有达成预期的效果,它们就会被取缔。所以,你不应该依赖这些试用性质的功能。
-
- STS和LTS:Java 8、11、17 都是 LTS 版本,其他版本则是支持周期只有 6 个月的 STS 版本。具体而言,只要有新版本问世,对 STS 版本的支持即宣告终止。类似地,一旦有新的 LTS版本问世,(通常在一年以内)很快也会停止对原 LTS 版本的支持(这里指的是 Oracle 所提供的免费支持,也就是说,OpenJDK 可能会支持更长时间)。
- Short-Term-Support STS 短期支持
- Long-Term-Support LTS 长期支持
-
-
图像用户界面
- Java1.0通过抽象窗口工具集(Abstract Windowing Toolkit, AWT)创建了一种在所有平台上都表现平平的 GUI。
- 归根结底,Java 在桌面领域从未真正强大过,甚至从未触及设计师的雄心壮志。
- 结果就是,Java 在桌面领域的大多数应用场景是 IDE 以及一些企业内部的应用程序。虽然人们确实也会用 Java 开发用户界面,但要清楚地意识到,这只是 Java 语言的一个小众需求。
-
JDK HTML文档:Oracle 公司为 Java 开发工具集(Java Development Kit, JDK)提供了电子文档,用Web 浏览器即可查看。
-
经过测试的示例:本书创建了一套测试系统,用于展示和验证大多数示例的输出结果。
01 什么是对象
1.1 抽象的历程
-
所有编程语言都是一种抽象
- 汇编语言是对计算机底层的一个极简化的抽象
- 命令式编程语言(FORTRAN,BASIC,C等)都是对汇编语言的抽象
- 然而以上的抽象方式要求根据计算机的结构而非问题的结构来思考
-
构建机器模型的一种替代方式是根据问题构建问题模型,例如
- Prolog语言将所有问题都转换为决策链
- 这些编程语言具有强烈的专业性
-
面向对象编程更进一步,具备了更强的通用性
- 通过添加新的对象,程序可以将自己改编为一种描述问题的语言:于此,阅读代码即是阅读表述问题的文字
-
SmallTalk是第一门获得成功的面向对象的编程语言,并未Java的出现提供了灵感。Alan Kay总结了该语言的5个特征
-
- 万物皆对象
-
- 一段对象实际上是对个对象通过发送消息来通知彼此之间要干什么
-
- 从内存角度而言,每一个对象都是由其它更为基础的对象组成的
-
- 每一个对象都有类型
-
- 同一类型的对象可以接收相同的消息
-
-
Grady Booch更为简洁的描述:对象具有状态,行为和标识
- 这意味着,对象可以拥有自己的内部属性(状态),方法(用于产生行为),同时每一个对象都有唯一的地址(标识)
1.2 对象具有接口
- 类描述了一系列具有相同特征(即数据元素)和行为(即功能方法)的对象
- 类其实就是一种抽象的数据类型
- 一个类可以创建多个对象
- 一个对象能接受什么请求,由其"接口"决定
- 类中定义了"接口"
- 接口的响应代码和数据称为"实现"
1.3 对象可以提供服务
- 将对象想象成“服务提供者”
- 提升对象内聚程度
- 复用或创建一些提供对应服务以解决问题的对象
1.4 隐藏的实现
-
程序员分为两类
- 类的创建者:创建新数据类型的人,暴露必要的接口给客户程序员,隐藏其它不必要的信息
- 客户程序员:使用现有数据类型的人,其诉求是通过工具类快速开发应用程序
-
设置访问控制的原因
-
- 防止客户程序员接触到他们不该触碰的内容,即用于数据类型内部运转的代码
-
- 让库的设计者再改变类的内部工作机制时,不用担心影响到使用该类的客户程序员
-
-
Java提供了3个显示关键字,即访问修饰符(access specifier)
- public:定义的内容可以被所有人访问
- private:内容只能通过类的创建者通过类自身的方法访问,其他人无法访问
- protected:继承的子类可以访问到protected成员,但不能访问到private成员
-
如果不适用修饰符,Java提供一种默认的访问权限:包访问,即一个类可以访问同一个包中的其他类,外部不可访问
1.5 复用实现
-
代码复用是我们使用面向对象的编程的一个重要理由
-
新创建的类可以使用任意数量的类型和对象组成,也可以任意组合这些对象,实现想要的功能。
-
利用已有的类组合成一个类,这个概念叫做“组合”(composition)
- 组合通常代表一种有的关系,比如“汽车有发动机”
- 如果组合是动态的,称为聚合(aggregation)。
-
组合和聚合的区别
- 聚合:表示两个对象之间是整体和部分的弱关系,部分的生命周期可以超越整体,比如电脑和鼠标
- 组合:表示两个对象之间是整体和部分的强关系,部分的生命周期不能超越整体,或者说不能脱离整体的存在,比如汽车和发动机。
1.6 继承
- 通过继承,子类可以获得父类的属性和方法,而无需重新编写相同的代码。
- 子类可以扩展或修改继承自父类的行为,并可以定义自己特有的属性和方法。
- 扩展(extend)和重写
- 这样,继承提供了一种层次化的类组织方式,使得代码更加模块化和易于维护。
1.7 多态
-
对非面向对象编译器:其生成的函数调用会触发“前期绑定”(early binding),意味着编译器会生成对一个具体方法名的调用,该方法名决定了被执行代码的绝对地址。
-
面向对象语言使用机制“后期绑定”(late binding),当你想某个对象发送消息时,知道运行时才会确定那一段代码被调用
-
Java通过特殊方法实现后期绑定,通俗说,当向一个对象啊发消息时,该对象自己会找到解决之道
-
C++中,必须通过virtual 关键字来达到这些目的,因为方法并不具备 默认动态绑定特性,而java默认具有动态绑定的特性,所以无需借助关键字和代码实现多态
1.8 单根层次结构
- Java中所有的类默认继承自一个终极基类,名字Object
- 单根层次结构具备很多优势
-
- 所有对象具有共同的接口,因为属于同一个终极基类
- C++无法保证所有对象属于同一个聚类,从兼容的角度看,这种限制性小的设计对C语言友好,但从面向对象思想的计财处看,必须手动构建类的层次,才能拥有其它面向对象编程语言具有的便捷性
- 此外,使用任何新的库时,可能会有不兼容的接口
- C++的灵活性物有所值吗?
- 如果已经花费大量心血编写了C代码,那么答案是肯定的,如果是从头开始,那么使用Java或其它方案会高效很多
-
- 有利于垃圾回收:这是Java对C++的巨大改进
-
1.9 集合
-
集合:(也可称为容器),会根据你放入其中的内容自行调整通奸。
-
C++中,集合是STL(Standard Template Library 标准模板库)中的一部分,Java也在标准库中提供了大量的集合
-
从程序设计角度,真正需要的是能解决实际问题的集合,选择不同的集合只要有以下零个原因
-
- 不同的集合提供了不同类型的接口和行为。
- 比如,栈和队列的用途就和Set及List完全不同
-
- 不同的集合和特定操作的执行效率有差异
- 比如,List的两种基础类型ArrayList,LinkedList,一个是序列列表,一个是链式列表
-
-
参数化类型(泛型)
- 因为单根层次结构决定了所有对象属于Object类型,所以一个持有Object的集合可以持有任何对象,使得集合十分易于复用
- 然而此时将对象都转型成了Object,取出时需要向下转型,除非明确知道向下转型的具体类型,否则向下转型是不安全的
- Java5引入的特性:支持参数化类型,也称泛型(generics)。可以通过再一堆叫括号中间加上类名来定义泛型
ArrayList<Shape> shape = new ArrayList();
1.10 对象创建和生命周期
-
C++语言的总之时效率有限,所以其对象数据保存交给程序员来管理
- 如果要最大化运行效率,可以通过栈区保存对象,或者保存在静态存储区中,这种做法优先考虑分配和释放内存的速度。代价是牺牲了灵活性,必须在编写代码时明确对象的数量,生命周期等
- 另外可以在内存池中动态创建对象,这个内存池称为“堆”。该方案知道运行时才能知道需要多少对象,及对象的生命周期和确切的类型,并且堆是在运行是动态管理内存的,所以对分配内存的时间比栈多。
-
动态创建对象是普遍存在的,其基于一个普遍接受的假设:对象往往是复杂的,所以你在创建对象时,查找和释放内存空间带来的额外开销不会造成严重影响。此外,更大的灵活性才是解决常规编程问题的关键。
-
Java只允许动态分配内存,每创建一个对象,都要使用new操作符创建一个对象的动态实例
-
对象的生命周期
- 对于允许在栈上创建对象的编程语言,编译器会判断对象将会存在多久以及负责自动销毁该对象。但如果在堆上创建对象,编译器就无从得知生命周期了
- 对于C++,必须在编码是明确合适销毁对象,否则如果代码有问题,会造成内存泄漏,导致了许多C++项目的失败。
- 而Java底层支持垃圾收集器机制,会自动找到会用的对象并销毁,提供了一种更高级的保证以防止潜在的的内存泄漏
-
Java设计垃圾收集器的意图就是处理内存释放的相关问题
- 垃圾收集器直到一个对象何时不再使用,会自动释放对象占用的内存
- 并且所有对象都继承自顶层基类Object,以及只能在堆上创建对象的特点
-
使得Java编程比C++简单不少,即程序员需要接入的决策和阻碍都大大减少
1.11 异常处理
-
异常处理是将编程语言甚至是操作系统和错误处理机制直接捆绑到一起。
- 异常时从错误发生之处“抛出”的对象,而根据错误类型,它可以被异常处理程序所“捕获”
- 当代码发生错误时,异常处理机制会使用一条特殊的,并行的执行路径处理这个错误,不影响正常代码的执行
-
Java一开始就会让你接触到异常处理,并且强制你必须使用它
1.12 总结
-
一段过程式程序包含了定义和函数调用。
- 如果想搞明白代码做了什么,必须查看它的函数调用以及底层代码
- 因此,过程式程序的理解成本很高,因为其设计的表达方式更多的面向计算机
-
面向对象编程中
- 对象的定义所呈现的是问题空间的概念,而发送至对象的消息则代表问题的具体活动
- 面向对象编程的设计狼好的程序总是易于阅读的,并且复用很常见,代码行数不会太多
02 安装Java和本书示例
2.1 编辑器
VScode or Intellij IDEA
2.2 shell
- shell,windos称 命令提示符
- 目录,windows用反斜杠"“而不是斜杠”"/"来分割一个目录
- shell基础操作
2.3 安装Java
- 运行Java需要安装JDK(Java Development Kit,Java开发工具集)
2.4 确认安装成功
在shell窗口输入java -version
2.5 安装和运行本书示例
- 在Github网站下载本书的示例,随后通过gradlew run 运行程序
03 对象无处不在
3.1 通过引用操作对象
- Java将一切视为对象,程序员 实际操作的是该对象的引用
String s;//创建一个引用
String s="asdf"//创建一个引用并进行初始化
3.2 必须创建所有对象
- 引用的作用是关联对象。通常我们使用new关键字来创建对象。
3.2.1 5种数据的存储方式
-
- 寄存器(register):
- 这是最快的数据存储方式,数据会保存在中央处理器(central processing unit,CPU)里。
- 而寄存器数量有限,只能按需分配
- 除C和C++,在程序中看不到寄存器的存在
-
- 栈(stack):
- 数据存储在随机存取存储器(random-access memory,RAM)里,处理器可以通过栈指针直接操作数据。
- 栈指针向下申请一块新的内存,向上释放内存
- 但在Java系统中,栈上的所有对象都有明确的生命周期,这会限制程序的灵活性
-
- 堆(heap):
- 一个通用的内存池(也是RAM),存放所有的Java对象
- new的对象会在堆中分配内存,且编译器不用关心堆上的对象存在多久
- 灵活性的代价:分配和清理堆存储比栈存储花费更多时间(但Java的堆内存分配机制已经非常高效了)
-
- 常量存储(constant storage):
- 常量通常直接保存在程序代码中,因为其值不变
- 有时也会和其它代码分隔开,如在嵌入式系统里,常量可以存储在只读存储器(read-only memory,ROM)中
-
- 非RAM存储(non-RAM storage):
- 如果一段数据没有保存在应用程序里,那么该数据的生命周期即不依赖于应用程序是否独立,也不受程序管制。
- 典型例子是 “序列化对象”(serialized object),指的是转换为字节流(叫做“序列化”)可以发送至其它机器的对象
- 另一个例子是“持久化对象”(persistent object),指的是保存在磁盘上的对象
- 这些对象的特点是,它们会将对象转换成其他形式以保存在其他媒介中,然后在需要的时候重新转换回RAM
3.2.2 基本类型
- new关键字在堆上创建对象,这意味着即使是简单的变量也不会很高效
- 对于基本类型,java使用了和C以及C+相同的实现机制,无需使用new来创建基本类型的变量,该变量会在栈上保存它的值,因此运行效率较高
- 包装类:可以将基本类型呈现为位于堆上的非原始对象。“自动装箱”机制能够将基本类型对象自动转换为包装类对象
- 高精度数字
- BigInter可以支持任意精度的整数
- BigDecimal用户任意精度的定点数
3.2.3 Java中的数组
- 许多编程语言都支持数组,然而在C和C++中,数组的本质是内存块,所以使用数组十分危险。
- Java的一个核心设计目的就是安全。
- 例如Java的数组一定会被初始化,并且无法访问数组边界之外的元素
- 这种边界检查的代价是需要消耗少许内存,以及运行时需要少量时间验证索引的正确性。
- 其背后的假设是,安全性以及生产力的改善可以完全抵消这种代价。
- 当创建一个用户防止对象的数组时,数组实际包含的是引用,这些引用会初始化为一个特殊的值null
3.3 注释
java中的注释和C++注释一样
/*
里面的文字都是注释
*/
//这是第二种注释
3.4 无须销毁对象
3.4.1 作用域
- 大多数过程式的编程语言都具有作用域(scope)的概念,作用域会决定其范围内定义的变量名的可见性和生命周期。
- C,C++,Java的作用域都通过大括号"{}"来定义
- Java中的变量即使在不同作用域也不能重名,这与C和C++不同
3.4.2 对象的作用域
- Java对象的生命周期和基本类型不同,使用new创建一个对象后,该对象在作用域结束后依然存在
{
String s = new String("a string");
}
- 在上面代码中,虽然引用s会在作用域结束后消失,但它指向的String对象还会继续占用内存
- Java中垃圾收集器会见时所有通过new创建的对象,并及时的发现哪些对象不再被引用,然后它会释放这些对象所占用的内存
- 这类机制解决了一类非常严重的编程问题:由于程序员忘记释放内存而导致的“内存泄漏(memory leak)”
- 而在C++中,你不仅要确保对象在需要时随时可用,而且事后还要负责销毁对象
3.5 使用class关键字创建新类型
- 在class关键字后面跟着新的类名创建对象
class ATypeName{
//类的具体实现
}
ATypeName a = new ATypeName();//使用new关键字创建一个该类的对象
-
当定义一个类时,可以为其定义两种元素:字段(也称“数据成员”)和方法(也称“成员函数”)
-
基本类型的默认值:
- 当一个类的字段是基本类型时,即使没有初始化,也会用有默认值
- 这一特点保证了基本类型的字段一定会被初始化(C++不会这么做)
-
这种机制不会引用与局部变量,因为局部变量不是类的字段。
-
未赋值的局部变量可能回事一个任意值,但在Java中,这将会是一个编译错误,C++中只是警告
3.6 方法,参数以及返回值
-
Java中方法决定了对象可以接受那些信息(也是其它语言中的函数),由最基础的4个部分构成:方法名,参数,返回值,方法体
- 方法名和参数列表共同构成了方法的“签名”(signature),方法签名即该方法的唯一标识符
-
参数列表中,实际操作的是引用
3.7 编写Java程序
-
名称可见性:在不同模块中使用想用名称如何区分
- C++:使用命名空间的概念
- Java:将互联网域名反转,因为域名是唯一的,以此来描述库的名称
-
使用其他组件
- 使用import关键字告知Java编译器你想要使用哪个类
- 许多编程风格指出,每一个用到的类应该被单独导入
-
static关键字解决两个问题
-
- 需要一个共享空间保存某个特定的字段,而不关心创建多少对象,甚至没有对象
-
- 需要使用一个类的某个方法,而该方法和具体的对象无关
-
-
static的字段或方法不依赖于任何对象,非static的字段和方法需要创建一个对象才能使用
-
使用“类数据”“类方法”来表示该数据或方法只服务于类,而非特定的对象
-
调用static变量的两种方法
- 通过对象调用
- 通过类名调用(推荐使用,体现变量的static特质)
3.8 第一个Java程序
- 程序启动的入口方法main()
public static void main(String[] args){}
- public关键字代表这个方法可以被外部程序所调用
- main参数是一个String数组,该数组用户获取控制台的输入,Java编译器强制传递该参数
3.9 编程风格
- 驼峰命名法
- 类名只有单词的首字母大写,单词间直接连接
- 方法,字段,变量以及对象名,首字母小写,其他和类名一样
04 操作符
这里和C++很像,就粗略过了
-
对象赋值时,真正操作的是对象的引用
c=d
意味着将c和d都指向原本只有d指向的那个对象- 这种现象也成为“别名”,在参数传递时也会产生别名,在进阶卷将会详解
-
Java中不能将非布尔值当布尔值使用
-
逻辑操作符支持一种名为“短路”的现象
- 一旦表达式当前部分的结算结果能够明确无误地确定整个表达式,那么余下的部分不会再执行
f(a) & f(b) & f(c)
,若f(a)
为假,则f(b),f(c)
都不用执行
-
字面量
- 字面量的后缀标识了类型
- 大写或小写的L表示long
200L,200l
- 大写或小写的F表示float
200.0F,200.0f
- 大写或小写的D表示double
200D,200d
- 大写或小写的L表示long
- 前缀可以标识进制
- 16进制
0x12 | 0X12
- 八进制
012
- 二进制
0b12 | 0B12
- 通过Integer和Long的toBinaryString()方法可以将整数转为二进制串
- 16进制
- 可以在数字字面量中间使用下划线,便于阅读,同时有一些规则限制(java 7新增功能)
- 字面量的后缀标识了类型
-
移位操作符
- 左移,右端补0
- 右移
- 有符号右移,进行符号扩展,正数高位插0,复数高位插1
- 无符号右移,使用
>>>
,默认高位插0
-
Java中无法自动将int型转换为boolean型,或者说boolean类型不允许进行任何类型的转湖岸
-
如果对小于 int 类型的基本数据类型(即 char、byte 或者 short)执行算术运算或按位运算,运算执行前这些值就会被自动提升为 int,结果也是 int 类型,如果要把结果赋值给较小的类型,就必须使用强制类型转换(由于把值赋给了较小的类型,可能会出现信息丢失)。
-
Java不需要sizeof(),因为Java具有可移植性,所有数据类型在所有机器中大小都是相同的
05 控制流
这里和C++很像,就粗略过了
if(Boolean-expression)
statement
else
statement
while(Boolean-expression)
statement
do
statement
while(Boolean-expression)
for(initialization;Boolean-expression;step)
statement
//Java5 引入更简洁的for,用于数组和容器
for(Object object:objectSequence)
statement
- 编程语言一开始就有goto,可以说goto是条件控制的起源
- Java中没有goto
- 但有一个相似的标签,在Java中使用标签的唯一理由是用到了嵌套循环
- 带标签的break,continue是较少使用的实验性功能,因此此前没有其它编程语言先例
label1:
outer-iteration {
inner-iteration {
// ...
break; // [1]
// ...
continue; // [2]
// ...
continue label1; // [3] 跳到标签处,随后继续循环
// ...
break label1; // [4] 跳到标签出,随后跳出循环
}
}
- switch语句实现多路选择的简洁方式
- Java7之前选择器的执行结果只能是整数,之后可以使用字符串
- 可以通过enum和switch结合使用
switch(integral-selector){
case intrgral-value1:statement;break;
case intrgral-value2:statement;break;
case intrgral-value3:statement;break;
// ...
default:statement
}
06 初始化和清理
- 初始化(initialization)和清理(cleanup)正式导致“不安全”编程的两个因素
- C++引入了构造器(constructor)的概念,Java同样,并且引入了一个垃圾收集器(garage collector)
6.1 用构造器保证初始化
- 构造器的名字就是类的名字
- 构造器可以带参数也可以不带参数
6.2 方法重载
-
同一个词可以表达不同的含义,即重载
-
构造器只有一个名字,因此必须进行方法重载
-
同名的函数或构造器,每个重载方法必须有独一无二的参数类型列表
6.3 无参构造器
- 无参构造器用于创建“默认对象”
- 如果创建了一个没有构造器的类,编译器会自动为这个类添加一个无参构造器
6.4 this关键字
-
类中的方法有一个隐藏的参数,在所有显式参数之前,代表着被操作对象的引用
-
this关键字只能在非静态方法中使用,当需要在方法中调用对象的时候,直接使用this即可,表示了对对象的引用
-
在构造器中调用构造器
- 因为this本身表示了对对象的引用,那么在this后加上参数列表,它就会显式地调用与该参数列表匹配的构造器
- 构造器一次只能调用一个,不能同时调用
- 构造器调用必须出现在方法最开始的地方,否则编译器会报错
-
static方法的意思:没有this
-
静态方法有点类似于全局方法,可能看起来不符合面向对象的思想,但确实有其实用之处
6.5 清理:终结和垃圾收集
6.5.1 finialize方法
-
Java有垃圾收集器回收不用的对象,然而仅限于使用new关键字创建的对象
-
如果在不是哟个new的情况下分配了一块特殊内存,Java允许在类中定义一个finialize()方法
- 当垃圾收集器准备释放对象资源的时候,首先调用finialize()方法,并且在下一次垃圾收集时才会回收这个对象的内存,因此finialize方法可以在垃圾收集前执行一些重要的清理工作
-
finialize方法和C++中的析构函数不同
- C++中,对象一定要被销毁
- Java中,finialize方法可以在垃圾清理前做一些其它工作,并不是做销毁工作
-
finialize方法的作用
- 垃圾收集只与内存有关,但只能回收new出来的对象
- finialize存在原因:没有使用Java中的通用方法来分配内存,采用了类似C语言的机制
- 主要通过本地方法来实现,在Java代码中调用非Java代码。Java里的本地方法只支持C和C++,但这些语言可以调用其他语言,所以Java实际可以调用任何代码
- 在非Java代码里,比如C的malloc函数,就需要调用相应的free方法来释放内存,需要在finalize方法中通过本地方法调用
6.5.2 垃圾收集器的工作原理
-
垃圾收集器可以提升对象创建的速度
- 可以把C++的堆想象成一个院子,每个对象都有自己的地盘,但地盘可能废弃,需要重新使用
- 但Java由于垃圾收集器的存在,堆更像是一个传送带,每次分配对象只是传送带移动,即“堆指针”向前移动,类似C++栈分配(当然记录分配情况会有额外开销,但与查找存储的开销小的多)
- 然而堆并不是传送带,否则如果缺页发生的频率过高,性能影响会更加显著。因此在垃圾回收的同时,垃圾收集器会压缩队中的所有对象,方便将“堆指针”移动到靠近传送带起点的位置,从而避免发生却也。即:垃圾收集器在分配存储空间的同时会将对象重新排列,由此实现一个高速的,有无限空闲空间的堆模型
-
JVM采用了一种自适应的垃圾收集方案,至于如何处理找到的存活对象,取决于当前使用的垃圾回收算法
- 算法1:“停止-复制”(stop-and-copy)
- 程序首先停止运行,将存活的对象从一个堆复制到另一个堆,剩下的都是垃圾。复制到新堆后,可以从头开始分配,因此新堆十分紧凑,可以像传送带一样分配内存。
- 当一个对象从一个地方移动到另一个地方的时,所有指向该对象的引用都必须修改,从栈或静态存储区到这个对象的引用可以立即更改,但在遍历过程中可能出现新指向该对象的其它引用,这些引用需要在找到时进行修复(想象一张旧地址对应新地址的表)
- 然而“复制收集器”有两个问题
-
- 需要两个堆,比实际多了一倍空间。一些JVM解决的方法是,将堆划分成块,复制动作在块之间
-
- 复制过程本身。一旦程序变得稳定,便很少产生垃圾,甚至没有,此时复制收集器仍会把内存从一个地方复制到另一个地方,为了防止这种情况,一些JVM在检测到没有新垃圾产生后,会切换到不同的垃圾收集算法(这就是“自适应”),如下面的算法
-
- 算法2:“标记-清除”(mark-and-sweep)
- Sun公司JVM早期版本一直使用该算法,对于一般用途,“标记-清楚”非常慢,但垃圾少或没有时,速度就会很快
- 该算法从栈和静态存储开始,遍历所有引用以查找存活对象,没找到一个存活对象,会给对象设置一个标志–此时尚未开始收集。只有在标记过程完成后才会开始清楚。清除过程中,没有标记的对象被释放,但不会发生复制,因此收集器如果压缩堆里的碎片需要重新排列对象。
- 比较
- “停止-复制”:不是在后台完成,程序在垃圾收集时会停止
- “标记-清除”:在后台完成,
- 垃圾收集器在内存不足时都会停止程序,且有些垃圾收集器会主动停止程序
- 算法1:“停止-复制”(stop-and-copy)
-
具体方案:此处的JVM里,内存以较大的块的形式分配
- 严格地“停止-复制”需要将每个存活的对象从旧堆分配到新堆,有了块之后,垃圾收集器可以将对象直接复制到废弃地块中,每个块中都有一个代数来跟踪它是否活着。
- 通常,只压缩上次垃圾收集依赖创建地块(这里面多是临时变量,生命周期短,很快称为垃圾)。如果块在某处被引用,认为其存活,垃圾收集过程中,存活对象不会被压缩或复制,而是增加代数。这样,可以很方便地处理正常情况下地大量短期临时对象。垃圾回收期会周期性地进行全面清理,不过大对象仍然不会被复制(只是增加他们所占块地代数)
- JVM会监控垃圾收集地效率,如果所有对象都很稳定,垃圾收集器效率很低地话,会切换到“标记-清除”算法。同样,JVM会跟踪标记和清除地效果,如果堆里出现很多碎片,会切换回“停止-复制”算法,这就是 **“自适应”**的用武之地
- 由此会得到一个啰嗦的称呼:“自适应的,分代的,停止-复制,标记-清除”垃圾收集器
-
JVM中有很多技术可以提升速度,其中一项重要的是“即时(just-in-time,JIT)编译器”,其与加载器的操作有关
- JIT会键该程序部分或全部编译为本地机器码,这样不需要JVM的解释,从而运行得更快
- 当需要加载一个类得时候,会先定位.class文件,然后将类的字节码加载到内存。JIT会根据实际执行情况进行动态优化,会监控代码的执行频率和模式,根据这些信息生成更高效的机器码,这种动态优化使得程序在运行时会逐渐变得更快,尽管在编译时可能会慢一点
- 惰性评估和选择性编译:JIT编译器可以根据需要对代码进行即时编译。它可以延迟编译,只在代码被频繁执行时才进行编译,避免了对不常执行的代码进行编译的浪费。这种选择性编译可以减少启动时间,并且只对真正需要优化的代码进行编译。
- 跨平台兼容性:JIT编译器能够根据不同的硬件和操作系统生成特定的本地机器码,因此可以实现跨平台的兼容性。通过JIT编译器,Java程序可以在不同的平台上运行,而不需要重新编写和调整代码。
6.6 成员初始化
-
基本类型字段会有一个初始值,对象初始值为null
-
如果一个对象未被初始化而被使用,会得到一个异常的运行错误
-
可以通过方法进行初始化,方法的参数需要已经初始化了
public class MethodInit2 {
int i = f();
int j = g(i);
int f() { return 11; }
int g(int n) { return n * 10; }
}
6.7 构造器初始化
-
使用构造器进行初始化,为编程带来了更大的灵活性,但者不能阻止自动初始化的执行
-
类中的变量定义决定了初始化的顺序,即使分散到方法定义之间,变量定义仍然会在任何方法(包括构造器)调用之前就被初始化
-
静态变量在初始化时首先初始化,这里举一个例子
-
假设有一个Dog类,同时有一个主函数类,主函数类里面有一个main函数和一些静态变量
-
- 调用main函数的时候,会加载main函数所在的类,这时会先初始化类中的静态变量,在其它变量
-
- main函数中new Dog对象,尽管没有显式的使用static关键字,但构造器实际上也是静态的,当第一次创建Dog对象或访问Dog类的静态方法时,Java解释器会搜索类路径定位Dog.class包
-
- 当 Dog.class 被加载后(这将创建一个 Class 对象,后面会介绍),它的所有静态初始化工作都会执行。因此,静态初始化只在 Class 对象首次加载时发生一次。
-
- 当使用 new Dog() 创建对象时,构建过程首先会在堆上为 Dog 对象分配足够的存储空间
-
- 这块存储空间会被清空,然后自动将该 Dog 对象中的所有基本类型设置为其默认值(数值类型的默认值是 0,boolean 和 char 则是和 0 等价的对应值),而引用会被设置为null。
-
- 执行所有出现在字段定义处的初始化操作
-
- 执行构造器
-
-
显式的静态初始化:Java允许在一个类中将多个静态初始化语句放在一个特殊的“静态子句”中(有时称为静态块)。这段代码和其它静态初始化语句一样,只执行一次::第一次创建该类的对象时,或第一次访问该类的静态成员时(即使从未创建过该类的对象)。
class Cup {
Cup(int marker) {
System.out.println("Cup(" + marker + ")");
}
void f(int marker) {
System.out.println("f(" + marker + ")");
}
}
class Cups {
static Cup cup1;
static Cup cup2;
static {
cup1 = new Cup(1);
cup2 = new Cup(2);
}
Cups() {
System.out.println("Cups()");
}
}
public class ExplicitStatic {
public static void main(String[] args) {
System.out.println("Inside main()");
Cups.cup1.f(99); // [1]
}
// static Cups cups1 = new Cups(); // [2]
// static Cups cups2 = new Cups(); // [2]
}
- 非静态实例的初始化:Java 提供了一种称为实例初始化(instance initialization)的类似语法,用于初始化每个对象的非静态变量。
class Mug {
Mug(int marker) {
System.out.println("Mug(" + marker + ")");
}
}
public class Mugs {
Mug mug1;
Mug mug2;
{ // [1]
mug1 = new Mug(1);
mug2 = new Mug(2);
System.out.println("mug1 & mug2 initialized");
}
Mugs() {
System.out.println("Mugs()");
}
Mugs(int i) {
System.out.println("Mugs(int)");
}
public static void main(String[] args) {
System.out.println("Inside main()");
new Mugs();
System.out.println("new Mugs() completed");
new Mugs(1);
System.out.println("new Mugs(1) completed");
}
}
- 除了缺少 static 关键字外,实例初始化子句看起来与静态初始化子句完全相同。此语法对于支持匿名内部类的初始化是必需的(参见第 11 章),但也可以用来保证无论调用哪个显式的构造器,某些操作都会发生。
6.8 数组初始化
-
数组是一个对象序列或基本类型序列,其中元素类型相同,用一个标识符名字打包在一起
-
数组通过方括号**索引操作符(index operator)[]**来定义和使用,数组有以下两种定义
- 类型名字前后加上空方括号
int[] a1;
- 也可像C++
int a1[]
- 然而第一种可能更合理,表示类型是一个“int数组”
- 类型名字前后加上空方括号
-
编译器不允许指定数组的大小。数组名只是一个引用(已经为引用分配了),但并没有为数组对象本身分配任何空间
-
要为数组对象分配空间,需要编写一个初始化表达式
- 初始化可以出现在任何地方
- 特殊的初始化是在创建数组的地方使用一组花括号括起来的值,这是编译器负责存储的分配
int[] a1 = { 1, 2, 3, 4, 5 };
-
所有数组有一个固有成员,length,元素从0开始计数,最大下标length-1
- 越届时C和C++会默默接受,并允许访问所有内存,这是许多臭名昭著的错误来源
- 而Java会通过抛出异常
6.8.1 动态数组创建
- 这里指在程序中指定数组大小进行创建
- 也可通过花括号包围列表来初始化对象数组
Object[] a = {object1 ...};//只能用在定义数组的时候
Object[] b= new Object[int];
Object[] c= new Object[]{object1 ...};//可以用到任何地方
6.8.2 可变参数序列
-
Java提供了类似C语言得可变参数列表(C中简称varargs),来创建和调用有可变参数的方法,包括数量可变的参数和未知类型的参数
-
- 由于所有类都继承Object,因此可以创建一个接受Object数组的方法
static void printArray(Object[] args)
- 由于所有类都继承Object,因此可以创建一个接受Object数组的方法
-
- Java5后,可以使用省略号定义一个可变参数列表
static void printArray(Object... args)
,这里编译器会自动填充,得到的仍然是数组,可变参数列表也可以是基本变量
- Java5后,可以使用省略号定义一个可变参数列表
-
- 可变参数字段和非可变参数可以混用,以此得到好的重载
6.9 枚举类型
//创建一个枚举类
public enum Spiciness {
NOT, MILD, MEDIUM, HOT, FLAMING
}
//使用枚举类
public class SimpleEnumUse {
public static void main(String[] args) {
Spiciness howHot = Spiciness.MEDIUM;
System.out.println(howHot);
}
}
- 其它方法
toString()
,更方便地显式enum实例地名字ordinal()
,来表示特定enum常量地声明顺序values()
,按照顺序生成一个enum常量值地数组
- enum可以和switch语句很好地连用
6.10 新特性:类型推断
- 最早在JDK10中启用,JDK11中优化。
- 再一个局部定义中(即在方法内部),编译器可以自动发现类型,这就是类型推断(type inference),我们可以通过var关键字启用它
public class TypeInference {
void method() {
// 显式类型:
String hello1 = "Hello";
// 类型推断:
var hello = "Hello!";
// 用户定义的类型也起作用:
Plumbus pb1 = new Plumbus();
var pb2 = new Plumbus();
}
// 静态方法里也可以启用:
static void staticMethod() {
var hello = "Hello!";
var pb2 = new Plumbus();
}
}
- var的限制
- 必须提供足够的信息来推断类型,不可使用var声明变量没有初始值或为空
- 函数返回值不能是var
- 类型推断不能用于方法的参数。Java不支持默认参数default argument,但可以通过重载来实现,
public class MyClass { public void myMethod(int x, int y) { // 方法的具体实现 } public void myMethod(int x) { int defaultY = 10; // 默认值 myMethod(x, defaultY); // 调用另一个重载方法,提供默认值 } }
#include <iostream> // 带有默认参数的函数声明 void printMessage(const std::string& message = "Hello, World!"); int main() { // 调用函数时不提供参数 printMessage(); // 输出:Hello, World! // 调用函数时提供参数 printMessage("Hello, C++!"); // 输出:Hello, C++! return 0; } // 带有默认参数的函数定义 void printMessage(const std::string& message) { std::cout << message << std::endl; }
- var很适合for循环,C++中的auto也是类型推断
6.11 总结
- C中变量初始化不当导致了很多的问题,而Java的构造器可以保证正确的初始化和处理
- C++中,“析构”非常重要,因为使用new创建的对象必须显式销毁,而Java中垃圾收集器会自动为所有对象释放内存,特殊情况需要手动
- 在不需要类似析构函数行为的情况下,Java的垃圾收集器极大地简化了编程工作,增阿吉了管理内存方面急需的安全性
- 但同时增加了运行成本,Java的速度问题是它设计某些特定编程领域的障碍
07 实现隐藏
- 重构的主要动机之一就是重写已经能正常工作的代码,提升其可读性、可理解性和可维护性
- 面向对象设计的一个主要考虑是“将变化的事物与保持不变的事物分离”。
- Java提供了**访问权限修饰符(access specifier)**来允许库开发者说明哪些是对客户程序员可用的,哪些是不可用的。
- 访问控制级别从“最多访问”到“最少访问”依次是:public、protected、包内访问(package access,没有关键字)和private。
- Java中package关键字 将组件捆绑成一个内聚的库单元
7.1 package:库单元
-
一个包(package)包含了一组类,这些类通过同一个命名空间(namespace)组织在了一起。
- 使用包的一种方式是指定全名,如
java.util.ArrayList list =new java.util.ArrayList();
,但这样会很冗长 - 此时可以使用import关键字,导入单个类
import java.util.ArrayList;
,此后可以不受限制的使用ArrayList
- 同时也可以导入某个包中的全部内容
import java.util.*;
,这里使用了通配符 - 导入包提供了一种管理命名空间的机制
- 使用包的一种方式是指定全名,如
-
一个Java源代码文件是一个编译单元(或称转移单元)
- 每个编译单元需要有一个.java结尾的文件名
- 在编译单元内,可以有且只能有一个public类,否则编译器会报错
- 如果该编译单元中有其他类,则在该包之外是看不到的,因为这些类不是public的,而是主public类的支持类(support class)
7.1.1 代码组织
- 编译一个.java文件时,文件中的每一个类都会产生一个输出文件,其名字是.java文件中对应的类的名字,但扩展名为.class
- Java中一个可运行程序就是一堆.class文件,可以使用jar归档器将它们打包压缩成一个Java档案文件(JAR)。Java解释器负责查找,加载和解释这些文件
- 库是一组这样的类文件,每个源文件有一个public类和其它非public类,即每个源文件都有一个公共组件,使用package关键字使得组件都属于一个命名空间
- package语句出现在文件中第一个非注释处,如以下代码便是该编译单元位于hiding的命名空间中,任何使用该类都必须指定名称或者导入包(Java包的命名规则是全部小写字母)
package hiding.mypackage;
public class MyClass {
// ...
}
7.1.2 创建独一无二的包名
-
包的整理:将所有的.class文件放在一个目录中,使用操作系统的分层文件结构
-
将包文件收集到单个子目录中解决了另外两个问题:
-
- 创建唯一的包名
-
- 找到那些可能隐藏在某个目录结构中的类
-
-
按照惯例
- package 名称的第一部分是类创建者的反向的因特网域名。因为因特网域名是唯一的
- 第二部分是将 package 名称解析为你机器上的一个目录这样当 Java 解释器需要加载一个 .class 文件时,它就可以定位到该 .class 文件所在的目录。
-
环境变量CLASSPATH,CLASSPATH 包含了一个或多个目录,用作查找 .class 文件的根目录
- 使用Maven或Gradle时,可以使用其响应的代码目录
-
通过import中*导入的两个库里有相同名称的类
- 如果不编写导致冲突的代码,则不会有问题
- 或者将某个类的导入转化为单类导入,使用完全指定名称的形式
7.1.3 定制工具库
- 通过包创建自己的util工具库
7.1.4 用 import 来改变行为
-
Java 缺少的一个功能是 C 语言的条件编译(conditional compilation),你可以通过更改一个开关设置来获得不同的行为,而无须更改任何其他代码。
-
没有的原因
- C中这一功能最常用于解决跨平台问题:根据目标平台来编译代码的不同部分
- Java 旨在自动跨平台,因此不需要这样的功能
-
但是条件编译还有其他用途。比如调试代码,在开发中启用该功能,发布的产品中禁用
7.2 Java 访问权限修饰符
7.2.1 包访问
- 默认访问权限没有关键字,通常称为包访问权限(有时称为“友好访问权限”)。
- 这意味着当前包中的所有其他类都可以访问该成员。对于此包之外的所有类,该成员显示为 private。包访问权限将相关的类分组到一个包中,以便它们可以轻松地交互。
- 一个编译单元(即一个文件)只能属于一个包,所以一个编译单元中的所有
类都可以通过包访问权限来相互访问。 - 默认包:当在统同一目录下的文件未声明包时,Java 将这样的文件看作属于该目录的“默认包”的隐含部分,因此它们为该目录中所有其他文件提供了包访问权限。
7.2.2 public:接口访问权限
- public 后面的成员声明对于所有人都是可用的
7.2.3 private:你无法访问它
- 只有当前类的成员可以访问
- 只要能确定是类的“辅助”方法,这个方法就可以设为 private,以确保在包的其他地方不会意外地使用它,从而让自己无法再更改或删除。
7.2.4 protected:继承访问权限
- protected 关键字处理的是继承的概念,它利用一个现有类—我们叫作基类(base class)—并在不修改现有类的情况下向该类添加新成员,还可以改变该类现有成员的行为。
- 为了继承一个类,需要声明新类,通过关键字 extends 扩展了现有类
class Foo extends Bar {
- 基类的创建者想要把特定成员的访问权限赋给子类,而不是所有的类,这时候可以通过protected关键字
- protected包括了包访问权
7.2.5 包访问权限与公共构造器
- 在只有包访问权限的类中声明一个 public 构造器是不合法的,编译器会将其标记为错误。
- 这是因为在 Java 中,包访问权限(也称为默认访问权限)是指只有同一个包中的其他类可以访问该类或其成员。如果在只有包访问权限的类中声明一个 public 构造器,那么这个构造器将可以被其他包中的类访问,违反了包访问权限的规则。
- 为了修复这个错误,应该将构造器的访问权限设置为包访问权限(默认访问权限),或者将该构造器放在另一个具有 public 访问权限的类中。这样,只有同一个包中的类才能访问这个构造器。
7.3 接口和实现
-
访问控制常常被称为实现隐藏。将数据和方法包装在类中,并与实现隐藏相结合,称为封装(encapsulation)A。其结果就是具有特征和行为的数据类型
-
访问权限控制在数据类型的内部设置了访问边界的两个原因
-
- 确定客户程序员可以使用和不可以使用的内容
-
- 将接口与实现分离
-
7.4 类的访问权限
-
访问权限修饰符可以出现在关键字 class 之前,实现对类的访问权限的控制
-
限制
- 每个编译单元(文件)只能有一个public类
- 每个编译单元都有一个由该 public类表示的公共接口。
- 它可以根据需要拥有任意数量的包访问权限的类。
- public类的名称必须和包含编译单元的文件名相匹配,包括大小写
- 当然,编译单元里可以没有 public 类,这时可以随意命名文件,但会为阅读和维护代码的人带来困扰
- 每个编译单元(文件)只能有一个public类
-
创建包访问权限的类时
- 将类的字段需要设置为包访问或者private
- public字段只有在强制的场景才能设置,否则与包访问的概念相冲突
-
类不能是private或protected的
- 因此类只能是public或包访问
- 如果需要防止对类的访问,可以将构造器设置为private,从而禁止其他人创建该类的对象,而你则可以在这个类的静态方法中创建对象
7.5 新特性:模块
- 在 JDK 9 之前,Java 程序会依赖整个 Java 库。这意味着即使最简单的程序也带有大量从未使用过的库代码
- JDK 9 最终引入了模块(module):Java 库设计者现在可以将代码清晰地划分为模块,这些模块以编程的方式指定它们所依赖的每个模块,并定义导出哪些组件以及哪些组件完全不可用。。
- 当使用库组件时,你会仅仅获得该组件的模块及其依赖项,不会有不使用的模块。
- 如果想继续使用隐藏的库组件,你必须启用“逃生舱口”(escape hatch),未来隐藏的库组件变更引发的问题都需要自己承担
7.6 总结
-
本章研究了类如何生成,以方便构建库:
- 首先,介绍了一组类是如何被打包到一个库里的;
- 其次,介绍了类是如何控制对其成员的访问的。
-
据估计,用 C 语言开发的项目,当代码量达到 5 万 ~10 万行时就会出现问题,因为C 语言只有单一的命名空间,这时候名称就开始冲突,导致额外的管理开销。
-
在 Java 中,通过 package 关键字、包命名方案和 import 关键字,你可以完全控制名称,因此很容易避免名称冲突的问题。
-
控制对成员的访问权限有两个原因
-
- 让用户远离他们不应该接触的部分
-
- 让库设计者可以改变类的内部实现,而不必担心会影响到客户程序员
-