跳到主要内容

8 篇博文 含有标签「Java」

查看所有标签

· 67 分钟阅读
CheverJohn

来自于我协会的朱学长哈!

来自于我协会的朱学长哈!

1. Java 基本功

1.1. Java 入门(基础概念与常识)

1.1.1. Java 语言有哪些特点?

  1. 简单易学;
  2. 面向对象(封装,继承,多态);
  3. 平台无关性( Java 虚拟机实现平台无关性);
  4. 可靠性;
  5. 安全性;
  6. 支持多线程( C++ 语言没有内置的多线程机制,因此必须调用操作系统的多线程功能来进行多线程程序设计,而 Java 语言却提供了多线程支持);
  7. 支持网络编程并且很方便( Java 语言诞生本身就是为简化网络编程设计的,因此 Java 语言不仅支持网络编程而且很方便);
  8. 编译与解释并存;

修正(参见: issue#544):C++11 开始(2011 年的时候),C++就引入了多线程库,在 windows、linux、macos 都可以使用std::threadstd::async来创建线程。参考链接:http://www.cplusplus.com/reference/thread/thread/?kw=thread

1.1.2. 关于 JVM JDK 和 JRE 最详细通俗的解答

1.1.2.1. JVM

Java 虚拟机(JVM)是运行 Java 字节码的虚拟机。JVM 有针对不同系统的特定实现(Windows,Linux,macOS),目的是使用相同的字节码,它们都会给出相同的结果。

什么是字节码?采用字节码的好处是什么?

在 Java 中,JVM 可以理解的代码就叫做字节码(即扩展名为 .class 的文件),它不面向任何特定的处理器,只面向虚拟机。Java 语言通过字节码的方式,在一定程度上解决了传统解释型语言执行效率低的问题,同时又保留了解释型语言可移植的特点。所以 Java 程序运行时比较高效,而且,由于字节码并不针对一种特定的机器,因此,Java 程序无须重新编译便可在多种不同操作系统的计算机上运行。

Java 程序从源代码到运行一般有下面 3 步:

Java程序运行过程

我们需要格外注意的是 .class->机器码 这一步。在这一步 JVM 类加载器首先加载字节码文件,然后通过解释器逐行解释执行,这种方式的执行速度会相对比较慢。而且,有些方法和代码块是经常需要被调用的(也就是所谓的热点代码),所以后面引进了 JIT 编译器,而 JIT 属于运行时编译。当 JIT 编译器完成第一次编译后,其会将字节码对应的机器码保存下来,下次可以直接使用。而我们知道,机器码的运行效率肯定是高于 Java 解释器的。这也解释了我们为什么经常会说 Java 是编译与解释共存的语言。

HotSpot 采用了惰性评估(Lazy Evaluation)的做法,根据二八定律,消耗大部分系统资源的只有那一小部分的代码(热点代码),而这也就是 JIT 所需要编译的部分。JVM 会根据代码每次被执行的情况收集信息并相应地做出一些优化,因此执行的次数越多,它的速度就越快。JDK 9 引入了一种新的编译模式 AOT(Ahead of Time Compilation),它是直接将字节码编译成机器码,这样就避免了 JIT 预热等各方面的开销。JDK 支持分层编译和 AOT 协作使用。但是 ,AOT 编译器的编译质量是肯定比不上 JIT 编译器的。

总结:

Java 虚拟机(JVM)是运行 Java 字节码的虚拟机。JVM 有针对不同系统的特定实现(Windows,Linux,macOS),目的是使用相同的字节码,它们都会给出相同的结果。字节码和不同系统的 JVM 实现是 Java 语言“一次编译,随处可以运行”的关键所在。

1.1.2.2. JDK 和 JRE

JDK 是 Java Development Kit,它是功能齐全的 Java SDK。它拥有 JRE 所拥有的一切,还有编译器(javac)和工具(如 javadoc 和 jdb)。它能够创建和编译程序。

JRE 是 Java 运行时环境。它是运行已编译 Java 程序所需的所有内容的集合,包括 Java 虚拟机(JVM),Java 类库,java 命令和其他的一些基础构件。但是,它不能用于创建新程序。

如果你只是为了运行一下 Java 程序的话,那么你只需要安装 JRE 就可以了。如果你需要进行一些 Java 编程方面的工作,那么你就需要安装 JDK 了。但是,这不是绝对的。有时,即使您不打算在计算机上进行任何 Java 开发,仍然需要安装 JDK。例如,如果要使用 JSP 部署 Web 应用程序,那么从技术上讲,您只是在应用程序服务器中运行 Java 程序。那你为什么需要 JDK 呢?因为应用程序服务器会将 JSP 转换为 Java servlet,并且需要使用 JDK 来编译 servlet。

1.1.3. Oracle JDK 和 OpenJDK 的对比

可能在看这个问题之前很多人和我一样并没有接触和使用过 OpenJDK 。那么 Oracle 和 OpenJDK 之间是否存在重大差异?下面我通过收集到的一些资料,为你解答这个被很多人忽视的问题。

对于 Java 7,没什么关键的地方。OpenJDK 项目主要基于 Sun 捐赠的 HotSpot 源代码。此外,OpenJDK 被选为 Java 7 的参考实现,由 Oracle 工程师维护。关于 JVM,JDK,JRE 和 OpenJDK 之间的区别,Oracle 博客帖子在 2012 年有一个更详细的答案:

问:OpenJDK 存储库中的源代码与用于构建 Oracle JDK 的代码之间有什么区别?

答:非常接近 - 我们的 Oracle JDK 版本构建过程基于 OpenJDK 7 构建,只添加了几个部分,例如部署代码,其中包括 Oracle 的 Java 插件和 Java WebStart 的实现,以及一些封闭的源代码派对组件,如图形光栅化器,一些开源的第三方组件,如 Rhino,以及一些零碎的东西,如附加文档或第三方字体。展望未来,我们的目的是开源 Oracle JDK 的所有部分,除了我们考虑商业功能的部分。

总结:

  1. Oracle JDK 大概每 6 个月发一次主要版本,而 OpenJDK 版本大概每三个月发布一次。但这不是固定的,我觉得了解这个没啥用处。详情参见:https://blogs.oracle.com/java-platform-group/update-and-faq-on-the-java-se-release-cadence
  2. OpenJDK 是一个参考模型并且是完全开源的,而 Oracle JDK 是 OpenJDK 的一个实现,并不是完全开源的;
  3. Oracle JDK 比 OpenJDK 更稳定。OpenJDK 和 Oracle JDK 的代码几乎相同,但 Oracle JDK 有更多的类和一些错误修复。因此,如果您想开发企业/商业软件,我建议您选择 Oracle JDK,因为它经过了彻底的测试和稳定。某些情况下,有些人提到在使用 OpenJDK 可能会遇到了许多应用程序崩溃的问题,但是,只需切换到 Oracle JDK 就可以解决问题;
  4. 在响应性和 JVM 性能方面,Oracle JDK 与 OpenJDK 相比提供了更好的性能;
  5. Oracle JDK 不会为即将发布的版本提供长期支持,用户每次都必须通过更新到最新版本获得支持来获取最新版本;
  6. Oracle JDK 根据二进制代码许可协议获得许可,而 OpenJDK 根据 GPL v2 许可获得许可。

1.1.4. Java 和 C++的区别?

我知道很多人没学过 C++,但是面试官就是没事喜欢拿咱们 Java 和 C++ 比呀!没办法!!!就算没学过 C++,也要记下来!

  • 都是面向对象的语言,都支持封装、继承和多态
  • Java 不提供指针来直接访问内存,程序内存更加安全
  • Java 的类是单继承的,C++ 支持多重继承;虽然 Java 的类不可以多继承,但是接口可以多继承。
  • Java 有自动内存管理机制,不需要程序员手动释放无用内存
  • 在 C 语言中,字符串或字符数组最后都会有一个额外的字符‘\0’来表示结束。但是,Java 语言中没有结束符这一概念。 这是一个值得深度思考的问题,具体原因推荐看这篇文章: https://blog.csdn.net/sszgg2006/article/details/49148189

1.1.5. 什么是 Java 程序的主类 应用程序和小程序的主类有何不同?

一个程序中可以有多个类,但只能有一个类是主类。在 Java 应用程序中,这个主类是指包含 main()方法的类。而在 Java 小程序中,这个主类是一个继承自系统类 JApplet 或 Applet 的子类。应用程序的主类不一定要求是 public 类,但小程序的主类要求必须是 public 类。主类是 Java 程序执行的入口点。

1.1.6. Java 应用程序与小程序之间有哪些差别?

简单说应用程序是从主线程启动(也就是 main() 方法)。applet 小程序没有 main() 方法,主要是嵌在浏览器页面上运行(调用init()或者run()来启动),嵌入浏览器这点跟 flash 的小游戏类似。

1.1.7. import java 和 javax 有什么区别?

刚开始的时候 JavaAPI 所必需的包是 java 开头的包,javax 当时只是扩展 API 包来使用。然而随着时间的推移,javax 逐渐地扩展成为 Java API 的组成部分。但是,将扩展从 javax 包移动到 java 包确实太麻烦了,最终会破坏一堆现有的代码。因此,最终决定 javax 包将成为标准 API 的一部分。

所以,实际上 java 和 javax 没有区别。这都是一个名字。

1.1.8. 为什么说 Java 语言“编译与解释并存”?

高级编程语言按照程序的执行方式分为编译型和解释型两种。简单来说,编译型语言是指编译器针对特定的操作系统将源代码一次性翻译成可被该平台执行的机器码;解释型语言是指解释器对源程序逐行解释成特定平台的机器码并立即执行。比如,你想阅读一本英文名著,你可以找一个英文翻译人员帮助你阅读, 有两种选择方式,你可以先等翻译人员将全本的英文名著(也就是源码)都翻译成汉语,再去阅读,也可以让翻译人员翻译一段,你在旁边阅读一段,慢慢把书读完。

Java 语言既具有编译型语言的特征,也具有解释型语言的特征,因为 Java 程序要经过先编译,后解释两个步骤,由 Java 编写的程序需要先经过编译步骤,生成字节码(*.class 文件),这种字节码必须由 Java 解释器来解释执行。因此,我们可以认为 Java 语言编译与解释并存。

1.2. Java 语法

1.2.1. 字符型常量和字符串常量的区别?

  1. 形式上: 字符常量是单引号引起的一个字符; 字符串常量是双引号引起的若干个字符
  2. 含义上: 字符常量相当于一个整型值( ASCII 值),可以参加表达式运算; 字符串常量代表一个地址值(该字符串在内存中存放位置)
  3. 占内存大小 字符常量只占 2 个字节; 字符串常量占若干个字节 (注意: char 在 Java 中占两个字节)

java 编程思想第四版:2.2.2 节

1.2.2. 关于注释?

Java 中的注释有三种:

  1. 单行注释

  2. 多行注释

  3. 文档注释。

在我们编写代码的时候,如果代码量比较少,我们自己或者团队其他成员还可以很轻易地看懂代码,但是当项目结构一旦复杂起来,我们就需要用到注释了。注释并不会执行,是我们程序员写给自己看的,注释是你的代码说明书,能够帮助看代码的人快速地理清代码之间的逻辑关系。因此,在写程序的时候随手加上注释是一个非常好的习惯。

《Clean Code》这本书明确指出:

代码的注释不是越详细越好。实际上好的代码本身就是注释,我们要尽量规范和美化自己的代码来减少不必要的注释。

若编程语言足够有表达力,就不需要注释,尽量通过代码来阐述。

举个例子:

去掉下面复杂的注释,只需要创建一个与注释所言同一事物的函数即可

// check to see if the employee is eligible for full benefits
if ((employee.falgs & HOURLY_FLAG) && (employee.age > 65))

应替换为

if (employee.isEligibleForFullBenefits())

1.2.3. 标识符和关键字的区别是什么?

在我们编写程序的时候,需要大量地为程序、类、变量、方法等取名字,于是就有了标识符,简单来说,标识符就是一个名字。但是有一些标识符,Java 语言已经赋予了其特殊的含义,只能用于特定的地方,这种特殊的标识符就是关键字。因此,关键字是被赋予特殊含义的标识符。比如,在我们的日常生活中 ,“警察局”这个名字已经被赋予了特殊的含义,所以如果你开一家店,店的名字不能叫“警察局”,“警察局”就是我们日常生活中的关键字。

1.2.4. 自增自减运算符

在写代码的过程中,常见的一种情况是需要某个整数类型变量增加 1 或减少 1,Java 提供了一种特殊的运算符,用于这种表达式,叫做自增运算符(++)和自减运算符(--)。

++和--运算符可以放在操作数之前,也可以放在操作数之后,当运算符放在操作数之前时,先自增/减,再赋值;当运算符放在操作数之后时,先赋值,再自增/减。例如,当“b=++a”时,先自增(自己增加 1),再赋值(赋值给 b);当“b=a++”时,先赋值(赋值给 b),再自增(自己增加 1)。也就是,++a 输出的是 a+1 的值,a++输出的是 a 值。用一句口诀就是:“符号在前就先加/减,符号在后就后加/减”。

1.2.5. Java中的几种基本数据类型是什么,对应的,各自占用多少字节呢?

Java有8种基本数据类型,分别为:

  1. 6种数字类型 :byte、short、int、long、float、double
  2. 1种字符类型:char
  3. 1中布尔型:boolean。

这八种基本类型都有对应的包装类分别为:Byte、Short、Integer、Long、Float、Double、Character、Boolean

基本类型位数字节默认值
int3240
short1620
long6480L
byte810
char162'u0000'
float3240f
double6480d
boolean1false

对于boolean,官方文档未明确定义,它依赖于 JVM 厂商的具体实现。逻辑上理解是占用 1位,但是实际中会考虑计算机高效存储因素。

注意:

  1. Java 里使用 long 类型的数据一定要在数值后面加上 L,否则将作为整型解析:
  2. char a = 'h'char :单引号,String a = "hello" :双引号

1.2.6. 自动装箱与拆箱

  • 装箱:将基本类型用它们对应的引用类型包装起来;
  • 拆箱:将包装类型转换为基本数据类型;

更多内容见:深入剖析 Java 中的装箱和拆箱

1.2.7. continue、break、和return的区别是什么?

在循环结构中,当循环条件不满足或者循环次数达到要求时,循环会正常结束。但是,有时候可能需要在循环的过程中,当发生了某种条件之后 ,提前终止循环,这就需要用到下面几个关键词:

  1. continue :指跳出当前的这一次循环,继续下一次循环。
  2. break :指跳出整个循环体,继续执行循环下面的语句。

return 用于跳出所在方法,结束该方法的运行。return 一般有两种用法:

  1. return; :直接使用 return 结束方法执行,用于没有返回值函数的方法
  2. return value; :return 一个特定值,用于有返回值函数的方法

1.3. 方法(函数)

1.3.1. 什么是方法的返回值?返回值在类的方法里的作用是什么?

方法的返回值是指我们获取到的某个方法体中的代码执行后产生的结果!(前提是该方法可能产生结果)。返回值的作用是接收出结果,使得它可以用于其他的操作!

1.3.2. 为什么 Java 中只有值传递?

为什么 Java 中只有值传递?

1.3.3. 重载和重写的区别

重载就是同样的一个方法能够根据输入数据的不同,做出不同的处理

重写就是当子类继承自父类的相同方法,输入数据一样,但要做出有别于父类的响应时,你就要覆盖父类方法

1.3.3.1. 重载

发生在同一个类中,方法名必须相同,参数类型不同、个数不同、顺序不同,方法返回值和访问修饰符可以不同。

下面是《Java 核心技术》对重载这个概念的介绍:

综上:重载就是同一个类中多个同名方法根据不同的传参来执行不同的逻辑处理。

1.3.3.2. 重写

重写发生在运行期,是子类对父类的允许访问的方法的实现过程进行重新编写。

  1. 返回值类型、方法名、参数列表必须相同,抛出的异常范围小于等于父类,访问修饰符范围大于等于父类。
  2. 如果父类方法访问修饰符为 private/final/static 则子类就不能重写该方法,但是被 static 修饰的方法能够被再次声明。
  3. 构造方法无法被重写

综上:重写就是子类对父类方法的重新改造,外部样子不能改变,内部逻辑可以改变

暖心的 Guide 哥最后再来个图标总结一下!

区别点重载方法重写方法
发生范围同一个类子类 中
参数列表必须修改一定不能修改
返回类型可修改一定不能修改
异常可修改可以减少或删除,一定不能抛出新的或者更广的异常
访问修饰符可修改一定不能做更严格的限制(可以降低限制)
发生阶段编译期运行期

1.3.4. 深拷贝 vs 浅拷贝

  1. 浅拷贝:对基本数据类型进行值传递,对引用数据类型进行引用传递般的拷贝,此为浅拷贝。
  2. 深拷贝:对基本数据类型进行值传递,对引用数据类型,创建一个新的对象,并复制其内容,此为深拷贝。

deep and shallow copy

1.3.5. 方法的四种类型

1、无参数无返回值的方法

// 无参数无返回值的方法(如果方法没有返回值,不能不写,必须写void,表示没有返回值)
public void f1() {
System.out.println("无参数无返回值的方法");
}

2、有参数无返回值的方法

/**
* 有参数无返回值的方法
* 参数列表由零组到多组“参数类型+形参名”组合而成,多组参数之间以英文逗号(,)隔开,形参类型和形参名之间以英文空格隔开
*/
public void f2(int a, String b, int c) {
System.out.println(a + "-->" + b + "-->" + c);
}

3、有返回值无参数的方法

// 有返回值无参数的方法(返回值可以是任意的类型,在函数里面必须有return关键字返回对应的类型)
public int f3() {
System.out.println("有返回值无参数的方法");
return 2;
}

4、有返回值有参数的方法

// 有返回值有参数的方法
public int f4(int a, int b) {
return a * b;
}

5、return 在无返回值方法的特殊使用

// return在无返回值方法的特殊使用
public void f5(int a) {
if (a>10) {
return;//表示结束所在方法 (f5方法)的执行,下方的输出语句不会执行
}
System.out.println(a);
}

2. Java 面向对象

2.1. 类和对象

2.1.1. 面向对象和面向过程的区别

  • 面向过程面向过程性能比面向对象高。 因为类调用时需要实例化,开销比较大,比较消耗资源,所以当性能是最重要的考量因素的时候,比如单片机、嵌入式开发、Linux/Unix 等一般采用面向过程开发。但是,面向过程没有面向对象易维护、易复用、易扩展。
  • 面向对象面向对象易维护、易复用、易扩展。 因为面向对象有封装、继承、多态性的特性,所以可以设计出低耦合的系统,使系统更加灵活、更加易于维护。但是,面向对象性能比面向过程低

参见 issue : 面向过程 :面向过程性能比面向对象高??

这个并不是根本原因,面向过程也需要分配内存,计算内存偏移量,Java 性能差的主要原因并不是因为它是面向对象语言,而是 Java 是半编译语言,最终的执行代码并不是可以直接被 CPU 执行的二进制机械码。

而面向过程语言大多都是直接编译成机械码在电脑上执行,并且其它一些面向过程的脚本语言性能也并不一定比 Java 好。

2.1.2. 构造器 Constructor 是否可被 override?

Constructor 不能被 override(重写),但是可以 overload(重载),所以你可以看到一个类中有多个构造函数的情况。

2.1.3. 在 Java 中定义一个不做事且没有参数的构造方法的作用

Java 程序在执行子类的构造方法之前,如果没有用 super()来调用父类特定的构造方法,则会调用父类中“没有参数的构造方法”。因此,如果父类中只定义了有参数的构造方法,而在子类的构造方法中又没有用 super()来调用父类中特定的构造方法,则编译时将发生错误,因为 Java 程序在父类中找不到没有参数的构造方法可供执行。解决办法是在父类里加上一个不做事且没有参数的构造方法。

2.1.4. 成员变量与局部变量的区别有哪些?

  1. 从语法形式上看:成员变量是属于类的,而局部变量是在方法中定义的变量或是方法的参数;成员变量可以被 public,private,static 等修饰符所修饰,而局部变量不能被访问控制修饰符及 static 所修饰;但是,成员变量和局部变量都能被 final 所修饰。
  2. 从变量在内存中的存储方式来看:如果成员变量是使用static修饰的,那么这个成员变量是属于类的,如果没有使用static修饰,这个成员变量是属于实例的。而对象存在于堆内存,局部变量则存在于栈内存。
  3. 从变量在内存中的生存时间上看:成员变量是对象的一部分,它随着对象的创建而存在,而局部变量随着方法的调用而自动消失。
  4. 成员变量如果没有被赋初值:则会自动以类型的默认值而赋值(一种情况例外:被 final 修饰的成员变量也必须显式地赋值),而局部变量则不会自动赋值。

2.1.5. 创建一个对象用什么运算符?对象实体与对象引用有何不同?

new 运算符,new 创建对象实例(对象实例在堆内存中),对象引用指向对象实例(对象引用存放在栈内存中)。一个对象引用可以指向 0 个或 1 个对象(一根绳子可以不系气球,也可以系一个气球);一个对象可以有 n 个引用指向它(可以用 n 条绳子系住一个气球)。

2.1.6. 一个类的构造方法的作用是什么? 若一个类没有声明构造方法,该程序能正确执行吗? 为什么?

主要作用是完成对类对象的初始化工作。可以执行。因为一个类即使没有声明构造方法也会有默认的不带参数的构造方法。如果我们自己添加了类的构造方法(无论是否有参),Java 就不会再添加默认的无参数的构造方法了,这时候,就不能直接 new 一个对象而不传递参数了,所以我们一直在不知不觉地使用构造方法,这也是为什么我们在创建对象的时候后面要加一个括号(因为要调用无参的构造方法)。如果我们重载了有参的构造方法,记得都要把无参的构造方法也写出来(无论是否用到),因为这可以帮助我们在创建对象的时候少踩坑。

2.1.7. 构造方法有哪些特性?

  1. 名字与类名相同。
  2. 没有返回值,但不能用 void 声明构造函数。
  3. 生成类的对象时自动执行,无需调用。

2.1.8. 在调用子类构造方法之前会先调用父类没有参数的构造方法,其目的是?

帮助子类做初始化工作。

2.1.9. 对象的相等与指向他们的引用相等,两者有什么不同?

对象的相等,比的是内存中存放的内容是否相等。而引用相等,比较的是他们指向的内存地址是否相等。

2.2. 面向对象三大特征

2.2.1. 封装

封装是指把一个对象的状态信息(也就是属性)隐藏在对象内部,不允许外部对象直接访问对象的内部信息。但是可以提供一些可以被外界访问的方法来操作属性。就好像我们看不到挂在墙上的空调的内部的零件信息(也就是属性),但是可以通过遥控器(方法)来控制空调。如果属性不想被外界访问,我们大可不必提供方法给外界访问。但是如果一个类没有提供给外界访问的方法,那么这个类也没有什么意义了。就好像如果没有空调遥控器,那么我们就无法操控空凋制冷,空调本身就没有意义了(当然现在还有很多其他方法 ,这里只是为了举例子)。

public class Student {
private int id;//id属性私有化
private String name;//name属性私有化

//获取id的方法
public int getId() {
return id;
}

//设置id的方法
public void setId(int id) {
this.id = id;
}

//获取name的方法
public String getName() {
return name;
}

//设置name的方法
public void setName(String name) {
this.name = name;
}
}

2.2.2. 继承

不同类型的对象,相互之间经常有一定数量的共同点。例如,小明同学、小红同学、小李同学,都共享学生的特性(班级、学号等)。同时,每一个对象还定义了额外的特性使得他们与众不同。例如小明的数学比较好,小红的性格惹人喜爱;小李的力气比较大。继承是使用已存在的类的定义作为基础建立新类的技术,新类的定义可以增加新的数据或新的功能,也可以用父类的功能,但不能选择性地继承父类。通过使用继承,可以快速地创建新的类,可以提高代码的重用,程序的可维护性,节省大量创建新类的时间 ,提高我们的开发效率。

关于继承如下 3 点请记住:

  1. 子类拥有父类对象所有的属性和方法(包括私有属性和私有方法),但是父类中的私有属性和方法子类是无法访问,只是拥有
  2. 子类可以拥有自己属性和方法,即子类可以对父类进行扩展。
  3. 子类可以用自己的方式实现父类的方法。(以后介绍)。

2.2.3. 多态

多态,顾名思义,表示一个对象具有多种的状态。具体表现为父类的引用指向子类的实例。

多态的特点:

  • 对象类型和引用类型之间具有继承(类)/实现(接口)的关系;
  • 对象类型不可变,引用类型可变;
  • 方法具有多态性,属性不具有多态性;
  • 引用类型变量发出的方法调用的到底是哪个类中的方法,必须在程序运行期间才能确定;
  • 多态不能调用“只在子类存在但在父类不存在”的方法;
  • 如果子类重写了父类的方法,真正执行的是子类覆盖的方法,如果子类没有覆盖父类的方法,执行的是父类的方法。

2.3. 修饰符

2.3.1. 在一个静态方法内调用一个非静态成员为什么是非法的?

由于静态方法可以不通过对象进行调用,因此在静态方法里,不能调用其他非静态变量,也不可以访问非静态变量成员。

2.3.2. 静态方法和实例方法有何不同

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

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

2.3.3. 常见关键字总结:static,final,this,super

详见笔主的这篇文章: https://snailclimb.gitee.io/javaguide/#/docs/java/basic/final,static,this,super

2.4. 接口和抽象类

2.4.1. 接口和抽象类的区别是什么?

  1. 接口的方法默认是 public,所有方法在接口中不能有实现(Java 8 开始接口方法可以有默认实现),而抽象类可以有非抽象的方法。
  2. 接口中除了 static、final 变量,不能有其他变量,而抽象类中则不一定。
  3. 一个类可以实现多个接口,但只能实现一个抽象类。接口自己本身可以通过 extends 关键字扩展多个接口。
  4. 接口方法默认修饰符是 public,抽象方法可以有 public、protected 和 default 这些修饰符(抽象方法就是为了被重写所以不能使用 private 关键字修饰!)。
  5. 从设计层面来说,抽象是对类的抽象,是一种模板设计,而接口是对行为的抽象,是一种行为的规范。

备注:

  1. 在 JDK8 中,接口也可以定义静态方法,可以直接用接口名调用。实现类和实现是不可以调用的。如果同时实现两个接口,接口中定义了一样的默认方法,则必须重写,不然会报错。(详见 issue:https://github.com/Snailclimb/JavaGuide/issues/146
  2. jdk9 的接口被允许定义私有方法 。

总结一下 jdk7~jdk9 Java 中接口概念的变化(相关阅读):

  1. 在 jdk 7 或更早版本中,接口里面只能有常量变量和抽象方法。这些接口方法必须由选择实现接口的类实现。
  2. jdk8 的时候接口可以有默认方法和静态方法功能。
  3. Jdk 9 在接口中引入了私有方法和私有静态方法。

2.5. 其它重要知识点

2.5.1. String StringBuffer 和 StringBuilder 的区别是什么? String 为什么是不可变的?

简单的来说:String 类中使用 final 关键字修饰字符数组来保存字符串,private final char value[],所以 String 对象是不可变的。

补充(来自issue 675):在 Java 9 之后,String 类的实现改用 byte 数组存储字符串 private final byte[] value;

StringBuilderStringBuffer 都继承自 AbstractStringBuilder 类,在 AbstractStringBuilder 中也是使用字符数组保存字符串char[]value 但是没有用 final 关键字修饰,所以这两种对象都是可变的。

StringBuilderStringBuffer 的构造方法都是调用父类构造方法也就是AbstractStringBuilder 实现的,大家可以自行查阅源码。

AbstractStringBuilder.java

abstract class AbstractStringBuilder implements Appendable, CharSequence {
/**
* The value is used for character storage.
*/
char[] value;

/**
* The count is the number of characters used.
*/
int count;

AbstractStringBuilder(int capacity) {
value = new char[capacity];
}}

线程安全性

String 中的对象是不可变的,也就可以理解为常量,线程安全。AbstractStringBuilderStringBuilderStringBuffer 的公共父类,定义了一些字符串的基本操作,如 expandCapacityappendinsertindexOf 等公共方法。StringBuffer 对方法加了同步锁或者对调用的方法加了同步锁,所以是线程安全的。StringBuilder 并没有对方法进行加同步锁,所以是非线程安全的。

性能

每次对 String 类型进行改变的时候,都会生成一个新的 String 对象,然后将指针指向新的 String 对象。StringBuffer 每次都会对 StringBuffer 对象本身进行操作,而不是生成新的对象并改变对象引用。相同情况下使用 StringBuilder 相比使用 StringBuffer 仅能获得 10%~15% 左右的性能提升,但却要冒多线程不安全的风险。

对于三者使用的总结:

  1. 操作少量的数据: 适用 String
  2. 单线程操作字符串缓冲区下操作大量数据: 适用 StringBuilder
  3. 多线程操作字符串缓冲区下操作大量数据: 适用 StringBuffer

2.5.2. Object 类的常见方法总结

Object 类是一个特殊的类,是所有类的父类。它主要提供了以下 11 个方法:


public final native Class<?> getClass()//native方法,用于返回当前运行时对象的Class对象,使用了final关键字修饰,故不允许子类重写。

public native int hashCode() //native方法,用于返回对象的哈希码,主要使用在哈希表中,比如JDK中的HashMap。
public boolean equals(Object obj)//用于比较2个对象的内存地址是否相等,String类对该方法进行了重写用户比较字符串的值是否相等。

protected native Object clone() throws CloneNotSupportedException//naitive方法,用于创建并返回当前对象的一份拷贝。一般情况下,对于任何对象 x,表达式 x.clone() != x 为true,x.clone().getClass() == x.getClass() 为true。Object本身没有实现Cloneable接口,所以不重写clone方法并且进行调用的话会发生CloneNotSupportedException异常。

public String toString()//返回类的名字@实例的哈希码的16进制的字符串。建议Object所有的子类都重写这个方法。

public final native void notify()//native方法,并且不能重写。唤醒一个在此对象监视器上等待的线程(监视器相当于就是锁的概念)。如果有多个线程在等待只会任意唤醒一个。

public final native void notifyAll()//native方法,并且不能重写。跟notify一样,唯一的区别就是会唤醒在此对象监视器上等待的所有线程,而不是一个线程。

public final native void wait(long timeout) throws InterruptedException//native方法,并且不能重写。暂停线程的执行。注意:sleep方法没有释放锁,而wait方法释放了锁 。timeout是等待时间。

public final void wait(long timeout, int nanos) throws InterruptedException//多了nanos参数,这个参数表示额外时间(以毫微秒为单位,范围是 0-999999)。 所以超时的时间还需要加上nanos毫秒。

public final void wait() throws InterruptedException//跟之前的2个wait方法一样,只不过该方法一直等待,没有超时时间这个概念

protected void finalize() throws Throwable { }//实例被垃圾回收器回收的时候触发的操作

2.5.3. == 与 equals(重要)

== : 它的作用是判断两个对象的地址是不是相等。即,判断两个对象是不是同一个对象(基本数据类型==比较的是值,引用数据类型==比较的是内存地址)。

equals() : 它的作用也是判断两个对象是否相等。但它一般有两种使用情况:

  • 情况 1:类没有覆盖 equals() 方法。则通过 equals() 比较该类的两个对象时,等价于通过“==”比较这两个对象。
  • 情况 2:类覆盖了 equals() 方法。一般,我们都覆盖 equals() 方法来比较两个对象的内容是否相等;若它们的内容相等,则返回 true (即,认为这两个对象相等)。

举个例子:

public class test1 {
public static void main(String[] args) {
String a = new String("ab"); // a 为一个引用
String b = new String("ab"); // b为另一个引用,对象的内容一样
String aa = "ab"; // 放在常量池中
String bb = "ab"; // 从常量池中查找
if (aa == bb) // true
System.out.println("aa==bb");
if (a == b) // false,非同一对象
System.out.println("a==b");
if (a.equals(b)) // true
System.out.println("aEQb");
if (42 == 42.0) { // true
System.out.println("true");
}
}
}

说明:

  • String 中的 equals 方法是被重写过的,因为 object 的 equals 方法是比较的对象的内存地址,而 String 的 equals 方法比较的是对象的值。
  • 当创建 String 类型的对象时,虚拟机会在常量池中查找有没有已经存在的值和要创建的值相同的对象,如果有就把它赋给当前引用。如果没有就在常量池中重新创建一个 String 对象。

2.5.4. hashCode 与 equals (重要)

面试官可能会问你:“你重写过 hashcode 和 equals 么,为什么重写 equals 时必须重写 hashCode 方法?”

2.5.4.1. hashCode()介绍

hashCode() 的作用是获取哈希码,也称为散列码;它实际上是返回一个 int 整数。这个哈希码的作用是确定该对象在哈希表中的索引位置。hashCode() 定义在 JDK 的 Object.java 中,这就意味着 Java 中的任何类都包含有 hashCode() 函数。

散列表存储的是键值对(key-value),它的特点是:能根据“键”快速的检索出对应的“值”。这其中就利用到了散列码!(可以快速找到所需要的对象)

2.5.4.2. 为什么要有 hashCode

我们先以“HashSet 如何检查重复”为例子来说明为什么要有 hashCode: 当你把对象加入 HashSet 时,HashSet 会先计算对象的 hashcode 值来判断对象加入的位置,同时也会与该位置其他已经加入的对象的 hashcode 值作比较,如果没有相符的 hashcode,HashSet 会假设对象没有重复出现。但是如果发现有相同 hashcode 值的对象,这时会调用 equals()方法来检查 hashcode 相等的对象是否真的相同。如果两者相同,HashSet 就不会让其加入操作成功。如果不同的话,就会重新散列到其他位置。(摘自我的 Java 启蒙书《Head first java》第二版)。这样我们就大大减少了 equals 的次数,相应就大大提高了执行速度。

通过我们可以看出:hashCode() 的作用就是获取哈希码,也称为散列码;它实际上是返回一个 int 整数。这个哈希码的作用是确定该对象在哈希表中的索引位置。hashCode()在散列表中才有用,在其它情况下没用。在散列表中 hashCode() 的作用是获取对象的散列码,进而确定该对象在散列表中的位置。

2.5.4.3. hashCode()与 equals()的相关规定
  1. 如果两个对象相等,则 hashcode 一定也是相同的
  2. 两个对象相等,对两个对象分别调用 equals 方法都返回 true
  3. 两个对象有相同的 hashcode 值,它们也不一定是相等的
  4. 因此,equals 方法被覆盖过,则 hashCode 方法也必须被覆盖
  5. hashCode() 的默认行为是对堆上的对象产生独特值。如果没有重写 hashCode(),则该 class 的两个对象无论如何都不会相等(即使这两个对象指向相同的数据)

推荐阅读:Java hashCode() 和 equals()的若干问题解答

2.5.5. Java 序列化中如果有些字段不想进行序列化,怎么办?

对于不想进行序列化的变量,使用 transient 关键字修饰。

transient 关键字的作用是:阻止实例中那些用此关键字修饰的的变量序列化;当对象被反序列化时,被 transient 修饰的变量值不会被持久化和恢复。transient 只能修饰变量,不能修饰类和方法。

2.5.6. 获取用键盘输入常用的两种方法

方法 1:通过 Scanner

Scanner input = new Scanner(System.in);
String s = input.nextLine();
input.close();

方法 2:通过 BufferedReader

BufferedReader input = new BufferedReader(new InputStreamReader(System.in));
String s = input.readLine();

3. Java 核心技术

3.1. 集合

3.1.1. Collections 工具类和 Arrays 工具类常见方法总结

详见笔主的这篇文章: https://gitee.com/SnailClimb/JavaGuide/blob/master/docs/java/basic/Arrays,CollectionsCommonMethods.md

3.2. 异常

3.2.1. Java 异常类层次结构图

因为有一些错误,所以这边省略了一大段话,还请查看原文

3.2.2. Throwable 类常用方法

  • public string getMessage():返回异常发生时的简要描述
  • public string toString():返回异常发生时的详细信息
  • public string getLocalizedMessage():返回异常对象的本地化信息。使用 Throwable 的子类覆盖这个方法,可以生成本地化信息。如果子类没有覆盖该方法,则该方法返回的信息与 getMessage()返回的结果相同
  • public void printStackTrace():在控制台上打印 Throwable 对象封装的异常信息

3.2.3. try-catch-finally

  • try 块: 用于捕获异常。其后可接零个或多个 catch 块,如果没有 catch 块,则必须跟一个 finally 块。
  • catch 块: 用于处理 try 捕获到的异常。
  • finally 块: 无论是否捕获或处理异常,finally 块里的语句都会被执行。当在 try 块或 catch 块中遇到 return 语句时,finally 语句块将在方法返回之前被执行。

在以下 4 种特殊情况下,finally 块不会被执行:

  1. 在 finally 语句块第一行发生了异常。 因为在其他行,finally 块还是会得到执行
  2. 在前面的代码中用了 System.exit(int)已退出程序。 exit 是带参函数 ;若该语句在异常语句之后,finally 会执行
  3. 程序所在的线程死亡。
  4. 关闭 CPU。

下面这部分内容来自 issue:https://github.com/Snailclimb/JavaGuide/issues/190

注意: 当 try 语句和 finally 语句中都有 return 语句时,在方法返回之前,finally 语句的内容将被执行,并且 finally 语句的返回值将会覆盖原始的返回值。如下:

public class Test {
public static int f(int value) {
try {
return value * value;
} finally {
if (value == 2) {
return 0;
}
}
}
}

如果调用 f(2),返回值将是 0,因为 finally 语句的返回值覆盖了 try 语句块的返回值。

3.2.4.使用 try-with-resources 来代替try-catch-finally

《Effecitve Java》中明确指出:

面对必须要关闭的资源,我们总是应该优先使用try-with-resources而不是try-finally。随之产生的代码更简短,更清晰,产生的异常对我们也更有用。try-with-resources语句让我们更容易编写必须要关闭的资源的代码,若采用try-finally则几乎做不到这点。

Java 中类似于InputStreamOutputStreamScannerPrintWriter等的资源都需要我们调用close()方法来手动关闭,一般情况下我们都是通过try-catch-finally语句来实现这个需求,如下:

        //读取文本文件的内容
Scanner scanner = null;
try {
scanner = new Scanner(new File("D://read.txt"));
while (scanner.hasNext()) {
System.out.println(scanner.nextLine());
}
} catch (FileNotFoundException e) {
e.printStackTrace();
} finally {
if (scanner != null) {
scanner.close();
}
}

使用Java 7之后的 try-with-resources 语句改造上面的代码:

try (Scanner scanner = new Scanner(new File("test.txt"))) {
while (scanner.hasNext()) {
System.out.println(scanner.nextLine());
}
} catch (FileNotFoundException fnfe) {
fnfe.printStackTrace();
}

当然多个资源需要关闭的时候,使用 try-with-resources 实现起来也非常简单,如果你还是用try-catch-finally可能会带来很多问题。

通过使用分号分隔,可以在try-with-resources块中声明多个资源:

3.3. 多线程

3.3.1. 简述线程、程序、进程的基本概念。以及他们之间关系是什么?

线程与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。与进程不同的是同类的多个线程共享同一块内存空间和一组系统资源,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。

程序是含有指令和数据的文件,被存储在磁盘或其他的数据存储设备中,也就是说程序是静态的代码。

进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。简单来说,一个进程就是一个执行中的程序,它在计算机中一个指令接着一个指令地执行着,同时,每个进程还占有某些系统资源如 CPU 时间,内存空间,文件,输入输出设备的使用权等等。换句话说,当程序在执行时,将会被操作系统载入内存中。 线程是进程划分成的更小的运行单位。线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。从另一角度来说,进程属于操作系统的范畴,主要是同一段时间内,可以同时执行一个以上的程序,而线程则是在同一程序内几乎同时执行一个以上的程序段。

3.3.2. 线程有哪些基本状态?

Java 线程在运行的生命周期中的指定时刻只可能处于下面 6 种不同状态的其中一个状态(图源《Java 并发编程艺术》4.1.4 节)。

Java线程的状态

线程在生命周期中并不是固定处于某一个状态而是随着代码的执行在不同状态之间切换。Java 线程状态变迁如下图所示(图源《Java 并发编程艺术》4.1.4 节):

Java线程状态变迁

由上图可以看出:

线程创建之后它将处于 NEW(新建) 状态,调用 start() 方法后开始运行,线程这时候处于 READY(可运行) 状态。可运行状态的线程获得了 cpu 时间片(timeslice)后就处于 RUNNING(运行) 状态。

操作系统隐藏 Java 虚拟机(JVM)中的 READY 和 RUNNING 状态,它只能看到 RUNNABLE 状态(图源:HowToDoInJavaJava Thread Life Cycle and Thread States),所以 Java 系统一般将这两个状态统称为 RUNNABLE(运行中) 状态 。

RUNNABLE-VS-RUNNING

当线程执行 wait()方法之后,线程进入 WAITING(等待)状态。进入等待状态的线程需要依靠其他线程的通知才能够返回到运行状态,而 TIME_WAITING(超时等待) 状态相当于在等待状态的基础上增加了超时限制,比如通过 sleep(long millis)方法或 wait(long millis)方法可以将 Java 线程置于 TIMED WAITING 状态。当超时时间到达后 Java 线程将会返回到 RUNNABLE 状态。当线程调用同步方法时,在没有获取到锁的情况下,线程将会进入到 BLOCKED(阻塞) 状态。线程在执行 Runnable 的run()方法之后将会进入到 TERMINATED(终止) 状态。

3.4. 文件与 I\O 流

3.4.1. Java 中 IO 流分为几种?

  • 按照流的流向分,可以分为输入流和输出流;
  • 按照操作单元划分,可以划分为字节流和字符流;
  • 按照流的角色划分为节点流和处理流。

Java Io 流共涉及 40 多个类,这些类看上去很杂乱,但实际上很有规则,而且彼此之间存在非常紧密的联系, Java I0 流的 40 多个类都是从如下 4 个抽象类基类中派生出来的。

  • InputStream/Reader: 所有的输入流的基类,前者是字节输入流,后者是字符输入流。
  • OutputStream/Writer: 所有输出流的基类,前者是字节输出流,后者是字符输出流。

按操作方式分类结构图:

IO-操作方式分类

按操作对象分类结构图:

IO-操作对象分类

3.4.1.1. 既然有了字节流,为什么还要有字符流?

问题本质想问:不管是文件读写还是网络发送接收,信息的最小存储单元都是字节,那为什么 I/O 流操作要分为字节流操作和字符流操作呢?

回答:字符流是由 Java 虚拟机将字节转换得到的,问题就出在这个过程还算是非常耗时,并且,如果我们不知道编码类型就很容易出现乱码问题。所以, I/O 流就干脆提供了一个直接操作字符的接口,方便我们平时对字符进行流操作。如果音频文件、图片等媒体文件用字节流比较好,如果涉及到字符的话使用字符流比较好。

3.4.1.2. BIO,NIO,AIO 有什么区别?
  • BIO (Blocking I/O): 同步阻塞 I/O 模式,数据的读取写入必须阻塞在一个线程内等待其完成。在活动连接数不是特别高(小于单机 1000)的情况下,这种模型是比较不错的,可以让每一个连接专注于自己的 I/O 并且编程模型简单,也不用过多考虑系统的过载、限流等问题。线程池本身就是一个天然的漏斗,可以缓冲一些系统处理不了的连接或请求。但是,当面对十万甚至百万级连接的时候,传统的 BIO 模型是无能为力的。因此,我们需要一种更高效的 I/O 处理模型来应对更高的并发量。
  • NIO (Non-blocking/New I/O): NIO 是一种同步非阻塞的 I/O 模型,在 Java 1.4 中引入了 NIO 框架,对应 java.nio 包,提供了 Channel , Selector,Buffer 等抽象。NIO 中的 N 可以理解为 Non-blocking,不单纯是 New。它支持面向缓冲的,基于通道的 I/O 操作方法。 NIO 提供了与传统 BIO 模型中的 SocketServerSocket 相对应的 SocketChannelServerSocketChannel 两种不同的套接字通道实现,两种通道都支持阻塞和非阻塞两种模式。阻塞模式使用就像传统中的支持一样,比较简单,但是性能和可靠性都不好;非阻塞模式正好与之相反。对于低负载、低并发的应用程序,可以使用同步阻塞 I/O 来提升开发速率和更好的维护性;对于高负载、高并发的(网络)应用,应使用 NIO 的非阻塞模式来开发
  • AIO (Asynchronous I/O): AIO 也就是 NIO 2。在 Java 7 中引入了 NIO 的改进版 NIO 2,它是异步非阻塞的 IO 模型。异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。AIO 是异步 IO 的缩写,虽然 NIO 在网络操作中,提供了非阻塞的方法,但是 NIO 的 IO 行为还是同步的。对于 NIO 来说,我们的业务线程是在 IO 操作准备好时,得到通知,接着就由这个线程自行进行 IO 操作,IO 操作本身是同步的。查阅网上相关资料,我发现就目前来说 AIO 的应用还不是很广泛,Netty 之前也尝试使用过 AIO,不过又放弃了。

4. 参考

· 2 分钟阅读
CheverJohn

1.HashMap不是线程安全的。

HashMap是Map接口的子类,是将键映射到值的对象,其中键和值都是对象,并且不能包含重复键,但可以包含重复值。

HashMap允许nullkey和null value,而hashtable不允许。

2.HashTable是线程安全的

HashMap是Hashtable的轻量级实现(非线程安全的实现),他们都完成了Map接口,主要区别在于HashMap允许空键值,由于非线程安全,效率上可能高于Hashtable。

HashMap允许将null作为一个entry的key或者value,而Hashtable不允许。

HashMap把Hashtable的contains方法去掉了,改成containsvalue和containsKey。因为contains方法容易让人引起误解。

最大的不同是,Hashtable的方法是Synchronize的,而HashMap不是,在多个线程访问Hashtable时,不需要自己为它的方法实现同步,而HashMap就必须要为止提供外同步了。

Hashtable和HashMap采用的hash/rehash算法大致都一样,所以性能上不会有很大的差别。

· 20 分钟阅读
CheverJohn

序言

这个我随手整到的一个知识点,确实很值得深挖,我在这其中得到的知识点也远大于其本身,这一点知识让我明白了任何小知识都得深挖源码,收获太大了。

首先感谢一下带给我思路的知乎答者:https://www.zhihu.com/question/28414001

下面正式开始我的表演

首先(看结果)

以一段代码开始

package cn.mr8god.kchaptereleven;

import java.util.HashSet;
import java.util.Random;
import java.util.Set;

/**
* @author Mr8god
* @date 2020/4/22
* @time 20:37
*/
public class SetOfInteger {
public static void main(String[] args) {
Random rnd = new Random(47);
Set<Integer> intset = new HashSet<Integer>();
for (int i = 0; i < 10000; i++){
intset.add(rnd.nextInt(30));
}
System.out.println(intset);
}
}

这是一段平平无奇的,展示HashSet魅力的一段代码。代码的意思也很简单,就是让随机生成的0~29的数字存入我们的Set当中去。这里边随机了10000次,就意味着有很多很多重复的数字,当然我们大Set家族绝对不会包容两个一模一样的玩意儿,所以输出结果很是理想(单指这方面的理想),它就输出了这三十个数字

[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29]

但是呢,结果却好像有点违背了我们所讲的“无序”,这明明是有顺序的一个一个排着输出的呀,啷个嘞就给我说成“无序”了呢?顺便展示一下概念

HashSet:一种没有重复元素的无序集合

我先来回答这个问题:我们一般所说的HashSet是无序的,但是它既不能保证存储和取出顺序一致, 更不能保证自然顺序一致(按照a-z)

顺道一提,我《Thinking in Java》书本中的输出是这样的

 [15, 8, 23, 16, 7, 22, 9, 21, 6, 1 , 29 , 14, 24, 4, 19, 26, 11, 18, 3, 12, 27, 17, 2, 13, 28, 20, 25, 10, 5, 0]

现在是不是就很奇妙了,为啥我同一段代码运行出来会是两个不同的结果,一个“有序”,一个“无序“。其实按照我江某人在C++中的经验来看,这很有可能就是语言版本的问题,这边很有可能就是JDK版本的问题,于是我们来分析源码验证我的思路

然后(分析源码)

我们首先从程序的第一步——集合元素的存储开始看起,先看HashSetadd方法的源码:

// HashSet 源码节选-JKD8
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}

我们可以看到,HashSet直接调用的是HashMapput方法,并且将元素e放到mapkey位置(保证了唯一性)

顺着线索继续查看我们的HashMapput方法源码:

//HashMap 源码节选-JDK8
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}

而我们的值在返回前需要经过HashMap中的hash方法

接着定位到hash方法的源码

static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

hash方法的返回结果中是一句三目运算符,键(key)为null即返回0,存在则返回后一句的内容

(h = key.hashCode()) ^ (h >>> 16)

重点来了,这个东西叫做“扰动函数

这个时候断一下我们再来分析一下

hashCodeObject类中的一个方法,在子类中一般会被重写,而根据我们之前自己给出的程序,暂以Integer类型为例,我们来看一下IntegerhashCode方法的源码

    /**
* Returns a hash code for this {@code Integer}.
*
* @return a hash code value for this object, equal to the
* primitive {@code int} value represented by this
* {@code Integer} object.
*/
@Override
public int hashCode() {
return Integer.hashCode(value);
}

/**
* Returns a hash code for a {@code int} value; compatible with
* {@code Integer.hashCode()}.
*
* @param value the value to hash
* @since 1.8
*
* @return a hash code value for a {@code int} value.
*/
public static int hashCode(int value) {
return value;
}

果然,不出所料,这边真的重写hashCode了,IntegerhasCode方法的返回值就是这个数本身

注释:其实整数的值因为与整数本身一样唯一,所以它也是一个足够好的散列值呢

这上面得出的结论就是,下面的A式B式是等价的

A:(h = key.hashCode()) ^ (h >>> 16)
B:key ^ (h >>> 16)

over,继续回归正途,接下来就是直接进行位运算层面了

接着(转攻计算散列值——位运算开始了)

首先不急,先理清思路,这个时候

HashSet因为底层使用了哈希表(链表结合数组)实现,存储key时可以通过一系列运算后得出自己在数组中所处的位置。

我们在hashCode方法中返回到了一个等同于本身值的散列值(证明过程如“然后(分析源码)”中的“这个时候断一下我们再来分析一下”可见)。

但是呢,考虑到int类型数据的范围:-2147483648~2147483647,很明显,这些散列值不能够直接使用,因为内存是没有办法放得下一个40亿长度的数组的。所以它使用了对数组长度进行取模运算的解决方法,得余后再作为其数组下标。

JDK7中,这个被称为indexFor()的方法就是用来做这个的。 在JDK8中,就是一句代码,其实和JDK7的一样

//JDK8中
(tab.length - 1) & hash;
//JDK7中 
bucketIndex = indexFor(hash, table.length);
static int indexFor(int h, int length) {
return h & (length - 1);
}

顺道加一句,为什么我们取模运算不用%而用&呢?因为位运算直接对内存数据进行操作,不需要转成十进制,因此处理速度会非常快,这样就导致了位运算&效率要比取模运算%高很多。

看到这边我们就知道了,存储时key的下标位置是需要通过hash方法和indexFor()JDK8的类indexFor()运算得来的。

正式开始我们的位运算(&)

我们开始举个例子了

HashMap中初始长度为16,length - 1 = 15;其二进制表示为 00000000 00000000 00000000 00001111

而与运算计算方式为:遇0则0,我们随便举一个key

    1111 1111 1010 0101 1111 0000 0011 1100 
& 0000 0000 0000 0000 0000 0000 0000 1111
----------------------------------------------------
0000 0000 0000 0000 0000 0000 0000 1100

上面随便举的key值就是:1111 1111 1010 0101 1111 0000 0011 1100

我们将这32位从中分开,左边16位称作高位,右边16位称作低位,可以看到经过&运算后 结果就是高位全部归0,剩下了低位的最后四位。但是问题就来了,我们按照当前初始长度为默认的16,HashCode值为下图两个,可以看到,在不经过扰动计算时,只进行与(&)运算后 Index值均为 12 这也就导致了哈希冲突

需要的.jpg

哈希冲突的简单理解:计划把一个对象插入到散列表(哈希表)中,但是发现这个位置已经被别人的对象给占据了

例子中,两个不同的HashCode值却经过运算后,得到了相同的值,也就代表,他们都需要被放在下标为2的位置

一般来说,如果数据分布比较广泛,而且存储数据的数组长度比较大,那么哈希冲突就会比较少,否则很高。

但是,如果像上例中只取最后几位的时候,这可不是什么好事,即使我的数据分布很散乱,但是哈希冲突仍然会很严重。

别忘了,我们的扰动函数还在前面搁着呢,这个时候它就要发挥强大的作用了,还是使用上面两个发生了哈希冲突的数据,这一次我们加入扰动函数再进行与(&)运算

对哈希冲突的解决2.jpg

补充 :>>> 按位右移补零操作符,左操作数的值按右操作数指定的为主右移,移动得到的空位以零填充 ^ 位异或运算,相同则0,不同则1

可以看到,本发生了哈希冲突的两组数据,经过扰动函数处理后,数值变得不再一样了,也就避免了冲突

其实在扰动函数中,将数据右位移16位,哈希码的高位和低位混合了起来,这也正解决了前面所讲 高位归0,计算只依赖低位最后几位的情况, 这使得高位的一些特征也对低位产生了影响,使得低位的随机性加强,能更好的避免冲突

再然后

到了这里,我们一步步研究到了这一些知识

HashSet add() → HashMap put() → HashMap hash() → HashMap (tab.length - 1) & hash;

有了这些知识的铺垫,我对于刚开始自己举的例子又产生了一些疑惑,我使用for循环添加一些整型元素进入集合,难道就没有任何一个发生哈希冲突吗,为什么遍历结果是有序输出的,经过简单计算 2 和18这两个值就都是2

//key = 2,(length -1) = 15 

h = key.hashCode() 0000 0000 0000 0000 0000 0000 0000 0010
h >>> 16 0000 0000 0000 0000 0000 0000 0000 0000
hash = h^(h >>> 16) 0000 0000 0000 0000 0000 0000 0000 0010
(tab.length-1)&hash 0000 0000 0000 0000 0000 0000 0000 1111
0000 0000 0000 0000 0000 0000 0000 0010
-------------------------------------------------------------
0000 0000 0000 0000 0000 0000 0000 0010

//2的十进制结果:2
//key = 18,(length -1) = 15

h = key.hashCode() 0000 0000 0000 0000 0000 0000 0001 0010
h >>> 16 0000 0000 0000 0000 0000 0000 0000 0000
hash = h^(h >>> 16) 0000 0000 0000 0000 0000 0000 0001 0010
(tab.length-1)&hash 0000 0000 0000 0000 0000 0000 0000 1111
0000 0000 0000 0000 0000 0000 0000 0010
-------------------------------------------------------------
0000 0000 0000 0000 0000 0000 0000 0010

//18的十进制结果:2

按照我们上面的知识,按理应该输出 1 2 18 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 但却仍有序输出了

这就非常苦恼了,不过我发现了一个有趣的现象,当我的代码如下时

package cn.mr8god.kchaptereleven;

import java.util.HashSet;
import java.util.Random;
import java.util.Set;

/**
* @author Mr8god
* @date 2020/4/22
* @time 20:37
*/
public class SetOfInteger {
public static void main(String[] args) {
Random rnd = new Random(47);
Set<Integer> intset = new HashSet<Integer>();
for (int i = 0; i < 3; i++){
intset.add(rnd.nextInt(30));
}
System.out.println(intset);
}
}

输出结果是

[5, 8, 13]

于是我将问题的核心转为了数组长度问题,这个就是最终的大Boss了

最后(大Boss——数组长度)

继续找到HashMap源码,我发现了一个有趣的东西

    /**
* The maximum capacity, used if a higher value is implicitly specified
* by either of the constructors with arguments.
* MUST be a power of two <= 1<<30.
*/
static final int MAXIMUM_CAPACITY = 1 << 30;

/**
* The load factor used when none specified in constructor.
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;

<< :按位左移运算符,做操作数按位左移右错作数指定的位数,即左边最高位丢弃,右边补齐0,计算的简便方法就是:把 << 左面的数据乘以2的移动次幂 为什么初始长度为16:1 << 4 即 1 * 2 ^4 =16;

这里边有一个叫做加载因子的东西,他默认值为0.75f,这是什么意思呢,我们来补充一点它的知识:

加载因子就是表示哈希表中元素填满的程度,当表中元素过多,超过加载因子的值时,哈希表会自动扩容,一般是一倍,这种行为可以称作rehashing(再哈希)。 加载因子的值设置的越大,添加的元素就会越多,确实空间利用率的到了很大的提升,但是毫无疑问,就面临着哈希冲突的可能性增大,反之,空间利用率造成了浪费,但哈希冲突也减少了,所以我们希望在空间利用率与哈希冲突之间找到一种我们所能接受的平衡,经过一些试验,定在了0.75f

现在可以解决我们上面的疑惑了

数组初始的实际长度 = 16 * 0.75 = 12

这代表当我们元素数量增加到12以上时就会发生扩容,当我们上例中for循环添加0-18, 这19个元素时,先保存到前12个到第十三个元素时,超过加载因子,导致数组发生了一次扩容,而扩容以后对应与(&)运算的(tab.length-1)就发生了变化,从16-1 变成了 32-1 即31

我们来算一下

//key = 2,(length -1) = 31 
h = key.hashCode() 0000 0000 0000 0000 0000 0000 0001 0010
h >>> 16 0000 0000 0000 0000 0000 0000 0000 0000
hash = h^(h >>> 16) 0000 0000 0000 0000 0000 0000 0001 0010
(tab.length-1)&hash 0000 0000 0000 0000 0000 0000 0011 1111
0000 0000 0000 0000 0000 0000 0000 0010
-------------------------------------------------------------
0000 0000 0000 0000 0000 0000 0000 0010

//十进制结果:2
//key = 18,(length -1) = 31 
h = key.hashCode() 0000 0000 0000 0000 0000 0000 0001 0010
h >>> 16 0000 0000 0000 0000 0000 0000 0000 0000
hash = h^(h >>> 16) 0000 0000 0000 0000 0000 0000 0001 0010
(tab.length-1)&hash 0000 0000 0000 0000 0000 0000 0011 1111
0000 0000 0000 0000 0000 0000 0000 0010
-------------------------------------------------------------
0000 0000 0000 0000 0000 0000 0001 0010

//十进制结果:18

当length - 1 的值发生改变的时候,18的值也变成了本身。

到这里,才意识到自己之前用2和18计算时 均使用了 length -1 的值为 15是错误的,当时并不清楚加载因子及它的扩容机制,这才是导致提出有问题疑惑的根本原因。

总结

JDK7JDK8,其内部发生了一些变化,导致在不同版本JDK下运行结果不同,根据上面的分析,我们从HashSet追溯到HashMaphash算法、加载因子和默认长度。

由于我们所创建的HashSetInteger类型的,这也是最巧的一点,Integer类型hashCode()的返回值就是其int值本身,而存储的时候元素通过一些运算后会得出自己在数组中所处的位置。由于在这一步,其本身即下标(只考虑这一步),其实已经实现了排序功能,由于int类型范围太广,内存放不下,所以对其进行取模运算,为了减少哈希冲突,又在取模前进行了,扰动函数的计算,得到的数作为元素下标,按照JDK8下的hash算法,以及load factor及扩容机制,这就导致数据在经过 HashMap.hash()运算后仍然是自己本身的值,且没有发生哈希冲突。

补充:对于有序无序的理解

集合所说的序,是指元素存入集合的顺序,当元素存储顺序和取出顺序一致时就是有序,否则就是无序。

并不是说存储数据的时候无序,没有规则,当我们不论使用for循环随机数添加元素的时候,还是for循环有序添加元素的时候,最后遍历输出的结果均为按照值的大小排序输出,随机添加元素,但结果仍有序输出,这就对照着上面那句,存储顺序和取出顺序是不一致的,所以我们说HashSet是无序的,虽然我们按照123的顺序添加元素,结果虽然仍为123,但这只是一种巧合而已。

所以HashSet只是不保证有序,并不是保证无序

· 8 分钟阅读
CheverJohn

自动装箱与拆箱的定义

装箱就是自动将基本数据类型转换为包装器类型;拆箱就是,自动将包装器类型转换为基本数据类型

Java中的数据类型分为两类:一类是基本数据类型,另一类是引用数据类型

基本数据类型的分类.jpg

简单类型二进制位数封装器类
int32Integer
byte8Byte
long64Long
float32Float
double64double
char16Character
boolean1Boolean

上自动装箱代码

public static void main(String[] args) {
// TODO Auto-generated method stub
int a=3;
//定义一个基本数据类型的变量a赋值3
Integer b=a;
//b是Integer 类定义的对象,直接用int 类型的a赋值
System.out.println(b);
//打印结果为3
}

上面代码中的Integer b = a;就是我们所说的自动装箱的过程,上面代码在执行的时候调用了Integer.valueOf(int i)方法简化后的代码:

public static Integer valueOf(int i) {       
if (i >= -128 && i <= 127)
return IntegerCache.cache[i + 127];
//如果i的值大于-128小于127则返回一个缓冲区中的一个Integer对象
return new Integer(i);
//否则返回 new 一个Integer 对象
}

可以看到Integer.valueOf(a)其实是返回了一个Integer的对象。因此由于自动装箱的存在Integer b = a这段代码是没有问题的,并且我们可以简化的来这样写:Integer b = 3;

同样也等价于这样写:Integer b = Integer.valueOf(3)。

上自动拆箱代码

public static void main(String[] args) {
// TODO Auto-generated method stub

Integer b=new Integer(3);
//b为Integer的对象
int a=b;
//a为一个int的基本数据类型
System.out.println(a);
//打印输出3。
}

上面有一个:int a = b; 代码中把一个对象赋给了基本类型。其实这就等于int a = b.intValue()。

根据源码中可知道intValue是什么

public int intValue() {
return value;
}

这个方法就是返回了value值嘛,但是这里的value又是怎么一回事呢?继续找源码:

public Integer(int value) {
this.value = value;
}

原来这里的value就是,Integer后边括号里的值呀,于是我们的拆箱代码其实本质上是这样写的:

public static void main(String[] args) {
// TODO Auto-generated method stub

Integer b=new Integer(3);
//b为Integer的对象
int a=b.intValue();
//其中b.intValue()返回实例化b时构造函数new Integer(3);赋的值3。
System.out.println(a);
//打印输出3。
}

范围概念

这里边是一个挺重要的知识点,至少我之前看的疯狂Java视频资料,以及我看的《Java编程思想》这本书,都有这方面的介绍。先看一个代码哈:

public static void main(String[] args) {
Integer a = 1000,b=1000;
Integer c=100,d=100;
System.out.println(a==b);
System.out.println(c==d);
}

原本我会以为是输出的是:true true啦,但是实际上不对,正确答案是false true。为甚呢?细细道来。

public static void main(String[] args) {        
//1
Integer a=new Integer(123);
Integer b=new Integer(123);
System.out.println(a==b);//输出 false

//2
Integer c=123;
Integer d=123;
System.out.println(c==d);//输出 true

//3
Integer e=129;
Integer f=129;
System.out.println(e==f);//输出 false
//4
int g=59;
Integer h=new Integer(59);
System.out.println(g==h);//输出 true
}

常量池.jpg

第一部分输出false,很好理解,因为比较的是堆中指向的对象是不是同一个嘛,a,b是栈中对象的引用分别指向堆中的两个不同的对象。而a==b这条语句就是判断a、b在堆中指向的对象是不是统一个,因此输出为false。

第二部分输出true也很好理解,正是用了我们的自动装箱技术

我带大家这次仔细的看自动装箱的源码

public static Integer valueOf(int i) {       
if (i >= -128 && i <= 127)
return IntegerCache.cache[i + 127];
//如果i的值大于-128小于127则返回一个缓冲区中的一个Integer对象
return new Integer(i);
//否则返回 new 一个Integer 对象
}

上面的代码中:IntegerCache.cache[i + 127]; 表示狠眼生,继续看代码:

 private static class IntegerCache {

static final Integer cache[];
//定义一个Integer类型的数组且数组不可变
static {
//利用静态代码块对数组进行初始化。
cache = new Integer[256];
int j = -128;
for(int k = 0; k < cache.length; k++)
cache[k] = new Integer(j++);
}

//cache[]原来是一个Integer 类型的数组(也可以称为常量池),value 从-128到127,
public static Integer valueOf(int i) {
if (i >=-128 && i <= 127)
return IntegerCache.cache[i + (-IntegerCache.low)];
//如果装箱时值在-128到127之间,之间返回常量池中的已经初始化后的Integer对象。
return new Integer(i);
//否则返回一个新的对象。
}
}

原来IntegerCache类在初始化的时候,生成了一个大小为256的integer类型的常量池,并且integer.val的值从-128~127,当我们运行Integer c = a(临时做的一个小栗子哈)的时候,如果-128 <= a <= 127,就不会再生成新的integer对象。于是我们第二部分的c和d指向的是同一个对象,所以比较的时候是相等的,所以我们输出true。

第三部分,理解如第二部分

第四部分:代码中g指向的是栈中的变量,h指向的是堆中的对象,但是我们的g == h为什么还是true呢?这就是自动插箱干的好事,g == h这代码执行的时候就是:g == h.IntValue(),而h.IntValue()=59,所以两边其实是两个int在比较而已。

总结

简单一句话:

装箱就是自动将基本数据类型转换为包装器类型;

拆箱就是自动将包装器类型转换为基本数据类型。

· 11 分钟阅读
CheverJohn

本文灵感来源于知乎文章(https://zhuanlan.zhihu.com/p/62779357) 以及《Java编程思想》P85页

使用this关键字之前

Java提供了一个叫做this关键字,this关键字总是指向调用该方法的对象。根据this出现的位置不同,this作为对象的默认引用有两种情况。

  1. 构造器中引用该构造器正在初始化的对象
  2. 在方法中引用调用该方法的对象

在方法中引用调用该方法的对象

代码示例

那我们直接上了概念,肯定是不能够被大家理解的哈,我们转念换个角度来想一想,我们如果没有this关键字,会面临一个什么样子的情况呢?

public class Person {
//定义一个move()方法
public void move(){
System.out.println("正在执行move()方法");
}
//定义一个eat()方法,eat()方法需要借助move()方法
public void eat(){
Person p = new Person();
p.move();
System.out.println("正在执行eat()方法");
}
public static void main(String[] args) {
//创建Person对象
Person p = new Person();
//调用Person的eat()方法
p.eat();
}
}
// 代码来源于:https://zhuanlan.zhihu.com/p/62779357

运行结果为:

正在执行move()方法
正在执行eat()方法

代码讲解

上述的方式确实能够做到eat()方法里调用move()方法,但是我们在main()方法里可以看到我们总共创建了两个对象:main()方法里创建了一个对象;eat()方法里创建了一个对象。但是实际上我们是不需要两个对象的,因为在程序调用第一个eat()方法时一定会提供一个Person对象,而不需要重新创建一个Person了。

因此我们可以通过this关键字在eat()方法中获得调用该方法的对象。this关键字只能在方法内部使用,表示对”调用方法的那个对象“的引用。

于是上面的代码Person类中的eat()方法改为下面这种方式较为合适:

//定义一个eat()方法,eat()方法需要借助move()方法
public void eat(){
//使用this引用调用eat()方法的对象
this.move();
System.out.println("正在执行eat()方法");
}

不过呢,虽然接下来要说的,可能会让读者同学感觉我是在耍你哈。但是可不是哦,上面这么多我只是做个引子而已,用来引导大家的。我先说我要说的知识点吧

this关键字的用法和其他对象引用并无不同。但是如果要在方法内部调用同一个类的另一个方法,就不必使用this,直接调用即可。当前方法的this引用会自动应用用途同一类中的其他方法。所以上述代码也可以这样写:

//定义一个eat()方法,eat()方法需要借助move()方法
public void eat(){
move();
System.out.println("正在执行eat()方法");
}

整体代码可以如下

package cn.mr8god.example;

/**
* @author Mr8god
* @date 2020/4/14
* @time 16:09
*/

public class Person {
public void move(){
System.out.println("正在执行move()方法");
}

public void eat(){
move();
System.out.println("正在执行eat()方法");
}

public static void main(String[] args) {
Person p = new Person();
p.eat();
}
}

暂时的小总结

​ 在eat()方法内部,你可以写this.move(),但无此必要。编译器能够帮你自动添加。只有当明确指出对当前对象的引用时,才需要使用this关键字。例如,当需要返回对当前对象的引用的时候,就常常在return语句里这样写:

package cn.mr8god.chapterfive;

/**
* @author Mr8god
* @date 2020/4/14
* @time 11:18
*/
public class Leaf {
int i = 0;
Leaf increment(){
i++;
return this;
}
void print(){
System.out.println("i = " + i);
}

public static void main(String[] args) {
Leaf x = new Leaf();
x.increment().increment().increment().print();
}
}

代码中,由于increment()通过this关键字返回了对当前对象的引用,所以很容易就可以在一条语句里对同一个对象执行多次操作。

this关键字对于将当前对象传递给其他方法也很有用

package cn.mr8god.chapterfive;

/**
* @author Mr8god
* @date 2020/4/14
* @time 11:21
*/

class Person{
public void eat(Apple apple){
Apple peeled = apple.getPeeled();
System.out.println("Yummy");
}
}

class Peeler{
static Apple peel(Apple apple){
return apple;
}
}
class Apple{
Apple getPeeled(){ return Peeler.peel(this); }
}
public class PassingThis {
public static void main(String[] args) {
new Person().eat(new Apple());
}

}

Apple需要调用Peeler.peel()方法,这个方法是一个外部的工具方法,将执行由于某种原因而必须放在Apple外部的操作。为了将其自身传递给外部方法,Apple必须使用this关键字。

在构造器中调用构造器

另一种情形是:this关键字可以用于构造器中作为默认引用,由于构造器是直接使用new关键字来调用的,而不是使用对象来调用的,所以this在构造器中代表该构造器正在初始化的对象。

例一

package cn.mr8god.example;

/**
* @author Mr8god
* @date 2020/4/14
* @time 17:05
*/
public class Person {
public int age;
public Person(){
int age = 0;
this.age = 3;
}

public static void main(String[] args) {
System.out.println(new Person().age);
}
}

与普通方法类似,大部分时候,我们在构造器中访问其他成员变量和方法时都可以省略this前缀,但是如果构造器中有一个与成员变量同名的局部变量,又必须在构造器中访问这个被覆盖的成员变量,则必须使用this前缀。正如上面的程序所示。

this作为对象的默认引用使用时,程序可以像访问普通引用变量一样来访问这个this引用,甚至可以把this当成普通方法的返回值。如下面的程序

public class Person {
public int age;
public Person grow() {
age ++;
return this;
}
public static void main(String[] args) {
Person p = new Person();
//可以连续调用同一个方法
p.grow().grow().grow();
System.out.println("p对象的age的值是:"+p.age);
}
}

运行结果为:

p对象的age的值是:3

上面的代码中可以看到,如果在某个方法中把this作为返回值,则可以多次连续调用同一个方法,从而使得代码变得更加的简洁。

例二

有时候为一个类写了多个构造器,我们可能想在一个构造器中调用另一个构造器,以避免重复代码。可以使用this关键字做到这一点。

package cn.mr8god.chapterfive;

import static net.mindview.util.Print.print;

/**
* @author Mr8god
* @date 2020/4/14
* @time 11:31
*/
public class Flower {
int petalCount = 0;
String s = "initial value";
Flower(int petals){
petalCount = petals;
print("Constructor w/ int arg only, petalCount= "
+ petalCount);
}
Flower(String ss){
print("Constructor w/ String arg only, s = " + ss);
s = ss;
}
Flower(String s, int petals){
this(petals);
this.s = s;
print("String & int args");
}
Flower(){
this("hi", 47);
print("default constructor (no args)");
}
void printPetalCount(){
print("petalCount = " + petalCount + " s = " + s);
}

public static void main(String[] args) {
Flower x = new Flower("江晨玥",222);
// Flower x = new Flower();
x.printPetalCount();
}
}

这串代码如果给初学者看的话,会有一点不明确的地方,毕竟代码太长了嘛,这边给个建议,就只看我们的this指针部分哦!

然后代码中,我选择初始化了一个Flower("江晨玥", 222)的方法,首先这行代码会被用到上面的Flower(String s, int petals)里边,很好的实现了方法的重载嘛。然后我们就可以直观地看到这边,我讲解几个可能会有疑问的地方哈

可能会有疑问一

这边

Flower(String s, int petals){
this(petals);
this.s = s;
print("String & int args");
}

代码中的this(petals)到底是怎么一回事,其实有这个疑问还是要算你的this关键字没有理解到家,this其实在这里指的是Flower,相当于我在这个Flower(String s, int petals)里引用了Flower(int petals)方法。

可能会有疑问二

这边的this.s = s,可能也会有疑问,其实这个也展示了this的另外一种用法。由于参数s的名称和数据成员s的名字相同,所以会很容易产生歧义。使用this.s就可以来代表数据成员解决这个问题。Java日常编程经常会这样的哦

讲解一

printPetalCount()

方法表明,除构造器之外,编译器禁止在其他任何方法中调用构造器,不信?你可以试试!

Over!

· 23 分钟阅读
CheverJohn

Java是一种面向对象编程(OOP)的语言。 掌握Java需要付出的代价就是,思考对象的时候,需要采用 形象思维(一种抽象思维),而不是程序化的思维。 特别是在尝试创建可重复使用(可再生) 的对象的时候,我们都会面临着一项痛苦的抉择。 事实上,正是由于这样的特性,很难有人能够设计出完美的东西,只有一些Java的编程专家才能编写出可以让大多数人使用的代码,而我江某人学习编程的目的就在于此,成为编程专家。

初章主要是描述了Java的多项设计思想,并从概念上解释面向对象的程序设计。

抽象概念的由来

抽象方法是怎么出现的呢?

​ 起因是我们在解决实际问题的时候发现每一类问题都有其自己的特征。而我们在C或一切其他语言中学到的都只是根据一类问题设计一套方法来解决它。以至于当超出这个问题的时候,方法就会显得特别笨拙。 ​ 面向对象的程序设计就是在以上基础跨出一大步。我们利用一些概念去描述表达实际问题中的元素。我们利用“对象”这个概念建立起实际问题和方法之间的联系。如果一些问题在后期出现了更多的问题,我们就可以相应的在代码中加入其它对象 通过添加新的对象类型,程序可以灵活的进行调整。与特定的问题打配合。从而达到解决问题的目的。 ​ 毫无疑问,面向对象程序设计语言是一门灵活、强大的语言抽象方法。它允许我们根据问题来描述问题,而不是单纯地根据方案。

OOP面向对象程序设计的特征

通过上面讲述的这些特征,我们可以理解“纯粹”的面向对象程序设计方法是什么样子的: (1) 所有东西都是对象。可以将对象想象成一种新型变量;它保存着数据,但可要求它对它自身进行一些操作,比如说增加点方法,增加点变量。理论上来讲,我们可以从问题中找出所有概念性的东西,然后在我们的程序中将其表达为一个对象。

(2) 程序将会是一大堆对象的组合;通过对象与对象之间的消息传递(传参),各个对象都知道自己该干什么,不该干什么。为了向另外一个对象发出请求,就需要向那个对象发送消息。具体来讲,我们可以将消息想象为一个调用请求,它调用的是从属于目标对象的一个子例程或函数。

(3) 每个对象都有自己的存储空间,可以容纳其他对象。或者是通过封装现有对象,进而制造出新的对象。所以尽管先前我们讲的对象看起来很简单,其实在每一个程序中,这些概念都能上升到一个任意高的复杂程度。

(4) 每个对象都有一种类型。根据Java的基本语法,每个对象都是某一个“类”的一个“实例”。而不同类与类之间的区别是什么呢?我抽象地讲,是“能将什么消息发给它?

(5) 同一类的所有对象都能接收相同的消息。举例子举例子,这边有圆(Circle)、形状(Shape)两个类。由于圆其实也是形状嘛,我可以这样说,圆(Circle)的一个对象也属于类型为形状(Shape)的一个对象,所以一个圆能够完全接收来自形状(Shape)的任意消息。这就意味着我们可以让程序代码统一指挥形状(Shape),令其自动控制所有符合形状(Shape)描述的对象,其中自然包括圆(Circle)类的那个对象。这一特性叫做对象的”可替换性“,是OOP最重要的概念之一。

对象的接口

上头我已经为大家引入了类与对象的概念,其实很好理解。类就相当于一样东西,比如说程序员就是一类。而对象呢,按照程序员类来说,这边的对象就是具体的一个程序员,比如说我江某人,就是一个程序员类的对象。

​ 每一个对象都隶属于一个特定的“类”,那个类具有自己的通用特征与行为。

​ 我们该如何让对象完成真正有用的工作呢?比如说让我江某人程序员对象完成一个C++的代码工作。我们可以在类中定义“接口”,对象的“类”就规定了它的接口形式。“类”和“接口”的等价或对应关系就是面向对象程序设计的基础。

​ 下面来一个图解

接口的讲解.jpg

在上面这个图解中,,类的名字叫做Light,我们可以向Light对象发出的请求包括有打开(on)、关闭(off)、变得更明亮(brighten)、变得更黯淡(dim)。我们可以简单地声明一个名字(lt),我们为Light对象创建了一个“句柄”(就是名字,咱们对象的名字,在这边名字就叫做lt),这里边lt,也就是咱们的句柄,指向了刚刚新建的对象。然后我们用new关键字新建类型为Light的一个对象。再用等号将其赋值给句柄。

​ 为了向对象发送一条消息,我们使用下面的格式来将句柄名、句点符号、和消息名称(on、off之类的)连接起来:

lt.on();

实现方案的隐藏

这部分我将谈谈我们为什么要隐藏我们的类成员,类中的方法

​ 首先我想就程序员的分类来讲,目前使用面向对象程序设计语言的程序员主要是分为两类的,一类是类的创建者,一类是类的使用者。前者制造出了包含各种使用的类包,后者会用前者的类包,解决各种问题。

​ 这个时候,我们就需要考虑一个问题了,类创建者创建的类包里不能所有东西都能被使用者调用呀。如果任何人都能使用一个类的所有成员,那么使用者就可以对那个类做出任何事情。即使是一些不能够给使用者使用的类内包含的一些成员。如若不能进行控制的话,就没有办法组织这一情况的发生。

为啥要控制类中成员的访问权限呢?

综上,我们有两方面的原因促使我们需要对类中成员的访问权限进行控制。 原因一:防止使用者程序员接触他们不该接触的东西——通常是一些内部数据类型的设计思想。若只是为了使用类包解决问题,用户只需要操作接口就行了,不需要明白这些信息。我们向用户提供的实际是一种服务。 原因二:允许类包设计人员修改内部结构,不用担心它对使用者程序员造成影响。假如我们(类设计程序员)最开始写了一个简单的类包,以便简化开发。以后又决定进行改写,使其更快地运行。若接口与实现方法早已经隔离开了,并分别受到保护,就可以放心做到这一点。

Java如何实现控制呢?

Java采用三个显式(明确)关键字以及一个隐式(暗示)关键字来设置类边界:public、private、protected 以及暗示性的friendly。若未明确指定其他关键字,则默认为后者。

解释这些关键字:

public(公共):意味着后续的定义,任何人均可使用。 private(私有):意味着除您自己、类型的创建者以及那个类型的内部函数成员之外,其他任何人都不能访问后续的定义信息。private在类创建者和类使用者之间竖起了一堵墙。若有人试图调用,便会在编译期报错。 friendly(友好的)涉及“包装”或“封装”(Package)的概念——即Java用来构建库的方法。若某样东西是“友好的”,意味着它只能在这个包装的范围内使用(所以这一访问级别有时也叫做“包装访问”) protected(受保护的):与“private”相似,只是一个继承的类就可以访问咱们的受保护成员,但是依旧不能访问私有成员。

方案的重复使用

创建并测试好一个类后,这个好不容易创建好的类其实往往有很多缺点。只有较多经验以及洞察力的人才能 设计出一个好的方案。

​ 为了重复使用一个类,最简单的方法就是仅直接使用那个类的对象。同时也将那个类的一个对象植入一个新类中。我们把这叫做“创建一个成员对象”。新类可以由任意数量和类型的其他对象构成。这个概念叫做“组织”——在现有类的基础上组织一个新类。有时组织也称为“包含”关系,比如“一辆车包含了一个变速箱”

​ 对象的组织具有极大的灵活性。新类的“成员对象”通常设为“私有”,使用这个类的使用者程序员不能访问它们。

继承:重新使用接口

当我们费尽心思做出一种数据类型之后,加入不得不又新建一种类型,令其实现大致相同的功能,那会是一件很麻烦的事情。但是若能利用已有的数据类型,对其进行“克隆模仿”,再根据实际情况进行添加或修改,那情况就会好多了。“继承”正是针对这个目标而设计的。但是继承并不完全等价于克隆。在继承的过程中,如果父类发生了变化,子类(继承后产生的新类)也会反映出这种变化。

在Java中继承是通过extends关键字实现的。使用继承时,相当于创建了一个新类。这个新类不仅包含了现有类型的所有成员(除了不能被访问的private成员)。其中最最重要的是它还复制了父类的接口。也就是说,能向父类发送的消息,亦可原样发给子类的对象。

由于父类和子类拥有相同的接口了,但是我们的子类不能一模一样呀,那样还跟父类有什么区别?为了做出区分,所以那个接口也必须进行特殊的设计。下面讲一下两种区分父类和子类的方法:

区分父类子类的方法

方法一:为子类添加新函数(功能)。这些新函数并非父类接口的一部分。为什么会有这种方法的出现呢?一般是因为我们发现父类原有的功能已经不能满足我们的需求了,于是我们就要添加更多的函数。这是一种最简单最基本的继承用法。

方法二:近看extends关键字看上去是让我们要为接口“扩展”新功能,但实情并非肯定得照办。为了区分我们的新类,第二个办法就是改变父类,“改善”父类。

改善父类

为了改善一个父类,我们无非就是改善父类中的函数(或者叫方法),那么我们相应的只需要在子类中的函数中建立一个新的定义就可以了。我们的目标是:”尽管使用的函数接口未变,但他的新版本具有不同的表现“,但是万物没有这么绝对,我们还有另外情况,这边引用两个概念:等价关系类似关系

等价关系:子类完全照搬父类的所有的东西 类似关系:我们在子类中新加入了新的东西,那是原来父类中没有的东西。新的子类依旧拥有旧的父类的接口,但也包含了其他一些新的东西。所以就变成了不是上面所说的那种“等价关系”。

举一个例子:假定我有一个房间,房间连好了用于制冷的各种控制装置,用程序员思维来看,就是说我们已经拥有了必要的“接口”来控制制冷。现在假设我们的制冷机坏掉了,于是我将它换成了一台新型的冷、热两用空调,冬天制热、夏天制冷嘛。冷热空调“类似“制冷机,但是能做更多的事情。但是呢,由于我们的房间只安装了控制制冷的设备”接口“,所以”接口“们只能同新机器的制冷部分打交道。新机器的接口已得到扩展,但现有的系统并不知情,也不能够接触除了原始接口以外的任何东西。

当我们明确了等价和类似两种概念之后,以后在面对情况的时候就可以合理选择了。

多形对象的互换使用

继承的结果往往会是创造了一系列的类,而这所有的类都是建立在统一的接口基础上的。如图

向上转型的形象描述.jpg

这边要讲一个很重要的概念了哈,我们一定要把子类的对象当做父类的对象来对待。这一点是非常重要的。这就意味着我们只需编写代码就行了,不需要注意类的特定细节,只与父类打交道。

根据图例我们可以看到通过集成,这边有三个子类。那么我们为三个子类新编写的代码也会像在父类中那样良好工作。所以说程序具备了“扩展能力”,具有扩展性。

假设我们新加了一个函数:

void doStuff(Shape s)}{
s.erase();
// 等等等等等
s.draw();
}

这样一个函数可以用途任何“几何形状”(Shape)通信,例如我这边又安排了一个代码:

Circle c = new Circle();
Triangle t = new Triangle();
Line l = new Line();
doStuff(c);
doStuff(t);
doStuff(l);

这边我就分析一下doStuff(c)这串代码的意思(事实就是:我确实后边安排了这个代码) 此时,一个Circle句柄传递给了一个本来期待Shape句柄的函数。但是由于咱们的圆也是一种几何形状,所以doStuff()能够正确地进行处理。也就是说,凡是doStuff()能发给一个Shape的消息,Circle也能接收。所以这样子写是正确的,不会有报错。

我们把这种生成子类的方法叫做向上转型。向上是因为继承的方向是从“上面”来的——即父类位于顶部,子类在下方展开。

注意了哦,doStuff()里面的代码,它并非是这样表达的:”如果你是一个Circle,就这样作;如果你是一个Square,就按照那样做;等等诸如此类“。若那样子写代码的话,得累死你,就需要检查Shape所有可能的类型,如圆、矩形、四边形等等等等。这显然是非常麻烦的,而且每次添加了一种新的Shape类型后,都要相应地进行修改,在这里,我们只需要这样做:”你是一种几何形状,我知道你能将自己删掉(即代码里面的erase()),请自己放手去干吧,并且自己去控制所有的细节吧。“

这边我写了三个代码有助于我们理解:

代码一(Shape父类):

package cn.mr8god.shape;

import static java.lang.System.*;

/**
* @author Mr8god
* @date 2020/4/1120:28
*/
public class Shape {
public void draw(){
out.println("我是父类中的draw()方法");
}

public void erase(){
out.println("我是父类中的erase()方法");
}

public void move(){
out.println("我是父类中的move()方法");
}

public void getColor(){
out.println("我是父类中的getColor()方法");
}

public void setColor(){
out.println("我是父类中的setColor()方法");
}

代码二(Circle继承类):

package cn.mr8god.shape;

import static java.lang.System.*;

/**
* @author Mr8god
* @date 2020/4/822:04
*/
public class Circle extends Shape{
void doStuff(Shape s){
s.erase();
out.println("我是来自子类Circle里边的doStuff方法。");
}

}

代码三(ShapeTest类):

package cn.mr8god.shape;


/**
* @ Mr8god
* @ 2020/4/11
*/
class ShapeTest {
public static void main(String[] args) {
Shape sh = new Shape();

sh.draw();
sh.erase();
sh.move();
sh.getColor();
sh.setColor();

Circle ci = new Circle();
ci.draw();
ci.erase();
ci.move();
ci.getColor();
ci.setColor();
ci.doStuff(ci);
}

}

自此输出:

向上转型代码的输出.jpg

如何实现控制访问

Java用三个关键字在类的内部设定边界:public、private、protected。这些访问指定词决定了紧跟其后被定义的东西可以被谁使用。

public:表示紧随其后的元素对任何人都是可用的

private:这个关键字表示除类型创建者和类型的内部方法之外的其他任何人都不能访问的元素。private就像你与使用类的程序员之间的一堵墙,如果有人试图访问private成员,就会在编译期间得到错误信息。

protected:这个关键字与private作用相当,差别仅在于继承的类可以访问protected成员,但是不能访问private成员。

· 13 分钟阅读
CheverJohn

Java内存区域

Java虚拟机运行时数据区.jpg

程序计数器

  1. 作用:当前线程所执行的字节码的行号指示器。
  2. 字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
  3. 存在的意义:Java虚拟机的多线程是通过线程轮流切换实现的,类似于操作系统的时间片算法来分配核心(处理器)的执行时间。所以,为了线程每一次切换后能够恢复到之前正确的执行位置,每个线程就需要各自独立的程序计数器。
  4. 如果线程此时正在执行的是一个Java方法,计数器记录的即是正在执行的虚拟机字节码指令的地址;如果正在执行的是Nativie方法,这个计数器即是空(Undefined)。

Java虚拟机栈(就是大家常说的栈内存)

虚拟机栈描述的是Java方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧,用于存储局部变量表、操作栈、动态链接、方法出口等信息。

每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

大部分程序员把Java的内存区域划分为堆内存(Heap)和栈内存(Stack),事实上Java内存区域的划分远比这复杂。其中所指的“堆”在后边会聊到,而所指的“栈”就是现在讲的虚拟机栈,或者说是虚拟机栈中的局部变量表部分。

局部变量表

局部变量表存放了编译期可知的8种基本数据类型(int、char、byte、short、long、double、boolean、float)、对象引用和returnAddress类型

这边插一句关于对象引用类型的分析解释:字如其意,是一种引用,就是C中的指针嘛,我们来看一个例子

int[] arr = new int[4];

=左边的arr是变量部分,=右边的是一个对象。

右边只是一个指向对象起始地址的引用指针。

左边的arr是变量,所以被存放在栈内存中。右边的是对象, 所以被存放在堆内存中,两者靠指针来维系关系。上面这句话应该这么来理解。

continue!继续聊局部变量表,8种基本数据类型里64位长度的long和double类型的数据会占用2个局部变量空间,其余的数据类型只占用1个。

局部变量表所需的内存空间在编译期间就完成了分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是早就确定好的,而且我们在方法运行期间不会改变 局部变量表的大小。

continue!继续聊虚拟机栈,我们聊报错

报错

有两种:

  1. StackOverflowError异常:如果线程请求的栈深度大于虚拟机所允许的深度
  2. OutOfMemoryError异常:当我们使用的虚拟机栈的内存不够时就会出现这个报错

本地方法栈

与虚拟机栈作用相似 ,区别在于虚拟机栈是为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用的Native方法服务。

甚至于有的虚拟机(比如Sun HotSpot虚拟机)直接将本地方法栈和虚拟机栈合二为一。

与虚拟机栈一样,本地方法栈区域也会抛出两种报错StackOverflowError和OutOfMemoryError。

Java堆

是Java虚拟机所管理的内存中最大的一块。在虚拟机启动时就被创建了。

唯一目的就是存放对象实例,只要我们new的对象实例都在这边分配了内存。

Java对可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。

当前主流的虚拟机都是按照可扩展来实现的(通过 -Xmx:最大多少内存空间,-Xms:最小多少内存空间 来控制)

如果堆中内存不够的话,将会抛出OutOfMemoryError异常,我称之为爆内存,并且在下面给出爆内存的具体例子:

public class 爆内存 {
public static void main(String[] args) {
int it = 20;
long[] arr = new long[100];
arr[99] = 33;
System.out.println(arr[99]);
}
}
//教你如何爆内存哈,在终端输入Java -Xmx256 爆内存

当然爆内存知识OutOfMemoryError异常三种情况中的一种,之后我也会仔细围绕这个好好讲讲,https://blog.csdn.net/z453588/article/details/83743837,可以先看看这个博客解解馋

方法区

与Java堆一样,是各个线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

可以和Java堆一样不需要连续的内存 和可以选择固定大小或者可扩展外

还可以选择不识闲垃圾回收。相对而言,垃圾收集行为在这个区域很少见。但是并非是说数据进入方法区就永久存在了。

这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载。

运行时常量池

是方法区的一部分。

Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

运行时常量池相对于Class文件常量池的另外一个重要特征就是具备动态性

动态性:Java语言并不要求常量一定只能在编译期产生,运行期间也可能将新的常量放入池中。这种特性被开发人员用的较多的就是String类的intern()方法。

既然运行时常量池是方法区的一部分,自然会受到方法区内存的限制,当无法再申请到内存时会抛出OutOfMemoryError异常。

直接内存

直接内存不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现。

JDK1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用来进行操作。这样在一些场景中能显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。

既然是直接内存,那么这一部分内存是不会受到Java堆大小的限制的咯,

但是既然是内存,则肯定还是会受到本机总共内存的大小和处理器寻址空间的限制。

服务器管理员配置虚拟机参数时,一般会根据实际内存设置 -Xmx等参数信息,但经常会忽略掉直接内存,使得各个内存区域的总和大于物理内存限制(包括物理上的限制和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError异常。

对象访问

上面都是从Java虚拟机的运行时数据区来讨论的,这边在讨论一个话题:对象访问是如何进行的呢?

对象访问是Java中的常客,即使是最简答的访问,也会涉及到Java栈、Java堆、方法区这三个最重要内存区域之间的关联关系。举个例子说明:

Object obj = new Object();

等号左边的一部分会被保存在Java栈的本地变量表里,我之前也有所涉及,作为一个reference类型数据出现,一个引用类型嘛。

而右边的部分会被存储到Java堆中去。形成一块连在一起的结构化内存(类似于数组一样的结构)

另外在Java堆中还必须包含能查找到此对象类型数据(如对象类型、父类、实现的接口、方法等)的地址信息,这些类型数据则存储在方法区中。

对象访问的两种方法:使用句柄和直接指针

如果使用句柄访问方式,Java堆中将会划分出一块内存来作为句柄池,栈内存中存储的就是对象的句柄地址,而句柄中包含了对象实例数据和类型数据各自的具体地址信息。

通过句柄访问对象.jpg

如果使用直接指针访问方式,Java堆对象的布局就必须要考虑如何放置访问类型数据的相关信息,栈内存中直接存储的就是对象地址

通过直接指针访问对象.jpg

各有优势:

使用句柄访问方式的最大好处就是堆内存中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针就行了。

使用直接指针访问方式的最大好处是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在Java中非常频繁,因此此类开销积少成多后会非常可观。

各种异常实战

明儿个再补充

· 13 分钟阅读
CheverJohn

一、首先说一下什么是递归

  1. 递归的本质是,某个方法调用了本身,本质还是调用一个方法,只是这个方法它恰好就是本身而已

  2. 递归因为是在自身中调用自身,所以会有下面三个较为显著的特点:

    1. 调用的是同一个方法

    2. 因为1,所以只需要写一个方法,就可以让你轻松调用无数次,所以调用的方法可大可小,具体取决于你的实际案例

    3. 在自身中调用自身,是嵌套调用(栈帧无法回收,开销巨大)

  3. 结合以上2和3的两个特点,所以递归调用最大的诟病就是开销巨大,栈帧和堆有时候会一起爆掉,俗称内存溢出

  4. 既然会导致内存溢出的话,我们就提出了尾递归这样一种解决思路

二、尾递归优化

  1. 尾递归优化是利用上面的第一个特点“调用同一个方法”来进行优化的

  2. 尾递归优化其实包括两个东西:1)尾递归的形式;2)编译器对尾递归的优化

    1. 尾递归的形式:
      1. 尾递归其实只是一种对递归的特殊写法,这种写法原本并不会带来跟递归不一样的影响,它只是写法不一样而已,写成这样不会有任何优化效果,该爆的栈和帧都还会爆
      2. 那么具体的不一样体现在哪里呢
        1. 前面说了,递归的本质是某个方法调用了自身,尾递归这种形式就要求:某个方法调用自身这件事,一定是该方法做的最后一件事(所以当有需要返回值的时候会是return f(n),没有返回的话就直接是f(n)了)
      3. 要求很简单,就是只有一条,但是有一些常见的误区
        1. 这个f(n)外不能加其他东西,因为这就不是最后一件事了,值返回来后还要再干点其他的活,变量空间还需要保留
          1. 比如如果有返回值的,你不能:乘个常数 return 3f(n);乘个n return n*f(n);甚至是 f(n)+f(n-1)
      4. 另外,使用return的尾递归还跟函数式编程有一点关系
  3. 为什么写成尾递归的形式,编译器就能优化了?或者说【编译器对尾递归的优化】的一些深层思想

    1. 说是深层思想,其实也是因为正好编译器其实在这里没做什么复杂的事,所以很简单
    2. 由于这两方面的原因,尾递归优化得以实现,而且效果很好
      1. 因为在递归调用自身的时候,这一层函数已经没有要做的事情了,虽然被递归调用的函数是在当前的函数里,但是他们之间的关系已经在传参的时候了断了,也就是这一层函数的所有变量什么的都不会再被用到了,所以当前函数虽然没有执行完,不能弹出栈,但它确实已经可以出栈了,这是一方面
      2. 另一方面,正因为调用的是自身,所以需要的存储空间是一毛一样的,那干脆重新刷新这些空间给下一层利用就好了,不用销毁再另开空间
    3. 有人对写成尾递归形式的说法是【为了告诉编译器这块要尾递归】,这种说法可能会导致误解,因为不是只告诉编译器就行,而是你需要做优化的前半部分,之后编译器做后半部分
  4. 所以总结:为了解决递归的开销大问题,使用尾递归优化,具体分两步:1)你把递归调用的形式写成尾递归的形式;2)编译器碰到尾递归,自动按照某种特定的方式进行优化编译

  5. 举例:

    (no尾递归)

    def recsum(x):
    if x == 1:
    return x
    else:
    return x + recsum(x - 1)

    (使用尾递归)

    def tailrecsum(x, running_total=0):
    if x == 0:
    return running_total
    else:
    return tailrecsum(x - 1, running_total + x)

但不是所有语言的编译器都做了尾递归优化。比如C实现了,JAVA没有去实现

说到这里你很容易联想到JAVA中的自动垃圾回收机制,同是处理内存问题的机制,尾递归优化跟垃圾回收是不是有什么关系,这是不是就是JAVA不实现尾递归优化的原因?

三、所以下面要讲一下垃圾回收(GC)

  1. 首先我们需要谈一下内存机制,这里我们需要了解内存机制的两个部分:栈和堆。下面虽然是在说JAVA,但是C也是差不多的
    1. 在Java中, JVM中的栈记录了线程的方法调用。每个线程拥有一个栈。在某个线程的运行过程中, 如果有新的方法调用,那么该线程对应的栈就会增加一个存储单元,即栈帧 (frame)。在frame 中,保存有该方法调用的参数、局部变量和返回地址
    2. Java的参数和局部变量只能是 基本类型 的变量(比如 int),或者对象的引用(reference) 。因此,在栈中,只保存有基本类型的变量和对象引用。而引用所指向的对象保存在堆中。
  2. 然后由栈和堆的空间管理方式的不同,引出垃圾回收的概念
    1. 当被调用方法运行结束时,该方法对应的帧将被删除,参数和局部变量所占据的空间也随之释放。线程回到原方法,继续执行。当所有的栈都清空时,程序也随之运行结束。
    2. 如上所述,栈 (stack)可以自己照顾自己。但堆必须要小心对待。堆是 JVM中一块可自由分配给对象的区域。当我们谈论垃圾回收 (garbage collection) 时,我们主要回收堆(heap)的空间
    3. Java的普通对象存活在堆中。与栈不同,堆的空间不会随着方法调用结束而清空(即使它在栈上的引用已经被清空了)(也不知道为什么不直接同步清空)。因此,在某个方法中创建的对象,可以在方法调用结束之后,继续存在于堆中。这带来的一个问题是,如果我们不断的创建新的对象,内存空间将最终消耗殆尽。
    4. 如果没有垃圾回收机制的话,你就需要手动地显式分配及释放内存,如果你忘了去释放内存,那么这块内存就无法重用了(不管是什么局部变量还是其他的什么)。这块内存被占有了却没被使用,这种场景被称之为内存泄露
  3. 所以不管是C还是JAVA,最原始的情况,都是需要手动释放堆中的对象,C到现在也是这样,所以你经常需要考虑对象的生存周期,但是JAVA则引入了一个自动垃圾回收的机制,它能智能地释放那些被判定已经没有用的对象

四、现在我们就可以比较一下尾递归优化和垃圾回收了

  1. 富士达他们最本质的区别是,尾递归优化解决的是内存溢出的问题,而垃圾回收解决的是内存泄露的问题

    1. 内存泄露:指程序中动态分配内存给一些临时对象,但是对象不会被GC所回收,它始终占用内存。即被分配的对象可达但已无用。
    2. 内存溢出:指程序运行过程中无法申请到足够的内存而导致的一种错误。内存溢出通常发生于OLD段或Perm段垃圾回收后,仍然无内存空间容纳新的Java对象的情况。
    3. 从定义上可以看出内存泄露是内存溢出的一种诱因,不是唯一因素。
  2. 自动垃圾回收机制的特点是:

    1. 解决了所有情况下的内存泄露的问题,但还可以由于其他原因内存溢出
    2. 针对内存中的堆空间
    3. 正在运行的方法中的堆中的对象是不会被管理的,因为还有引用(栈帧没有被清空)
      1. 一般简单的自动垃圾回收机制是采用 引用计数 (reference counting)的机制。每个对象包含一个计数器。当有新的指向该对象的引用时,计数器加 1。当引用移除时,计数器减 1,当计数器为0时,认为该对象可以进行垃圾回收
  3. 与之相对,尾递归优化的特点是:

    1. 优化了递归调用时的内存溢出问题

    2. 针对内存中的堆空间和栈空间

    3. 只在递归调用的时候使用,而且只能对于写成尾递归形式的递归进行优化

    4. 正在运行的方法的堆和栈空间正是优化的目标

最后可以解答一下前头提出的问题

  1. 通过比较可以发现尾递归和GC是完全不一样的,JAVA不会是因为有GC所以不需要尾递归优化。那为什么呢,我看到有的说法是:JAVA编写组不实现尾递归优化是觉得麻烦又没有太大的必要,就懒得实现了(原话是:在日程表上,但是非常靠后),官方的建议是不使用递归,而是使用while循环,迭代,递推