javase面试知识总结

文章目录


1.前导

1.java开发环境

JDK、JRE,JVM

JDK (Java Development Kit):是Java程序开发工具包,包含 JRE 和开发人员使用的工具;

JRE (Java Runtime Environment) :是Java程序的运行时环境,包含 JVM 和运行时所需的核心类库;

JVM(Java Virtual Machine ):是一款Java虚拟机,模拟Java运行时的一个平台,对内存分配,管理、线程调度等都有一定的管理;

在这里插入图片描述

总结:

  • JDK是一个Java程序员的开发工具包,除了包含JRE之外,还提供有许多Java开发工具给Java程序员使用,如Javac、JMC、Jstack等
  • JRE是一个Java的运行环境,除了包含JVM之外,还提供很多的一些JavaAPI类库,为Java程序提供很多功能,如果没有这些类库那么Java程序员需要完成这些功能就需要手动编写这些逻辑代码;
  • JVM是一个Java虚拟机,Java程序(class文件)最终是运行在JVM上的,他更倾向于模拟一台真实的计算机,对内存的分配,回收,共享、线程调度等功能都具备一定的管理能力;在运行Java程序的同时调用JRE提供的一些类库,使得Java程序更加强大、方便;

跨平台:任何软件的运行,都必须要运行在操作系统之上,而我们用Java编写的软件可以运行在任何的操作系统上,这个特性称为Java语言的跨平台特性(“一次编写,处处运行”)。该特性是由JVM实现的,我们编写的程序运行在JVM上,而JVM运行在操作系统上。

在这里插入图片描述

2.编译型语言与解释型语言

编程语言的发展经过三个阶段——机器语言、汇编语言、高级语言。但计算机只能识别并执行机器语言,因此需要将高级语言翻译为计算机可识别的机器编码来执行。

这里的翻译其实有两种方式,一种是编译,一种是解释,主要区别是翻译的时间不同。

编译型:

在程序执行之前,需要通过编译系统(使用专门的编译器,但编译系统不止于编译器),针对特定的平台,将高级语言源代码一次性的编译成可被该平台硬件执行的机器码,并包装成该平台所能识别的可执行性程序的格式。

比如下图是一个C语言的执行过程,主要经过四个步骤:

  1. 预处理——去掉注释,进行宏替换(#define相关),头文件(#include)(非必需)
  2. 编译——不同平台选用的汇编语言是不一样的。编译将高级语言编译成汇编语言。一些C++编译器可以直接将C++源代码生成目标代码
  3. 汇编——将汇编语言翻译成二进制的目标代码。(非必需)
  4. 链接——将目标代码同使用的函数的目标代码以及一些标准的启动代码( startup code)组合起来,生成程序的运行阶段版本。包含该最终产品的文件被称为可执行代码

img

特点

  1. 一次性编译为平台相关的机器语言文件。运行时执行可执行性程序,而不是源码,因此可以脱离开发环境,运行效率高。

  2. 与特定平台相关,不同的平台需要不同的编译器,因此一般无法移植到其他平台。

  3. C、C++、Objective等都是编译型语言的典型代表。

  4. 多用于开发操作系统、大型应用程序、数据库系统等等

    img

解释型

使用专门的解释器对源程序逐行解释成特定平台的机器码并立即执行。是代码在执行时才被解释器一行行动态翻译和执行,而不是在执行之前就完成翻译。

特点

  1. 解释型语言每次运行都需要将源代码解释为机器码并执行,效率相对较低。

  2. 只要平台提供相应的解释器,就可以运行源代码,方便移植。

  3. python、javascript、Java等是典型的解释型语言。

  4. 多用于网页脚本、服务器脚本及辅助开发接口这样对速度要求不高、对不同系统平台间的兼容性有一定要求的程序

    img

关于Java

Java需要编译:经过编译后.java文件生成.class文件。但是.class文件并不是计算机可以识别的机器码,而是与平台无关字节码。若要运行,还需要JVM的解释。(javac.exe的作用)

Java需要解释:Java基于不同平台上的JVM解释执行.class文件,将.class文件翻译成本地的机器码才能执行。(java.exe的作用)

img

3.java代码的编译和执行过程

Untitled

大部分的程序代码转换成物理机的目标代码(机器指令)或虚拟机能执行的指令集之前,都需要经过上图中的各个步骤

说明:绿色部分是解释的过程,蓝色部分是编译的过程

  • Java代码编译是由Java源码编译器(前端编译器)来完成,流程图如下所示:

Untitled

我们可以通过javac命令将Java程序的源代码编译成Java字节码,即我们常说的class文件。这是我们通常意义上理解的编译。

但是,字节码并不是机器语言,要想让机器能够执行,还需要把字节码翻译成机器指令。这个过程是Java虚拟机做的,这个过程也叫编译。是更深层次的编译。

image-20220301134130813

前端编译主要指与源语言有关但与目标机无关的部分,包括词法分析、语法分析、语义分析与中间代码生成。

后端编译主要指与目标机有关的部分,包括代码优化和目标代码生成等。

我们可以把将.java文件编译成.class的编译过程称之为前端编译。把将.class文件翻译成机器指令的编译过程称之为后端编译。

  • Java字节码的执行是由JVM执行引擎(后端编译器)来完成,流程图 如下所示:

Untitled

什么是解释器(Interpreter), 什么是JIT编译器?

  • 解释器:当Java虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容“翻译”为对应平台的本地机器指令执行
    • 解释器真正意义上所承担的角色就是一个运行时“翻译者”,将字节码文件中的内容“翻译”为对应平台的本地机器指令执行
    • 当一条字节码指令被解释执行完成后,接着再根据PC寄存器中记录的下一条需要被执行的字节码指令执行解释操作
  • JIT(Just In Time Compiler)编译器:就是虚拟机将源代码直接编译成和本地机器平台相关的机器语言

为什么说Java是半编译型半解释型语言?

  • JDK1.0时代,将Java语言定位为“解释执行”还是比较准确的。再后来,Java也发展出可以直接生成本地代码的编译器
  • 现在JVM在执行Java代码的时候,通常都会将解释执行与编译执行二者结合起来进行

Untitled

java代码的执行分类
  • 第一种是将源代码编译成字节码文件,然后在运行时通过解释器将字节码文件转为机器码执行
  • 第二种是编译执行(直接编译成机器码,但是要知道不同机器上编译的机器码是不一样,而字节码是可以跨平台的)。现代虚拟机为了提高执行效率,会使用即时编译技术(JIT,Just In Time)将方法编译成机器码后再执行
  • HotSpot VM是目前市面上高性能虚拟机的代表作之一。它采用解释器与即时编译器并存的架构。在Java虚拟机运行时,解释器和即时编译器能够相互协作,各自取长补短,尽力去选择最合适的方式来权衡编译本地代码的时间和直接解释执行代码的时间
  • 在今天,Java程序的运行性能早已脱胎换骨,已经达到了可以和C/C++ 程序一较高下的地步

命令:

编译:javac .java --> .class
执行:java  .class

2.基础知识

1.关键字和标识符

关键字

定义:被Java语言赋予了特殊含义,用做专门用途的字符串(单词)

特点:关键字中所有字母都为小写

常见的关键字

image-20201003225818510

在这里插入图片描述

注意:true、false、null都不是Java关键字

标识符

  • 标识符:是指在程序中,我们自己定义的名字。比如类的名字、方法的名字和变量的名字等等,都是标识符。
    • HelloWorld案例中,出现的标识符有类名字 HelloWorld 。
  • 命名规则硬性要求
    • 标识符(名字)可以包含 英文字母26个(区分大小写) 、 0-9数字 、 $(美元符号) 和 _(下划线) 。
    • 标识符不能以数字开头。
    • 标识符不能是关键字
    • 区分大小写。
  • 命名规范软性建议
    • 类名规范:每个单词首字母大写(大驼峰式)。
    • 方法名和变量名规范: 第一个单词全小写,后面每个单词首字母大写(小驼峰式)。
    • 包名规范:全部小写。

2.数据类型

对于每一种数据都定义了明确的具体数据类型(强类型语言),在内存中分配了不同大小的内存空间。

image-20200930201153709

  • 方法体外类体内声明的变量称为成员变量
  • 方法体内部声明的变量称为局部变量

image-20200930201542819

注意:二者在初始化值方面有一些区别

同:都有生命周期。

异:局部变量除形参外,其他都需要显式初始化。而全局变量可以不显示初始化,因为全局变量有默认的初始值

整数类型:byte、short、int、long
  • java的整型常量默认为int型,声明long型常量时必须在后面添加" l "或" L "
  • java程序中变量通常声明为int型,除非不足以表示较大的数,才使用long型
类型占用存储空间表数范围
byte1个字节=8bit(位)-128 ~ 127
short2个字节-2^15 ~ 2^15-1
int4个字节-2^31 ~ 2^31-1 ( 约21亿 )
long8个字节-2^63 ~ 2^63-1

注意:bit (位) :计算机中的最小存储单位。

byte (字节) :计算机中基本存储单元。

浮点类型:float、double
  • 浮点型常量有两种表示形式:
    • 十进制数形式:如:5.12 512.0f .512 (必须有小数点)
    • 科学计数法形式:如:5.12e2 512E2 100E-2
  • float:单精度,尾数可以精确到7位有效数字。很多情况下,精度很难满足需求。
  • double:双精度,精度是float的两倍。通常采用此类型。
Java 的浮点型常量默认为double型`,`声明float型常量,必须在后面添加" f "或" F "
类型占用存储空间表数范围
单精度float4个字节(32位)-3.403E38 ~ 3.403E38
双精度double8个字节(64位)-1.798E308 ~ 1.798E308
字符类型:char
  • char 型数据用来表示通常意义上的"字符"(2个字节)
  • Java中的所有字符都使用Unicode编码,故一个字符可以存储一个字母一个汉字,或其他书面语的一个字符。不同编码方式字符与字节的对应数也可能不同,可能3个字节对应一个汉字字符。这也是产生乱码的原因,不同的编码方式对代码进行编码和解析,得到不一致的结果,从而出现乱码现象。
  • 字符型变量的三种表现形式:
    • 字符常量是用单引号( ’ ’ )括起来的单个字符。例如:char c1 = ‘a’ ; char c2= ‘中’; char c3 = ‘9’ ;
    • Java中还允许使用转义字符’ \ '来将其后的字符转变为特殊字符型常量。 例如:char c3 = ’ \n ‘; 其中:’\n’表示换行符
    • 直接使用 Unicode 值来表示字符型常量:’ \uXXXX '。其中,XXXX代表一个十六进制整数。如:\u000a 表示 \n
  • char类型是可以进行运算的。因为它都有对应有Unicode码。

ASCII 码表

img

布尔类型:boolean
  • boolean 类型用来判断逻辑条件,一般用于程序流程控制:
    • if条件控制语句;
    • while循环控制语句;
    • do-while循环控制语句;
    • for循环控制语句;
  • boolean类型数据只允许取值true和false,不能为null
    • 不可以使用 0 或非 0 的整数替代false和true,这点和C语言不同。
    • Java虚拟机中没有任何供boolean值专用的字节码指令,Java语言表达所操作的 boolean值,在编译之后都使用java虚拟机中的int数据类型来代替:true用1表示,false 用0表示。———《java虚拟机规范8版》
数据类型的默认初始值
整数类型:byte,short,int,long0
单精度浮点型:float0.0f
双精度浮点型:double0.0d
字符型:char/u000
布尔型:booleanfalse(实际存的是0)
引用类型:referencenull

什么时候被初始化默认值:

1.局部变量声明之后,Java虚拟机就不会自动给它初始化为默认值,因此局部变量的使用必须先经过显式的初始化。 但是需要声明的是:对于只负责接收一个表达式的值的局部变量可以不初始化,参与运算和直接输出等其它情况的局部变量需要初始化。

2.对于类的成员变量,不管程序有没有显式的进行初始化,Java虚拟机都会先自动给它初始化为默认值。

类加载过程:

img

类变量的默认初始化是在准备阶段进行的,该阶段会为静态变量分配空间,此时是赋予默认初始值;但当静态变量被final修饰时,准备阶段赋值为指定的值,因为final修饰的变量在编译时就以及确定了。当初始化阶段时才会为静态变量分配指定的值,同时执行静态代码块中的代码。

何时触发初始化

  1. 为一个类型创建一个新的对象实例时(比如new、反射、序列化)
  2. 调用一个类型的静态方法时(即在字节码中执行invokestatic指令)
  3. 调用一个类型或接口的静态字段,或者对这些静态字段执行赋值操作时(即在字节码中,执行getstatic或者putstatic指令),不过用final修饰的静态字段除外,它被初始化为一个编译时常量表达式
  4. 调用JavaAPI中的反射方法时(比如调用java.lang.Class中的方法,或者java.lang.reflect包中其他类的方法)
  5. 初始化一个类的派生类时(Java虚拟机规范明确要求初始化一个类时,它的超类必须提前完成初始化操作,接口例外)
  6. JVM启动包含main方法的启动类时。

3.数据类型转换

基本数据类型转换
  • 自动类型转换:容量小的类型自动转换为容量大的数据类型。数据类型按容量大小排序为:

image-20200930210209899

  • 有多种类型的数据混合运算时,系统首先自动将所有数据转换成容量最大的那种数据类型,然后再进行计算。
  • byte , short , char之间不会相互转换,他们三者在计算时首先转换为int类型
  • boolean类型不能与其它数据类型进行运算
  • 当把任何基本数据类型的值和字符串(String)进行连接运算时(+),基本数据类型的值将自动转化为字符串(String)类型。

在这里插入图片描述

强制类型转换

  • 自动类型转换的逆过程,将容量大的数据类型转换为容量小的数据类型。使 用时要加上强制转换符:( ),但可能造成精度降低或溢出,需要格外注意。

  • 通常,字符串不能直接转换为基本数据类型,但通过基本数据类型对应的包装类则可以实现把字符串转换成基本数据类型。

    • 如: String a = “43”; int i = Integer.parseInt(a);
  • boolean类型不可以转换为其它的数据类型

类型转换包含着强制类型转换,强制类型转换可能会导致溢出或损失精度,要注意的是,浮点数是通过舍弃小数得到的,而不是四舍五入。

short 类型内存占有2个字节,在和 int类型运算时会提升为 int 类型 ,自动补充2个字节,计算后的结果还是 int 类型,但最终被强转为了short类型,占用2个字节空间。

在这里插入图片描述

  • 1)浮点转成整数,直接取消小数点(不是四舍五入),可能造成数据损失精度;
  • 2)int 强制转成 short 砍掉2个字节,可能造成数据丢失;

强制类型转换的细节

若有一个int类型的数值如 int a = 1000;现将int类型强制转换为byte类型则得到的byte类型b的值为多少?

首先将1000转换为二进制为 0000 0011 1110 1000(正数的补码为原码,若为负数需要先转反码再取补码),因为byte类型只有8位 所以只保留1000的后8位 为1110 1000(补码),计算出原码为1001 1000 转换为十进制为-24所以最后byte b= -24;因为使用的是带符号的二进制表示,所以计算结果转换为十进制数时会出现不一样的符号。

int a=1000;
byte b =(byte)a;//结果为-24

知识补充图:

img

img

浮点型数据计算时的问题:

由于在运算的时候,float类型和double很容易丢失精度

所以,为了能精确的表示、计算浮点数,Java提供了BigDecimal

代码示例:

例如

System.out.println(0.01+0.09);
//精确计算小数
BigDecimal b3=new BigDecimal("0.09");
BigDecimal b4=new BigDecimal("0.01");
BigDecimal result1=b3.add(b4);
System.out.println(result1);

运行结果:

img

结果分析:

这和十进制数转化为二进制数有关,

举个例子,0.9表示成二进制数0.92=1.8 取整数部分 1 ,0.8(1.8的小数部分)2=1.6 取整数部分 1, 0.62=1.2 取整数部分 1 ,0.22=0.4 取整数部分 0 ,0.42=0.8 取整数部分 0 ,0.82=1.6 取整数部分 1 ,0.6*2=1.2 取整数部分 0…0.9二进制表示为(从上往下): 1100100100100…

注意:上面的计算过程循环了,也就是说*2永远不可能消灭小数部分,这样算法将无限下去。很显然,小数的二进制表示有时是不可能精确的 。其实道理很简单,十进制系统中能不能准确表示出1/3呢?同样二进制系统也无法准确表示1/10。这也就解释了为什么浮点型减法出现了"减不尽"的精度丢失问题。

4.运算符

算数运算符

image-20201001082942775

比较运算符

image-20201004002055453

逻辑运算符

image-20201001083703973

  • “&”和“&&”的区别:
    • &:左边无论真假,右边都得进行运算
    • &&:如果左边为真,右边参与运算如果左边为假,那么右边不参与运算
  • “|”和“| |”的区别同理,| |表示:当左边为真,右边不参与运算
  • 异或( ^ ) :当左右都为true时,结果为false。

5.流程控制

这里只讲部分,因为太简单了

三元运算符:(x > y) ? x : y ;括号内的条件成立则取x否则取y

switch语句:

在这里插入图片描述

case合并:

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo02 {
    public static void main(String[] args) {
        int season = 3;
        switch (season){
            case 1: case 2: case 3:
                System.out.println("爆竹声中一岁除,春风送暖入屠苏");
                break;
            case 4: case 5: case 6:
                System.out.println("接天莲叶无穷碧,映日荷花别样红");
                break;
            case 7: case 8: case 9:
                System.out.println("塞下秋来风景异,衡阳雁去无留意");
                break;
            case 10: case 11: case 12:
                System.out.println("窗含西岭千秋雪,门泊东吴万里船");
                break;
        }
    }
}

case 穿透

只有当遇到break时才会退出分支,否则就从第一个满足条件的分支一直运行下去,运行下面不满足条件的分支,直到遇到break才退出

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo03 {
    public static void main(String[] args) {
        int i = 1;
        switch (i) {
            case 0:
                System.out.println("0");
                break;
            case 1:
                System.out.println("1");
            case 2:
                System.out.println("2");
            default:
                System.out.println("default");
        }
    }
}

6.方法

简单来说Java方法是语句的集合,它们在一起执行一个功能。

  • 方法:就是将一个功能抽取出来,把代码单独定义在一个大括号内,形成一个单独的功能。当我们需要这个功能的时候,就可以去调用。这样即实现了代码的复用性,也解决了代码冗余的现象。
修饰符 返回值类型 方法名(参数列表){
    逻辑代码...
    return 返回值;
}
  • 定义格式解释:
    • 修饰符: 目前固定写法 publicstatic
    • 返回值类型: 方法运行结果的数据类型,如果该方法没有返回值,那么请声明为void
    • 方法名:满足标识符的规范,用来调用方法。
    • 参数列表:参数像是一个占位符。当方法被调用时,传递值给参数。这个值被称为实参或变量。参数列表是指方法的参数类型、顺序和参数的个数。参数是可选的,方法可以不包含任何参数。

在这里插入图片描述

方法的重载:
  • 方法重载:**指在同一个类中,允许存在一个以上的同名方法,只要它们的**参数列表不同即可,与修饰符和返回值类型无关(即使修饰符和返回值列表不一样也不属于重载)
  • 参数列表不同:个数不同,数据类型不同,顺序不同。
参数传递问题:

在Java方法中参数列表有两种类型的参数,基本类型和引用类型。

**基本类型:**值存放在局部变量表中,无论如何修改只会修改当前栈帧的值,方法执行结束对方法外不会做任何改变;此时需要改变外层的变量,必须返回主动赋值。

**引用数据类型:**指针存放在局部变量表中,调用方法的时候,副本引用压栈,赋值仅改变副本的引用。但是如果通过操作副本引用的值,修改了引用地址的对象,此时方法以外的引用此地址对象当然被修改。(两个引用,同一个地址,任何修改行为2个引用同时生效)。

这两种类型都是将外面的参数变量拷贝一份到局部变量中,基本类型为值拷贝,引用类型就是将引用地址拷贝一份。

方法传递参数时,基本数据类型是传递值,无法通过方法来改变传递的参数。当传递引用变量时,将引用变量所指的地址值传递给方法,方法操作该地址值所指的空间时是可以改变引用变量的值的,因为引用变量的地址值没有改变,改变的是地址值里面的东西。

例如:但传递的是String类型时,方法传递的是指向该字符串的地址值,当在函数内部改变String类型的值时,由于String是不支持修改的,也就是地址所指向的值无法修改。修改的结果也只是参数赋值来的地址被改变了,所以对原参数并没有影响。

可变个数的形参
/*
 * 可变个数形参的方法
 * 
 * 1.jdk5.0新增的内容
 * 2.具体使用:
 * 		2.1 可变个数形参的格式:数据类型 ... 变量名
 * 		2.2 当调用可变个数形参的方法时,传入的参数个数可以是:0个,1个,2个 ......
 * 		2.3 可变个数形参的方法与本类中方法名相同,形参不同的方法之间构成方法的重载
 * 		2.4 可变个数形参的方法与本类中方法名相同,形参类型也相同的数组之间不构成重载。
 * 			换句话说,二者不能共存,因为可变个数的形参也可以看成是数组
 * 		2.5 可变个数形参在方法的形参中,必须声明在末尾
 * 		2.6 可变个数形参在方法的形参中,最多只能声明一个可变形参
 */
public class MethodArgsTest {
	
	public static void main(String[] args) {
		MethodArgsTest test = new MethodArgsTest();
		
//		test.show(12);
//		test.show("hello");
//		test.show("hello","world");
//		test.show();
		
		test.show(new String[] {"AA","BB","CC"});
	}
	
	public void show(int i) {
		System.out.println("1");
	}
	
//	public void show(String s) {
//		System.out.println("2");
//	}
	
	public void show(String ... strs) {
		System.out.println("3");
		
		for(int i = 0;i < strs.length;i++) {
			System.out.println(strs[i]);
		}
		
	}
	
	//可变个数的形参在方法的形参中,必须声明在末尾
//	public void show(String ... strs,int i) { //编译不通过
//		System.out.println("4");
//	}
	
	public void show(int i,String ... strs) {
		System.out.println("5");
	}
	
}

7.数组

数组就是一个容器,能够帮我们存储很多相同类型的数据;

在这里插入图片描述

数组特点:

  • 1)数组是一个容器,只能存储相同类型的数据;
  • 2)数组的长度在创建时就已经定义好,不可改变;
  • 3)数组的最大下标为长度-1

初始方式:

数组存储的数据类型[] 数组名字=new 数组存储的数据类型[长度];
int[] arr=new int[3];

数据类型[] 数组名称=new 数据类型[]{元素1,元素2,元素3....};
int[] arr=new int[]{3,5,6};

数据类型[] 数组名称={元素1,元素2,元素3};
int[] arr={1,34,65};

数组的访问:

直接输出数组名,输出的是该数组在内存中的内存地址值[I@1b6d3586

  • 索引:前面提到过,数组会为其中每个元素分配一个下标,这个下标我们称之为索引,索引在数组中都是从0开始,我们可以通过数组的索引来获取数组中的任意元素

在这里插入图片描述

数组的长度属性:

每一个数组都具有一个length属性,该属性的值为当前数组的元素个数,也就是数组的长度,通过数组名.length,可获取当前数组的长度值,返回的是一个int数,由此可以推断出,数组的最大索引值为length-1

数组越界异常

创建数组,赋值3个元素,数组的索引就是0,1,2,没有3索引,因此我们不能访问数组中不存在的索引,程序运行后,将会抛出 ArrayIndexOutOfBoundsException 数组越界异常。在开发中,数组的越界异常是不能出现的,一旦出现了,就必须要修改我们编写的代码。

空指针异常

arr = null 这行代码,意味着变量arr将不会在保存数组的内存地址,也就不允许再操作数组了,因此运行的候会抛出 NullPointerException 空指针异常。

数组原理:

Java虚拟机要运行程序,必须要对内存进行空间的分配和管理。

数组在内存中的存储:

在这里插入图片描述

数组作为参数:

在这里插入图片描述

Arrays工具类

java.util.Arrays类即为操作数组的工具类,包含了用来操作数组(比如: 排序和搜索)的各种方法。

1boolean equals(int[ ] a,int[ ] b)判断两个数组是否相等。
2String toString(int[ ] a)输出数组信息。
3void fill ( int[ ] a,int val )将指定值填充到数组之中。
4void sort(int[ ] a)对数组进行排序。
5int binarySearch(int[ ] a,int key)对排序后的数组进行二分法检索指定的值。

8.面向对象

概念

类:类是对一类事物的描述,是抽象的、概念上的定义

对象:对象是实际存在的该类事物的每个个体,因而也称为实例(instance)

面向对象程序设计的重点是类的设计设计类,就是设计类的成员

类是一组相关属性和行为的集合。可以看成是一类事物的模板,使用事物的属性特征行为特征来描述该类事物。

  • 属性:该事物的状态信息;

  • 行为:该事物的功能信息;

  • 定义类: 就是定义类的成员,包括成员变量和成员方法。

  • 成员变量: 和以前定义变量几乎是一样的。只不过位置发生了改变。在类中,方法外。

  • 成员方法: 和以前定义方法几乎是一样的。只不过把static去掉,static的作用在面向对象后面课程中再详细讲解。

内存解析

图片

  • 堆(Heap),此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存 。 这一点在 Java 虚拟机规范中的描述是:所有的对象实例以及数组都要在堆上分配
  • 通常所说的 栈(Stack),是指虚拟机栈。虚拟机栈用于存储局部变量等 。局部变量表存放了编译期可知长度的各种基本数据类型(boolean、byte 、 char 、 short 、 int 、 float 、 long 、 double),对象引用 ( reference 类型 , 它不等同于对象本身 , 是对象在堆内存的首地址)。方法执行完,自动释放。
  • 方法区(Method Area),用于存储已被虚拟机加载的类信息 、 常量 、 静态变量 、 即时编译器编译后的代码 等数据

new这个类的时候,Jvm去方法区找有没有这个class,没有就加载到方法区,属性方法这些都是在方法区class中的;Jvm加载完后,就根据这个模板在堆中创建对象给属性赋默认值,然后再执行赋值语句给对象赋值;

一个对象内存图:

在这里插入图片描述

对象调用方法时,根据对象中方法标记(地址值),去类中寻找方法信息。这样哪怕是多个对象,方法信息只保存一份,节约内存空间。

匿名对象:

顾名思义,匿名就是没有名字的对象,在创建对象时,只通过new的动作在堆内存开辟空间,却没有把堆内存空间的地址值赋值给栈内存的某个变量用以存储;

使用场景:

  • 1)如果对一个对象只需要进行一次方法调用,那么就可以使用匿名对象。
  • 2)我们经常将匿名对象作为实参传递给一个方法调用。
属性
  • 方法体外,类体内声明的变量称为成员变量
  • 方法体内部声明的变量称为局部变量

在这里插入图片描述

更多操作成员变量局部变量
声明的位置直接声明在类中方法形参或内部、代码块内、构造器内等
修饰符private、public、static、final等不能用权限修饰符修饰,可以用final修饰
初始化值有默认初始化值没有默认初始化值,必须显式赋值,方可使用
内存加载位置堆空间 或 静态域内栈空间

成员变量 与 局部变量的内存位置:

image-20201007093916245

封装

封装性的引入与体现
为什么需要封装?封装的作用和含义?
我要用洗衣机,只需要按一下开关和洗涤模式就可以了。有必要了解洗衣机内部的结构吗?有必要碰电动机吗?
我要开车,…

我们程序设计追求“高内聚,低耦合”。
高内聚:类的内部数据操作细节自己完成,不允许外部干涉;
低耦合:仅对外暴露少量的方法用于使用。

隐藏对象内部的复杂性,只对外公开简单的接口。便于外界调用,从而提高系统的可扩展性、可维护性。通俗的说,把该隐藏的隐藏起来,该暴露的暴露出来。这就是封装性的设计思想

封装是面向对象的三大特征之一,面向对象编程语言是对客观世界的模拟,客观世界里成员变量都是隐藏在对象内部的,外界无法直接操作和修改。封装可以被认为是一个保护屏障,防止该类的代码和数据被其他类随意访问。要访问该类的数据,必须通过指定的方式。适当的封装可以让代码更容易理解与维护,也加强了代码的安全性

权限修饰符:

Java规定的4种权限修饰符(从小到大排列):private、缺省(default)、protected、public

public(公共)protected(保护)default(默认)private(私有)
同一类中
同一包中(子类或任意类)
不同包的子类(通过super访问)
不同包的任意类

default是默认的权限修饰符,使用时不用显式的加上default关键字,什么修饰符都不加默认是default权限;default权限允许同一个包下是可以访问的,如果是不同包,那就访问不了了;

权限修饰符和方法的重写

方法的重写必须保证子类方法的权限修饰符>=父类方法的权限修饰符

priavte 关键字
  1. private是一个权限修饰符,代表最小权限。
  2. 可以修饰成员变量和成员方法。
  3. 被private修饰后的成员变量和成员方法,只在本类中才能访问。

封装的原则:将属性隐藏起来,若需要访问某个属性,提供公共方法对其访问

经过封装后,属性再也不是直接暴露给外部了,我们可以在外部操作属性之前加以控制;

this 关键字

this是Java中的一个关键字,代表所在类的当前对象的引用(地址值),即对象自己的引用;

在这里插入图片描述

tips :方法被哪个对象调用,方法中的this就代表那个对象。即谁在调用,this就代表谁。

构造方法

构造方法也叫构造器,顾名思义就是用来构造类的;当一个对象被创建时候,构造方法用来初始化该对象,给对象的成员变量赋初始值。

tips:无论你与否自定义构造方法,所有的类都有构造方法,因为Java自动提供了一个无参数构造方法,一旦自己定义了构造方法,Java自动提供的默认无参数构造方法就会失效。

构造方法注意事项:

  1. 如果你不提供构造方法,系统会给出无参数构造方法。
  2. 如果你提供了构造方法,系统将不再提供无参数构造方法。
  3. 构造方法是可以重载的,既可以定义参数,也可以不定义参数。
标准JavaBean

JavaBean 是 Java语言编写类的一种标准规范。符合 JavaBean 的类,要求类必须是和公共的,并且具有无参数的构造方法,提供用来操作成员变量的 setget 方法,采用private修饰成员变量。

/*
 * JavaBean是一种Java语言写成的可重用组件。

	所谓JavaBean,是指符合如下标准的Java类:
		>类是公共的
		>有一个无参的公共的构造器(构造器的权限与类的权限相同)
		>有属性,且有对应的get、set方法
 * 
 */
public class Customer {
	
	private int id;
	private String name;
	
	public Customer(){
		
	}
	
	public void setId(int i){
		id = i;
	}
	public int getId(){
		return id;
	}
	public void setName(String n){
		name = n;
	}
	public String getName(){
		return name;
	}
	
}
package包

package语句作为Java源文件的第一条语句,指明该文件中定义的类所在的包。(若缺省该语句,则指定为无名包)。它的格式为:

package 顶层包名.子包名;
1.java.lang----包含一些 Java 语言的核心类,如 StringMathIntegerSystemThread,提供常用功能
2.java.net----包含执行与网络相关的操作的类和接口。
3.java.io----包含能提供多种输入/输出功能的类。
4.java.util----包含一些实用工具类,如定义系统特性、接口的集合框架类、使用与日期日历相关的函数。
5.java.text----包含了一些 java 格式化相关的类
6.java.sql----包含了 java 进行 JDBC 数据库编程的相关类/接口
7.java.awt----包含了构成抽象窗口工具集(abstractwindowtoolkits)的多个类,这些类被用来构建和管理应用程序的图形用户界面(GUI)B/S  C/S
MVC设计模式

MVC 是常用的设计模式之一,将整个程序分为三个层次:视图模型层,控制器层,与数据模型层。这种将程序输入输出、数据处理,以及数据的展示分离开来的设计模式使程序结构变的灵活而且清晰,同时也描述了程序各个对象间的通信方式,降低了程序的耦合性。

image-20201007182548086

image-20201007182557203

继承

继承是面向对象三大特征之一,继承就是子类继承父类的特征(属性)和行为,使得子类对象(实例)具有父类的属性和方法,或子类从父类继承方法,使得子类具有父类相同的行为。

继承可以使得子类别具有父类别的各种属性和方法,而不需要再次编写相同的代码。在令子类别继承父类别的同时,可以重新定义某些属性,并重写某些方法,即覆盖父类别的原有属性和方法,使其获得与父类别不同的功能。另外,为子类别追加新的属性和方法也是常见的做法。

  • 总结:

1)子类继承父类可以获得父类的功能,提高代码的复用性

2)子类可以重写(覆盖)某些父类的功能,我们一般称为增强

3)子类除了可以继承父类的功能之外,还可以额外添加子类独有的功能,一般来说,子类要比父类强大(你的是我的,我的还是我的);

通过 extends 关键字,可以声明一个子类继承另外一个父类,定义格式如下

class 父类 {
    ...
}
class 子类 extends 父类 {
    ...
}
父类不可被继承的内容

并不是父类的所有内容都可以给子类继承的,以下2个内容不能被子类继承:

  1. 被private修饰的
  2. 构造方法不能继承

tips:虽然被private修饰的成员不能被继承下来,但是通过getter/setter方法访问父类的private成员变量

image-20201007183303624

在这里插入图片描述

成员变量的继承

无重复

在这里插入图片描述

有重复

在这里插入图片描述

当子类父类中出现重名的成员变量,这时的访问是有影响的,默认取子类的(就近原则);

成员方法的继承

成员方法不重名

在这里插入图片描述

成员方法重名(方法的重写)

如果子类父类中出现重名的成员方法,这时的访问是一种特殊情况,叫做方法重写 (Override)

  • 方法重写 :子类中出现与父类一模一样的方法时(返回值类型,方法名和参数列表都相同),会出现覆盖效果,也称为重写或者复写。声明不变,重新实现。

在这里插入图片描述

构造方法的继承特点
  1. 构造方法的名字是与类名一致的。所以子类是无法继承父类构造方法的
  2. 构造方法的作用是初始化成员变量的。所以子类的初始化过程中,必须先执行父类的初始化动作。子类的构造方法中默认有一个 super() ,表示调用父类的构造方法,父类成员变量初始化后,才可以给子类使用。

继承后子类构造方法特点:子类所有构造方法都会调用父类的无参构造

在这里插入图片描述

tips:子类在继承父类时,必须保证父类有无参构造方法,否则编译报错;当显示定义有参构造方法时,无参构造方法不会自动生成,需要人为的定义。

1、为什么在实例化子类的对象时,会先调用父类的构造器?

答:子类继承父类后,获取到父类的属性和方法,这些属性和方法在使用前必须先初始化,所以须先调用父类的构造器进行初始化

2、在哪里调用父类的构造器?

答:在子类构造器的第一行会隐式的调用 super();,即调用父类的构造器

如果父类中没有定义空参的构造器,则必须在子类的构造器的第一行显示的调用super(参数); ,以调用父类中构造器

如果子类中构造器的第一行写了this();,则就隐式的super();会消失,因为super()this()都只能在构造器的第一行定义

super关键字

super关键字:用于修饰父类成员变量,类似于之前学过的 thisthis代表的是本类对象,而super代表的是父类对象;使用super我们可以调用父类的成员(属性和行为),注意super关键字不能访问父类私有(private修饰)的成员

子父类中出现了同名的成员变量时,在子类中需要访问父类中非私有成员变量(不是private修饰的)时,需要使用 super 关键字,修饰父类成员变量,类似于之前学过的 this

  • super的关键字的作用如下:
    • 1)用于访问父类中定义的属性
    • 2)用于调用父类中定义的成员方法
    • 3)用于在子类构造方法中调用父类的构造器
this和super图解
  • 1)main方法进栈执行;
  • 2)执行new Zi(),首先现将Fu、Zi两个类加载到内存(方法区)
  • 3)初始化Fu类,再初始化子类;子类中保留父类的引用super;可以通过super来获取父类的非私有内容;
  • 4)子类调用method方法,调用的是this区中的方法,因此输出的是Zi method...

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-sPE4u8no-1650872081739)(D:\笔记\img\watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L0JiMTUwNzAwNDc3NDg=,size_16,color_FFFFFF,t_70#pic_center.png)]

继承的特点

在Java中,不支持多继承,只支持单继承,但支持多重继承;即A继承B,B继承C,这样下来C间接继承与A;

static 继承的特点

被static修饰的成员是可以被继承到子类的;

子类对象实例化的过程

image-20201023173518862

image-20201029120321119

抽象类

在继承体系中,由于父类的设计应该保证继承体系中所有子类的共性,子类往往比父类要描述的更加清晰、具体;因此我们有时需要将父类设计的抽象化;即方法只声明方法体,而没有方法具体功能,我们把没有方法主体的方法称为抽象方法包含抽象方法的类就是抽象类

  • 抽象方法 :没有方法体的方法。
  • 抽象类:包含抽象方法的类。

使用 abstract 关键字修饰方法,该方法就成了抽象方法,抽象方法只包含一个方法名,而没有方法体。

修饰符 abstract 返回值类型 方法名 (参数列表);

如果一个类包含抽象方法,那么该类必须是抽象类。

abstract class 类名字 { 
	
}

1)继承抽象类的子类必须重写父类所有的抽象方法。否则,该子类也必须声明为抽象类。

2)必须有子类实现该父类的抽象方法,否则,从最初的父类到最终的子类都不能创建对象,失去意义。

抽象类是不可以进行实例化的,抽象类本就是包含有无法实例化的抽象方法,或者说这个方法是没有任何意义的,他存在的意义就是让子类去实现它;因此抽象类是不可以实例化的,也就是不能创建对象;

抽象类注意事项

  1. 抽象类不能创建对象,如果创建,编译无法通过而报错。只能创建其非抽象子类的对象。

理解:假设创建了抽象类的对象,调用抽象的方法,而抽象方法没有具体的方法体,没有意义。

  1. 抽象类中,可以有构造方法,是供子类创建对象时,初始化父类成员使用的。

理解:子类的构造方法中,有默认的super(),需要访问父类构造方法。

  1. 抽象类中,不一定包含抽象方法,但是有抽象方法的类必定是抽象类。

理解:未包含抽象方法的抽象类,目的就是不想让调用者创建该类对象,通常用于某些特殊的类结构设计。

  1. 抽象类的子类,必须重写抽象父类中所有的抽象方法,否则,编译无法通过而报错。除非该子类也是抽象类。

理解:假设不重写所有抽象方法,则类中可能包含抽象方法。那么创建对象后,调用抽象的方法,没有意义。

接口

继承抽取了类的共性,使得其子类都具备了父类的功能,提高了代码的复用性,但是有些情况下,并不是所有的子类都应该具备父类的全部功能,有些功能只是当做与"扩展功能",并不是与生俱备的;

接口(英文:Interface),在Java编程语言中是一个抽象类型,接口中定义的全都是抽象方法,使用Interface来声明。一个类通过继承接口的方式,从而来继承接口的抽象方法。

一个类继承接口则需要实现该接口的所有的抽象方法,除非继承接口的类是抽象类,因此一个类继承接口我们往往称为某个类实现了(implements)某个接口,关键字从extends更换为implements

tips:在JDK7及以前,接口中只含有抽象方法;在JDK8中,接口可以含有默认方法以及静态方法;JDK9中,接口可以含有私有方法;

Java中定义接口采用interface关键字,关于接口有如下特点:

  • 1):接口中的所有成员变量都默认是由public static final修饰的;
  • 2):接口中的所有方法都默认是由public abstract修饰的;
  • 3):接口没有构造方法(不能实例化对象);
  • 4):类继承(实现)接口时,必须重写接口中的所有抽象方法,否则该类是抽象类;
  • 5):接口与接口之间采用多继承机制;
  • 6):接口中没有静态代码块。
  • 7):接口的静态方法不能被继承下来;
public interface 接口名称 {
    // 抽象方法
    // 默认方法
    // 静态方法
    // 私有方法
}

抽象方法

抽象方法:使用 abstract 关键字修饰,可以省略,没有方法体。该方法供子类实现使用。

由于接口中只能含有抽象方法,因此方法可以省略abstract关键字,并且默认都是public修饰的方法:

public interface InterFaceName{
    void method();
}
JDK1.8新特性

在JDK7及以前,接口中只允许存在抽象方法,在JDK8中,接口允许存在默认方法静态方法,两种方法可以为接口提供一些功能性的代码,也可以让子类选择性的重写方法,而不是强制性重写接口中的所有方法;

默认方法的出现可以让子类实现接口时选择性的重写方法,而不是强制性重写所有的方法;

  • 默认方法:使用 default 修饰,不可省略,供子类调用或者子类重写。
  • 静态方法:使用 static 修饰,供接口直接调用(接口中,被
  • static修饰的方法不能被继承到子类)。

tips:接口中默认方法的default修饰符不可省略,这点和我们之前学习的权限修饰符不一样;

tips:

1)默认方法可以重写也可以不重写,不重写默认被继承下来;

2)在接口中,静态方法不会被继承下来;

接口的多实现

在继承体系中,一个类只能继承一个父类。而对于接口而言,一个类是可以实现多个接口的,这叫做接口的多实现。并且,一个类能继承一个父类,同时实现多个接口。

class 类名 [extends 父类名] implements 接口名1,接口名2,接口名3... {
    // 重写接口中抽象方法【必须】
    // 重写接口中默认方法【不重名时可选】
} 

接口中,有多个抽象方法时,实现类必须重写所有抽象方法。如果抽象方法有重名的,只需要重写一次。代码如下:

方法优先级

当一个类,既继承一个父类,又实现若干个接口并且该接口(默认方法)存在和类同名方法时,子类就近选择执行父类的成员方法

接口的成员变量

接口中成员变量默认加上public static final修饰,接口中被static修饰的方法不能被继承,但被static修饰的变量(常量)是可以被继承的;

接口和抽象类之间的对比
No.区别点抽象类接口
1定义包含抽象方法的类主要是抽象方法和全局常量的集合
2组成构造方法、抽象方法、普通方法、常量、变量常量、抽象方法、(jdk8.0:默认方法、静态方法)
3使用子类继承抽象类(extends)子类实现接口(implements)
4关系抽象类可以实现多个接口接口不能继承抽象类,但允许继承多个接口
5常见设计模式模板方法简单工厂、工厂方法、代理模式
6对象都通过对象的多态性产生实例化对象都通过对象的多态性产生实例化对象
7局限抽象类有单继承的局限接口没有此局限
8实际作为一个模板是作为一个标准或是表示一种能力
9选择如果抽象类和接口都可以使用的话,优先使用接口,因为避免单继承的局限如果抽象类和接口都可以使用的话,优先使用接口,因为避免单继承的局限
多态

面向对象的三大特性——封装、继承、多态;前两个我们都学习过了,多态也是面向对象中一项重大的特征,多态体现了程序的可扩展性、代码的复用性等;

  • 多态:即事物(对象)存在的多种形态,简称多态;

同一行为,通过不同的事物,可以体现出来的不同的形态;

在这里插入图片描述

可用多态实现

多态的用法

多态的条件

  1. 继承或者实现
  2. 方法的重写
  3. 父类引用指向子类对象

一句话概括多态就是父类引用指向子类对象,在上一章案例中就是父类引用(Animal)指向了子类对象(Dog、Cat),多态一般伴随着重写的出现,调用者是父类,具体执行的方法则是子类(子类重写了父类的方法运行的是子类),因为只有子类的功能才能凸显多态性,不同的子类具备的功能是不一样的,那么有重写肯定就会有继承或者实现;

父类类型 变量名 = new 子类对象;
变量名.方法名();

当使用多态方式调用方法时,首先检查父类中是否有该方法,如果没有,则编译错误;如果有,执行的是子类重写方法。

多态的转型

基本数据类型的自动转换

  1. 自动转换:范围小的赋值给范围大的.自动完成:double d = 10;
  2. 强制转换:范围大的赋值给范围小的,强制转换:int i = (int)6.88;

img

img

向上转型

  • 向上转型:也叫自动类型提升;多态本身是子类类型向父类类型向上转换的过程,这个过程是默认的。当父类引用指向一个子类对象时,便是向上转型。
父类类型  变量名 = new 子类类型();
如:Animal a = new Cat();

向下转型

  • 向下转型:父类类型向子类类型向下转换的过程,这个过程是强制的。一个已经向上转型的子类对象,将父类引用转为子类引用,可以使用强制类型转换的格式,便是向下转型。
子类类型 变量名 = (子类类型) 父类变量名;
Animal animal=new Cat();: Cat c =(Cat) animal;  

注意:在向下转型时,必须保证源类型就是需要转换的类型,如上述中的animal原本就是Cat类型,只不过向上提升为了Animal类型;不能强制把一个Animal转换为Cat,或者把其他类型(Dog)转换为猫;

instanceof关键字

instanceof 是 Java 的一个二元操作符,类似于 ==,>,< 等操作符,它的作用是测试它左边的对象是否是它右边的类的实例,返回 boolean 的数据类型。

为了防止java.lang.ClassCastException异常的出现,我们可以在进行类型转换时先判断一下

public class Demo01 {
    public static void main(String[] args) {

        // 向上转型(自动类型提升)
        Animal animal = new Dog();

        if(animal instanceof Cat){
            Cat cat = (Cat) animal;       // 错误
        }else{
            System.out.println("不是该类型!");
        }
    }
}
内部类

内部类按定义的位置来分为:

  1. 成员内部内,类定义在了成员位置 (类中方法外称为成员位置)
  2. 局部内部类,类定义在方法内
成员内部类
class 外部类{
    // 成员变量
    // 成员方法
    class 内部类{
        // 成员变量
        // 成员方法
    }
}

在描述事物时,若一个事物内部还包含其他事物,就可以使用内部类这种结构。比如,电脑类 Computer 中包含CPU类 CPU ,这时, CPU 就可以使用内部类来描述,定义在成员位置。

内部类可以直接访问外部类的成员,包括私有成员。

创建内部类对象格式:

外部类名.内部类名 对象名 = new 外部类型().new 内部类型();

内外类属性重名问题

package com.dfbz.demo02;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Computer {
    double price = 8000;

    public class CPU {
        double price = 2500;

        public void run() {
            double price = 1000;
            System.out.println("外部类的price: " + Computer.this.price);            // 8000
            System.out.println("内部类的price: " + this.price);                     // 2500
            System.out.println("方法中的price: " + price);                          // 1000
        }
    }
}
局部内部类
  • 局部内部类 :定义在方法中的类。
class 外部类名 {
    数据类型 变量名;
    
    修饰符 返回值类型 方法名(参数列表) {
        // …
        class 内部类 {
            // 成员变量
            // 成员方法
        }
    }
}

使用方式: 在定义好局部内部类后,直接就创建对象

内部类可以直接访问外部类的成员,包括私有成员。

局部内部类编译后仍然是一个独立的类,编译后有$还有一个数字。

编译后类名为:Chinese$1Chopsticks.class

匿名内部类

我们在实现接口时必须定义一个类重写其方法,最终创建子类对象调用实现的方法;这一切的过程似乎我们只在乎最后一个步骤,即创建子类对象调用重写的方法

可整个过程却分为如下几步:

1)定义子类

2)重写接口的方法

3)创建子类,最终调用重写的方法

package com.dfbz.demo01;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo02 {
    public static void main(String[] args) {

        /*
        相当于: 
        class Abc(匿名) implements Chili{
            @Override
            public void chili() {
                System.out.println("湖南线椒~");
            }
        }
        
        // 多态
        Chili abc=new Abc();
        
         */
        
        // 返回的一个Chili的子类(相当于定义了一个匿名的类,并且创建了这个匿名类的实例对象)
        Chili abc = new Chili() {         // abc是Chili接口的子类对象

            // 重写抽象方法
            @Override
            public void chili() {
                System.out.println("湖南线椒~");
            }
        };
        
        Chili abc2 = new Chili() {         // abc是Chili接口的子类对象

            // 重写抽象方法
            @Override
            public void chili() {
                System.out.println("湖南线椒~");
            }
        };

        // 调用重写的方法
        abc.chili();

    }
}
  1. 定义一个没有名字的内部类
  2. 这个类实现了Chili接口
  3. 创建了这个没有名字的类的对象

上述代码类似于帮我们定义了一个类(匿名的),这个类重写了接口的抽象方法,然后为这个匿名的类创建了一个对象,用的是接口来接收(这里使用到了多态);

面向对象思维导图

在这里插入图片描述

在这里插入图片描述

9.Static关键字

static 关键字它可以用来修饰的成员变量成员方法被修饰的成员是属于类的,而不是单单是属于某个对象的。被static修饰的成员由该类的所有实例(对象)共享;

类变量

static 修饰成员变量时,该变量称为类变量。该类的每个对象都共享同一个类变量的值。任何对象都可以更改该类变量的值,但也可以在不创建该类的对象的情况下对类变量进行操作,因为该变量属于类,而不是某个对象。

  • 类变量: 使用 static关键字修饰的成员变量。
静态方法:

static 修饰成员方法时,该方法称为类方法。静态方法在声明中有 static ,建议使用类名来调用,而不需要创建类的对象。调用方式非常简单。

  • 类方法:使用 static关键字修饰的成员方法,习惯称为静态方法

  • 静态方法调用的注意事项:

    • 静态方法可以直接访问类变量(被static修饰的变量)和静态方法。(静态方法能够访问静态资源)
    • 静态方法不能直接访问普通成员变量或成员方法。反之,成员方法可以直接访问类变量或静态方法。
    • 静态方法中,不能使用this关键字。

tips:静态方法只能访问静态成员。

静态原理图解

static 修饰的内容:

  • 是随着类的加载而加载的,且只加载一次。
  • 存储于一块固定的内存区域(静态区),所以,可以直接被类名调用。
  • 它优先于对象存在,所以,可以被所有对象共享。

在这里插入图片描述

静态代码块
  • 静态代码块:定义在成员位置,使用static修饰的代码块{ }。
    • 位置:类中方法外。
    • 执行:随着类的加载执行,而执行且执行一次。

在类初始化的时候就被执行了。

tips:static 关键字,可以修饰变量、方法和代码块。在使用的过程中,其主要目的还是想在不创建对象的情况下,去调用方法。下面将介绍两个工具类,来体现static 方法的便利。

/*
 * 类的成员之四:代码块(或初始化块)
 * 
 * 1. 代码块的作用:用来初始化类、对象
 * 2. 代码块如果有修饰的话,只能使用static
 * 3. 分类:静态代码块  VS  非静态代码块
 * 
 * 4. 静态代码块
 * 	   >内部可以有输出语句
 * 	   >随着类的加载而执行,而且只执行一次
 * 	   >作用:初始化类的信息
 * 	   >如果一个类中定义了多个静态代码块,则按照声明的先后顺序执行
 * 	   >静态代码块的执行要优先于非静态代码块的执行
 * 	   >静态代码块内只能调用静态的属性、静态的方法,不能调用非静态的结构
 * 
 * 5. 非静态代码块
 * 		>内部可以有输出语句
 * 		>随着对象的创建而执行
 * 		>每创建一个对象,就执行一次非静态代码块
 * 		>作用:可以在创建对象时,对对象的属性等进行初始化
 * 		>如果一个类中定义了多个非静态代码块,则按照声明的先后顺序执行
 * 		>非静态代码块内可以调用静态的属性、静态的方法,或非静态的属性、非静态的方法
 * 		>先于构造器执行
 * 
 */
public class BlockTest {
	
	public static void main(String[] args) {
		
//		String desc = Person.desc;
//		System.out.println(desc);
//		
//		Person p1 = new Person();
//		Person p2 = new Person();
//		System.out.println(p1.age);
//		
//		Person.info();
		
		new Person();
	}
}

class Person{
	//属性
	String name;
	
	int age;

	static String desc = "我是一个人";
	
	//构造器
	public Person(){
		System.out.println("我是构造器");
	}
	public Person(String name,int age){
		this.name = name;
		this.age = age;
	}
	
	//非static的代码块
	{
		System.out.println("hello, block - 2");
	}
	{
		System.out.println("hello, block - 1");
		//调用非静态结构
		age = 1;
		eat();
		//调用静态结构
		desc = "我是一个爱学习的人1";
		info();
	}
	
	//static的代码块
	static{
		System.out.println("hello,static block-2");
	}
	static{
		System.out.println("hello,static block-1");
		//调用静态结构
		desc = "我是一个爱学习的人";
		info();
		//不可以调用非静态结构
//		eat();
//		name = "Tom";
	}
	
	//方法
	public void eat(){
		System.out.println("吃饭");
	}
	@Override
	public String toString() {
		return "Person [name=" + name + ", age=" + age + "]";
	}
	public static void info(){
		System.out.println("我是一个快乐的人!");
	}
	
}
程序中成员变量赋值的执行顺序

image-20201024110131546

10.final关键字

fianl是Java中的一个关键字,中文含义是"最终的",fianl可以修饰类、方法、变量等;

修饰类:

  • final修饰的类不能被继承。提高安全性,提高程序的可读性;

修饰方法:

  • final修饰的方法不能被子类重写;

修饰变量:

  • final修饰的变量(成员变量或局部变量)只能被赋值一次;因此被final修饰的变量我们称为常量,通常全大写命名;

面试题:排错

public class Something { 
	public int addOne(final int x) {
		
		return ++x; //报错,因为++x相当于x=x+1,而x是常量形参,不能修改
		// return x + 1;  //编译通过
		
	}
}
public class Something {

	public static void main(String[] args) {
		Other o = new Other();
		new Something().addOne(o);
	}

	public void addOne(final Other o) {
		// o = new Other();  //不通过;重新new了一个对象
		o.i++; //编译通过;o这个对象本身没有改变,只是里面的属性改变了
	}
}

class Other {
	public int i;
}

11.核心API

1.API概述

API(Application Programming Interface,应用程序接口)是一些预先定义的接口(功能),提供给开发人员使用,开发人员无需访问源码,或理解内部工作机制的细节;在JDK中提供了非常丰富的API接口,这些类将底层代码封装起来对外提供非常丰富而又强大的功能;

简单来说API就是提供的一些方法给我们开发时使用,不使用这些API也能开发,只不过API中封装的功能就得我们自己编写了;

java常用包

JDK中为开发开发人员提供了大量可使用的Java类库,类库是以包的形式提供的,这些包统称为API(应用程序接口),下列表格是常用包的功能介绍:

包名简介
java.applet创建applet小程序所需的类
java.awt创建图形化界面和绘制图形图形所属的类
java.io包含能提供多种输入/输出功能的类。
java.util包含一些实用工具类,如定义系统特性、接口的集合框架类、使用与日期日历相关的函数。
java.text包含了一些java格式化相关的类
java.sql包含了java进行JDBC数据库编程的相关类/接口
java.lang包含一些Java语言的核心类,如String、Math、Integer、System和Thread,提供常用功能。
java.nio为输入/输出操作提供缓存的类
java.net提供用于网络应用程序相关的类
javax.sql对于java.sql包进行扩展,提供了一些扩展类,如数据源、XA协议等;
javax.swing针对于AWT功能的升级、加强,提供更多、更丰富的UI组件类;
javax.net对于java.net包的扩展,提供了许多网络应用扩展类;

java包和javax包的区别:javax的x是extension的意思,也就是扩展包,一般是对原有的包进行扩展、增强,在原有的基础上增加了许多功能类;

java.lang包是所有类库的基础,为Java应用程序的运行做支持,java.lang包已经被嵌入到JVM虚拟机中并创建为对象,因此java.lang包下的类不需要使用import语句导入;

Java开发手册

官方在线手册:https://docs.oracle.com/javase/8/docs/api/

在线地址(中文版):https://www.matools.com/api/java8

官方版(英文版):https://docs.oracle.com/javase/8/docs/api/

2.Scanner 类

Java 5 中添加了java.util.Scanner类,这是一个用于扫描输入文本的新的实用程序,可以解析基本类型和字符串的简单文本扫描器;

Scanner in=new Scanner(System.in);

Scanner的构造方法传递的是一个输入流类型,System.in返回的是键盘输入流;关于流的知识我们到后面章节再学习;可以指定输入流的位置,不一定非要从键盘上读取数据,也可以从指定文件的文件流中读取

常用方法

返回值方法名作用
booleannextBoolean()将输入的下一个标记扫描为布尔值,并返回该值。
bytenextByte()将输入的下一个标记扫描为 byte
bytenextByte(int radix)将输入的下一个标记扫描为 byte
doublenextDouble()将输入的下一个标记扫描为 double
floatnextFloat()将输入的下一个标记扫描为 float
intnextInt()将输入的下一个标记扫描为 int
intnextInt(int radix)将输入的下一个标记扫描为 int
Stringnext()获取输入的一行字符串
longnextLong()将输入的下一个标记扫描为 long
shortnextShort()将输入的下一个标记扫描为 short
3.Random类

Random类用于实现生产伪随机数,伪随机数,也就是有规则的随机,并不是真正的随机。在进行随机时,随机算法的起源数字称为种子数(seed),在种子数的基础上进行一定的变换,从而产生需要的随机数字。

相同种子数的Random对象,相同次数生成的随机数字是完全相同的。也就是说,两个种子数相同的Random对象,第一次生成的随机数字完全相同,第二次生成的随机数字也完全相同。这点在生成多个随机数字时需要特别注意。

使用

构造方法介绍
Random()创建一个新的随机数生成器。
Random(long seed)根据seed种子创建一个新的随机数生成器。
Random r1 = new Random();

Random r2 = new Random(20);

常用方法

返回值方法名作用
intnextBoolean()随机生成一个布尔值
doublenextDouble()随机生成一个[0~1)之间的double值
intnextInt()在int的存储范围内随机生成一个int值
intnextInt(int bound)随机生成一个[0~bound)之间的int数
floatnextFloat()随机生成一个[0~1)之间的float值
longnextLong()在long的存储方位内随机生成一个long值
4.Runtime类

java.lang包中定义的Runtime类封装了与虚拟机相关的一些方法,在每一个Java进程之中都会存在有一个Runtime类的对象。Runtime类可以获取当前程序运行信息、退出程序、关闭虚拟机等操作;

获取:

Runtime的创建是由Java虚拟机来完成的,Java程序是不能自己来创建Runtime对象的;

在这里插入图片描述

Runtime的构造方法被私有,外界不能通过构造方法来创建Runtime类;

常用方法:

返回值方法名作用
Runtimepublic static getRuntime()获取当前进程的Runtime实例
longtotalMemory()获取JVM总的内存量(字节)
longmaxMemory()获取JVM使用的最大内存量(字节)
longfreeMemory()获取JVM中的空闲内存量(字节)
voidgc()运行垃圾回收器
Processexec(String command)以单独的经常执行其他应用程序
package com.dfbz.demo01;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo01 {
    public static void main(String[] args) throws Exception{
        Runtime runtime = Runtime.getRuntime();

        long totalMemory = runtime.totalMemory();
        long maxMemory = runtime.maxMemory();
        long freeMemory = runtime.freeMemory();

        System.out.println("所有可用内存空间: " + totalMemory);
        System.out.println("最大可用内存空间: " + maxMemory);
        System.out.println("空余内存空间: " + freeMemory);

        // 执行酷狗应用程序
        runtime.exec("D:\\KuGou\\KGMusic\\KuGou.exe");
   
    }
}
5.Math类

java.lang.Math类包含执行基本数字运算的方法。为了使用简单,其方法大都是静态方法;

常用方法

返回值方法名作用
intstatic abs(int a)获取绝对值
doublestatic ceil(doublea)向上取整
doublestatic floor(double a)向下取整
longstatic round(double a)四舍五入
doublestatic random()返回[0~1)的随机数
6.String类

java.lang.String类是用于创建字符串的,创建后不可变,其值在创建之后将被视为常量。String类封装了字符串类型的相关操作方法;

构造方法介绍
String()创建一个空的字符串对象
String(String original)根据字符串来创建一个字符串对象
String(char[] value)通过字符数组来创建字符串对象
String(byte[] bytes)通过字节数组来构造新的字符串对象
String(byte[] bytes, int offset, int length)通过字节数组一部分来构造新的字符串对象

常用方法:

1)比较功能方法

方法名介绍
boolean equals (Object anObject)将字符串与指定的字符串进行比较
boolean equalsIgnoreCase (String anotherString)将字符串与指定的字符串进行比较,忽略大小写。

2)获取功能方法

方法名介绍
int length ()返回此字符串的长度。
String concat (String str)将指定的字符串连接到该字符串的末尾。
char charAt (int index)返回指定索引处的字符。
int indexOf (String str)返回指定子字符串第一次出现在该字符串内的索引。

3)转换功能方法

方法名介绍
char[] toCharArray ()返回字符串的字符表示形式
byte[] getBytes ()返回字符串的字节表示形式
String toLowerCase()将字符串转换为小写
String toUpperCase()将字符串转换为大写
String replace (CharSequence target, CharSequence replacement)将与target匹配的字符串使用replacement字符串替换。

4)切割功能方法

方法名介绍
String substring (int beginIndex)返回一个子字符串,从beginIndex开始截取字符串到字符串结尾。
String substring (int beginIndex, int endIndex)返回一个子字符串,从beginIndex到
String[] split(String regex)将此字符串按照给定的regex(规则)拆分为字符串数组。
常量池

常量池也是JVM中的一块内存区域,在JDK1.6及以前,常量池是存储在方法区的,在JDK1.7之后,常量池被划分到了堆内存。常量池存储的是普通字面量的常量,其存储的东西只会保存一份

String字符串的比较

创建字符串的方式有很多种,不同的方式创建的字符串在内存中的表现形式是不一样的;因此我们在使用字符串做==比较时需要格外注意;因为==比较的是两个对象的内存地址值;

后序在字符串常量池会进行更深的解析

intern方法
  • public String intern():当调用intern方法时,如果常量池已经包含与equals()方法确定相当的字符串时(比较的是字符串内容而不是地址值),则返回来自常量池的字符串,否则将此字符串添加到常量池中并返回;
7.Object类

java.lang.Object类是Java语言中的根类,即所有类的父类。它中描述的所有方法子类都可以使用。在对象实例化的时候,最终找的父类就是Object。

如果一个类没有特别指定父类, 那么默认则继承自Object类。例如:

public class MyClass /*extends Object*/ {
  	// ...
}

经常会被重写的方法:

方法名介绍
String toString()返回该对象的字符串表示。
boolean equals(Object obj)指示其他某个对象是否与此对象“相等”。
toString方法
方法名介绍
public String toString()返回该对象的字符串表示。我们在输出对象时,默认会调用该对象的toString()方法;

toString方法返回该对象的字符串表示,其实该字符串内容就是对象的类型+@+内存地址值。由于toString方法返回的结果是内存地址,而在开发中,经常需要按照对象的属性得到相应的字符串表现形式,因此也需要重写它。

例如:

public class Person {  
    private String name;
    private int age;

    @Override
    public String toString() {
        return "Person{" + "name='" + name + '\'' + ", age=" + age + '}';
    }

    // 省略构造器与Getter Setter
}
equals方法
方法名作用
public boolean equals(Object obj)指示其他某个对象是否与此对象“相等”。

调用成员方法equals并指定参数为另一个对象,则可以判断这两个对象是否是相同的。这里的“相同”有默认和自定义两种方式。

默认地址比较

如果希望进行对象的内容比较,即所有或指定的部分成员变量相同就判定两个对象相同,则可以覆盖重写equals方法。例如:

import java.util.Objects;

public class Person {	
	private String name;
	private int age;
	
    @Override
    public boolean equals(Object o) {
        // 如果对象地址一样,则认为相同
        if (this == o)
            return true;
        // 如果参数为空,或者类型信息不一样,则认为不同
        if (o == null || getClass() != o.getClass())
            return false;
        // 转换为当前类型
        Person person = (Person) o;
        // 要求基本类型相等,并且将引用类型交给java.util.Objects类的equals静态方法取用结果
        return age == person.age && Objects.equals(name, person.name);
    }
}

这段代码充分考虑了对象为空、类型一致等问题,但方法内容并不唯一

8.Date类

java.util.Date类 表示特定的瞬间,精确到毫秒。

  • public Date():分配Date对象并初始化此对象,以表示分配它的时间(精确到毫秒)。
  • public Date(long date):分配Date对象并初始化此对象,以表示自从标准基准时间(称为“历元(epoch)”,即1970年1月1日00:00:00 GMT)以来的指定毫秒数。

tips:由于我们处于东八区,所以我们的基准时间为1970年1月1日8时0分0秒。

简单来说:使用无参构造,可以自动设置当前系统时间的毫秒时刻;指定long类型的构造参数,可以自定义毫秒时刻。例如:

package com.dfbz.demo01;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */

import java.util.Date;

public class Demo01 {
    public static void main(String[] args) {
        // 创建日期对象,把当前的时间
        System.out.println(new Date()); // Tue Jan 16 14:37:35 CST 2018
        // 创建日期对象,把当前的毫秒值转成日期对象
        System.out.println(new Date(0L)); // Thu Jan 01 08:00:00 CST 1970
    }
}

常用方法

  • int getDate():获取一个月中的日期;
  • int getDay():获取一个星期中的星期几,取值范围:0-6;0代表星期天、1代表星期1、2代表星期2;
  • int getHours():获取小时;
  • int getMinutes():获取分钟;
  • int getMonth():获取0-11的月份,0代表1月,1代表2月以此类推;因此我们获取月份一般使用:getMonth()+1
  • int getSeconds():获取秒;
  • long getTime():返回自1970年1月1日以来到当前日期的毫秒数;
  • int getYear():获取1900年到当前年的年份,如121代表2021年,120代表2020年以此类推…
package com.dfbz.demo01;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */

import java.util.Date;

public class Demo01 {
    public static void main(String[] args) {
        Date date = new Date();

        // 返回1900到现在的年份  如:2021 -> 121; 2020 -> 120
        System.out.println(date.getYear());

        // 返回月份  0-11  0代表1月,1代表2月...
        System.out.println(date.getMonth());

        // 返回当月的第几天
        System.out.println(date.getDate());

        // 小时
        System.out.println(date.getHours());

        // 分钟
        System.out.println(date.getMinutes());

        // 秒
        System.out.println(date.getSeconds());

        // 星期几
        System.out.println(date.getDay());
    }
}
  • void setYear(int year) 设置年,从1900为0年,设置1为1901年,设置20为1920年,设置120为2020年…
  • void setMonth(int month):设置月,范围0-11,0为1月,1为2月,11为12月…
  • void setDate(int date):设置一个月的某天;
  • void setHours(int hours):设置小时
  • void setMinutes(int minutes):设置分钟;
  • void setSeconds(int seconds) :设置秒;
  • void setTime(long time):设置1970年1月1日到现在的毫秒值;
package com.dfbz.demo02;

import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Date;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */

public class Demo01 {
    public static void main(String[] args) {

        Date date=new Date();
        date.setYear(120);          // 2020年
        date.setMonth(10);          // 11月
        date.setDate(8);           // 8号
        date.setHours(20);          // 20时
        date.setMinutes(50);        // 50分
        date.setSeconds(40);        // 40秒

        System.out.println(date);       // Sun Nov 08 20:50:40 CST 2020
    }
}
9.DateFormat类

java.text.DateFormat 是日期/时间格式化子类的抽象类,我们通过这个类可以帮我们完成日期和文本之间的转换,也就是可以在Date对象与String对象之间进行来回转换。

  • 格式化:按照指定的格式,从Date对象转换为String对象。
  • 解析:按照指定的格式,从String对象转换为Date对象。

构造方法

由于DateFormat为抽象类,不能直接使用,所以需要常用的子类java.text.SimpleDateFormat。这个类需要一个模式(格式)来指定格式化或解析的标准。构造方法为:

  • public SimpleDateFormat(String pattern):用给定的模式和默认语言环境的日期格式符号构造SimpleDateFormat。

参数pattern是一个字符串,代表日期时间的自定义格式。

  • 常用的格式规则为:
标识字母(区分大小写)含义
y
M
d
H
m
s
package com.dfbz.demo01;

import java.text.DateFormat;
import java.text.SimpleDateFormat;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */

public class Demo01 {
    public static void main(String[] args) {
        // 对应的日期格式如:2021-06-02 20:04:25
        DateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
    }
}

DateFormat类的常用方法有:

  • public String format(Date date):将Date对象格式化为字符串。
  • public Date parse(String source):将字符串解析为Date对象。

1)format方法

package com.dfbz.demo01;

import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Date;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo02 {
    public static void main(String[] args) {
        Date date = new Date();
        // 创建日期格式化对象,在获取格式化对象时可以指定风格
        DateFormat df = new SimpleDateFormat("yyyy年MM月dd日");
        String str = df.format(date);
        System.out.println(str);            // 2021年06月02日
    }
}

2)parse方法

package com.dfbz.demo01;

import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo03 {
    public static void main(String[] args) throws ParseException {          // 可能会出现ParseException异常

        // 创建日期格式化对象
        DateFormat df = new SimpleDateFormat("yyyy年MM月dd日");

        // 准备一个日期字符串
        String str = "2020年12月11日";

        // 使用格式化对象将字符串变为日期对象
        Date date = df.parse(str);

        System.out.println(date);           // Fri Dec 11 00:00:00 CST 2020
    }
}

请使用日期时间相关的API,计算出一个人已经出生了多少天。

思路:

1.获取当前时间对应的毫秒值

2.获取自己出生日期对应的毫秒值

3.两个时间相减(当前时间– 出生日期)

  • 代码实现:
package com.dfbz.demo01;

import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Scanner;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo04 {
    public static void main(String[] args) throws Exception {
        System.out.println("请输入出生日期 格式 YYYY-MM-dd");

        // 获取出生日期,键盘输入
        String birthdayString = new Scanner(System.in).next();

        // 将字符串日期,转成Date对象
        // 创建SimpleDateFormat对象,写日期模式
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");

        // 调用方法parse,字符串转成日期对象
        Date birthdayDate = sdf.parse(birthdayString);

        // 获取今天的日期对象
        Date todayDate = new Date();

        // 将两个日期转成毫秒值,Date类的方法getTime
        long birthdaySecond = birthdayDate.getTime();
        long todaySecond = todayDate.getTime();
        long liveSecond = todaySecond - birthdaySecond;

        if (liveSecond < 0) {
            System.out.println("还没出生呢");
        } else {
            System.out.println("您活了: " + (liveSecond / 1000 / 60 / 60 / 24) + "天");
        }
    }
}
10.Calendar类

java.util.Calendar是日历类,在Date后出现,替换掉了许多Date的方法。该类将所有可能用到的时间信息封装为静态成员变量,方便获取。日历类就是方便获取各个时间属性的。

在这里插入图片描述

Calendar为抽象类,Calendar类在创建对象时并非直接创建,而是通过静态方法创建,返回子类对象,如下:

  • public static Calendar getInstance():使用默认时区和语言环境获得一个日历
package com.dfbz.demo01;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */

import java.util.Calendar;

public class Demo01 {
    public static void main(String[] args) {

        // 获取日历对象
        Calendar cal = Calendar.getInstance();
    }
}

常用方法

根据Calendar类的API文档,常用方法有:

  • public int get(int field):返回给定日历字段的值。
  • public void set(int field, int value):将给定的日历字段设置为给定值。
  • public abstract void add(int field, int amount):根据日历的规则,为给定的日历字段添加或减去指定的时间量。
  • public Date getTime():返回一个表示此Calendar时间值(从历元到现在的毫秒偏移量)的Date对象。
  • public long getTimeInMillis():获取1970年到当前时间的毫秒值;

Calendar类中提供很多成员常量,代表给定的日历字段:

字段值含义
YEAR
MONTH月(从0开始,可以+1使用)
DATE、DAY_OF_MONTH月中的天(几号)
HOUR时(12小时制)
HOUR_OF_DAY时(24小时制)
MINUTE
SECOND
DAY_OF_WEEK周中的天(周几,0为星期6,1为星期天,2为星期1,3为星期2)
WEEK_OF_MONTH一个月的第几周

1)get方法

package com.dfbz.demo01;

import java.util.Calendar;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo02 {
    public static void main(String[] args) {
        Calendar calendar = Calendar.getInstance();

        System.out.println("年: " + calendar.get(Calendar.YEAR));
        System.out.println("月: " + calendar.get(Calendar.MONTH));       // 0-11,4代表5月,5代表6月
        System.out.println("日: " + calendar.get(Calendar.DAY_OF_MONTH));  // 0-6,0代表星期六,1代表星期天
        System.out.println("星期几: " + calendar.get(Calendar.DAY_OF_WEEK));
        System.out.println("一个月第几周: " + calendar.get(Calendar.WEEK_OF_MONTH));
        System.out.println("时: " + calendar.get(Calendar.HOUR));
        System.out.println("分: " + calendar.get(Calendar.MINUTE));
        System.out.println("秒: " + calendar.get(Calendar.SECOND));
    }
}

2)set方法

set方法主要是设置日期的值:

package com.dfbz.demo01;

import java.util.Calendar;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo03 {
    public static void main(String[] args) {
        Calendar calendar = Calendar.getInstance();

        calendar.set(Calendar.YEAR, 2020);
        calendar.set(Calendar.MONTH, 10);            // 10代表11月
        calendar.set(Calendar.DAY_OF_MONTH, 8);        // 范围1-7,1代表星期天,2代表星期1,3代表星期2...
        calendar.set(Calendar.HOUR, 10);
        calendar.set(Calendar.MINUTE, 18);
        calendar.set(Calendar.SECOND, 50);

        System.out.println("年: " + calendar.get(Calendar.YEAR));
        System.out.println("月: " + calendar.get(Calendar.MONTH));       // 0-11,10代表11月,11代表12月
        System.out.println("日: " + calendar.get(Calendar.DAY_OF_MONTH));
        System.out.println("星期几: " + calendar.get(Calendar.DAY_OF_WEEK));       // 0-6,0代表星期天,1代表星期1,2代表星期2...
        System.out.println("时: " + calendar.get(Calendar.HOUR));
        System.out.println("分: " + calendar.get(Calendar.MINUTE));
        System.out.println("秒: " + calendar.get(Calendar.SECOND));

    }
}

3)add方法

add方法可以对指定日历字段的值进行加减操作,如果第二个参数为正数则加上偏移量,如果为负数则减去偏移量。代码如:

package com.dfbz.demo01;

import java.util.Calendar;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo04 {
    public static void main(String[] args) {
        Calendar cal = Calendar.getInstance();

        // 2021年6月2日
        System.out.println(cal.get(Calendar.YEAR) + "年" + (cal.get(Calendar.MONTH) + 1) + "月" + cal.get(Calendar.DAY_OF_MONTH) + "日");

        // 使用add方法
        cal.add(Calendar.DAY_OF_MONTH, 2); // 加2天

        cal.add(Calendar.YEAR, -3); // 减3年

        // 2018年6月4日
        System.out.println(cal.get(Calendar.YEAR) + "年" + (cal.get(Calendar.MONTH) + 1) + "月" + cal.get(Calendar.DAY_OF_MONTH) + "日");
    }
}

4)getTime方法

Calendar中的getTime方法并不是获取毫秒时刻,而是拿到对应的Date对象。

package com.dfbz.demo01;

import java.util.Calendar;
import java.util.Date;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo05 {
    public static void main(String[] args) {
        Calendar cal = Calendar.getInstance();
        Date date = cal.getTime();
        System.out.println(date);           // Wed Jun 02 20:56:16 CST 2021
    }
}

tips:

  • 西方星期的开始为周日,中国为周一;
  • 在Calendar类中,月份的表示是以0-11代表1-12月;
  • 日期是有大小关系的,时间靠后,时间越大;
11.StringBuilder

之前在学习String类的时候我们就说到过:String类是不可变类,即一个String对象被创建以后,包含在这个对象中的字符序列是不可改变的,直至这个对象被销毁。

java.lang.StringBuilder又称为可变字符序列,是个字符串的缓冲区,即它是一个容器,容器中可以装很多字符串。并且能够对其中的字符串进行各种操作。它的内部拥有一个数组用来存放字符串内容,进行字符串拼接时,直接在数组中加入新内容。StringBuilder会自动维护数组的扩容。原理如下图所示:(默认16字符空间,超过自动扩充)

在这里插入图片描述

根据StringBuilder的API文档,常用构造方法有2个:

  • public StringBuilder():构造一个空的StringBuilder容器。
  • public StringBuilder(String str):构造一个StringBuilder容器,并将字符串添加进去。

StringBuilder常用的方法有2个:

  • public StringBuilder append(...):添加任意类型数据的字符串形式,并返回当前对象自身。
  • public String reverse():将当前对象的字符串反转,并返回当前对象自身。
  • public String toString():将当前StringBuilder对象转换为String对象。
12.StringBuffer
13.System 类

java.lang.System是Java中的系统类,主要用于获取系统的属性数据,没有构造方法。类中提供了大量的静态方法,可以获取与系统相关的信息或系统级操作,在System类的API文档中,常用的方法有:

  • public static long currentTimeMillis():返回以毫秒为单位的当前时间。
  • public static void arraycopy(Object src, int srcPos, Object dest, int destPos, int length):将数组中指定的数据拷贝到另一个数组中。
  • public static exit(int status):该方法用于退出jvm,如果参数是0表示正常退出jvm,非0表示异常退出jvm。
  • public static getProperties():该方法用于获取系统的所有属性。属性分为键和值两部分,它的返回值是Properties。
  • public static gc():该方法用来建议jvm赶快启动垃圾回收器回收垃圾。只是建议启动,但是Jvm是否启动又是另外一回事。

currentTimeMillis

通过System的currentTimeMillis()方法我们可以获取当前时间的毫秒值,有了毫秒值我们可以将其转换为Date对象、Calendar对象等进行日期的运算;

package com.dfbz.demo01;

import java.util.Calendar;
import java.util.Date;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo01 {
    public static void main(String[] args) {
        //  获取当前时间毫秒值
        long currTime = System.currentTimeMillis();

        // 有了毫秒值之后可以将其转换为Date
        Date date = new Date(currTime);

        // 获取日历类
        Calendar calendar = Calendar.getInstance();

        // 设置毫秒值
        calendar.setTimeInMillis(currTime);

        System.out.println(date);          
        System.out.println(calendar.get(Calendar.YEAR));
        System.out.println(calendar.get(Calendar.MONTH) + 1);
        System.out.println(calendar.get(Calendar.DAY_OF_MONTH));
    }
}

arraycopy

  • public static void arraycopy(Object src, int srcPos, Object dest, int destPos, int length):将数组中指定的数据拷贝到另一个数组中。
  • 数组的拷贝动作是系统级的,性能很高。System.arraycopy方法具有5个参数,含义分别为:
参数序号参数名称参数类型参数含义
1srcObject源数组
2srcPosint源数组索引起始位置
3destObject目标数组
4destPosint目标数组索引起始位置
5lengthint复制元素个数

gc

在Java程序运行时,如果一个对象没有再被引用,或者被指向为null,那么这个对象就应该被标记为"垃圾",Jvm会根据某种算法来定时清除这些对象来释放内存;我们也可以通过System.gc()方法告诉Jvm垃圾回收器,叫他来清除一下垃圾;但gc方法只是建议Jvm赶快启动垃圾回收器回收垃圾。Jvm是否启动又是另外一回事。

package com.dfbz.demo01;

import java.util.Properties;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo04 {
    public static void main(String[] args) {

        for (int i = 0; i < 10; i++) {
            // 使用匿名对象(没有对象保存返回值)
            new Student();

            // 触发垃圾回收器
            System.gc();
        }
    }
}

class Student {
    @Override
    protected void finalize() throws Throwable {
        System.out.println("垃圾回收器执行了");
    }
}

finalize():是Object类的一个方法,如果一个对象被垃圾回收 器回收的时候,会先调用对象的finalize()方法。

14.包装类

Java提供了两个类型系统,基本类型与引用类型,使用基本类型在于效率,然而很多情况,会创建对象使用,因为对象可以做更多的功能,如果想要我们的基本类型像对象一样操作,就可以使用基本类型对应的包装类,如下:

基本类型对应的包装类(位于java.lang包中)
byteByte
shortShort
intInteger
longLong
floatFloat
doubleDouble
charCharacter
booleanBoolean
装箱与拆箱

基本类型与对应的包装类对象之间,来回转换的过程称为”装箱“与”拆箱“:

  • 装箱:从基本类型转换为对应的包装类对象。
  • 拆箱:从包装类对象转换为对应的基本类型。

用Integer与 int为例:

基本数值---->包装对象

Integer i = new Integer(4);//使用构造函数函数
Integer ii = Integer.valueOf(4);//使用包装类中的valueOf方法

包装对象---->基本数值

int num = i.intValue();
自动装箱与自动拆箱

由于我们经常要做基本类型与包装类之间的转换,从Java 5(JDK 1.5)开始,基本类型与包装类的装箱、拆箱动作可以自动完成。例如:

Integer i = 4;		// 自动装箱。相当于Integer i = Integer.valueOf(4);
i = i + 5;			// 等号右边:将i对象转成基本数值(自动拆箱) i.intValue() + 5;
					// 加法运算完成后,再次装箱,把基本数值转成对象。
基本类型与字符串之间的转换

基本类型转换为String

基本类型直接与””相连接即可;如:10+""

String转换成对应的基本类型

  • public static byte parseByte(String s):将字符串参数转换为对应的byte基本类型。
  • public static short parseShort(String s):将字符串参数转换为对应的short基本类型。
  • public static int parseInt(String s):将字符串参数转换为对应的int基本类型。
  • public static long parseLong(String s):将字符串参数转换为对应的long基本类型。
  • public static float parseFloat(String s):将字符串参数转换为对应的float基本类型。
  • public static double parseDouble(String s):将字符串参数转换为对应的double基本类型。
  • public static boolean parseBoolean(String s):将字符串参数转换为对应的boolean基本类型。

注意:如果字符串参数的内容无法正确转换为对应的基本类型,则会抛出java.lang.NumberFormatException异常。

15.BigInteger 与 BigDecimal
BigInteger类
  • Integer类作为int的包装类,能存储的最大整型值为2^31 -1,Long类也是有限的, 最大为2^63 -1。如果要表示再大的整数,不管是基本数据类型还是他们的包装类 都无能为力,更不用说进行运算了。

  • java.math包的BigInteger可以表示不可变的任意精度的整数。BigInteger 提供 所有 Java 的基本整数操作符的对应物,并提供 java.lang.Math 的所有相关方法。 另外,BigInteger 还提供以下运算:模算术、GCD 计算、质数测试、素数生成、 位操作以及一些其他操作。

  • 构造器

    • BigInteger(String val):根据字符串构建BigInteger对象
  • 常用方法

image-20201027115048295

BigDecimal类
  • 一般的Float类和Double类可以用来做科学计算或工程计算,但在商业计算中,要求数字精度比较高,故用到java.math.BigDecimal类。

  • BigDecimal类支持不可变的、任意精度的有符号十进制定点数。

  • 构造器

    • public BigDecimal(double val)
    • public BigDecimal(String val)
  • 常用方法

    • public BigDecimal add(BigDecimal augend)
    • public BigDecimal subtract(BigDecimal subtrahend)
    • public BigDecimal multiply(BigDecimal multiplicand)
    • public BigDecimal divide(BigDecimal divisor, int scale, int roundingMode)
16.Collections工具类

Collections 是一个操作 Set、List 和 Map 等集合的工具类。+

Collections 中提供了一系列静态的方法对集合元素进行排序、查询和修改等操作,还提供了对集合对象设置不可变、对集合对象实现同步控制等方法。

排序操作:(均为static方法)

  1. reverse(List):反转 List 中元素的顺序
  2. shuffle(List):对 List 集合元素进行随机排序
  3. sort(List):根据元素的自然顺序对指定 List 集合元素按升序排序
  4. sort(List,Comparator):根据指定的 Comparator 产生的顺序对 List 集合元素进行排序
  5. swap(List,int i, int j):将指定 list 集合中的 i 处元素和 j 处元素进行交换

查找、替换

  1. Object max(Collection):根据元素的自然顺序,返回给定集合中的最大元素
  2. Object max(Collection,Comparator):根据 Comparator 指定的顺序,返回给定集合中的最大元素
  3. Object min(Collection)
  4. Object min(Collection,Comparator)
  5. int frequency(Collection,Object):返回指定集合中指定元素的出现次数
  6. void copy(List dest,List src):将src中的内容复制到dest中。**这里要注意dest的长度要大于src的长度。**一般使用下列语句保证dest的长度和src长度一致,这样才能copy成功。
  7. List dest = Arrays.asList(new Object[list.size()])
  8. boolean replaceAll(List list,Object oldVal,Object newVal)`:使用新值替换List 对象的所有旧值

Collections 类中提供了多个 synchronizedXxx()方法,该方法可使将指定集合包装成线程同步的集合,从而可以解决多线程并发访问集合时的线程安全问题

17.

12.集合

1.概述

集合和我们之前学习的数组类似,也是用于存储元素的,也是一种容器;不同的是集合是一个可变长的容器,数组则在创建时候就分配好了大小,不可改变,此外集合的功能要比数组强大的多,底层实现也非常复杂,类型也非常多,不同类型的集合又提供不同的功能;

数组和集合的区别:

  • 1)数组的长度是固定的,集合的长度是可变的。
  • 2)数组中存储的是同一类型的元素,可以存储基本数据类型值。集合存储的都是对象。而且对象的类型可以不一致。在开发中一般当对象多的时候,使用集合进行存储;
  • 3)集合的种类非常多,不同的集合底层采用的数据结构也大不相同,因此集合的功能更加丰富;

数组在内存存储方面的缺点:

  • 数组初始化后,长度确定。
  • 数组声明的类型,就决定了进行元素初始化时的类型。
  • 总结来说就是,长度固定,类型单一

数组在存储数据时的弊端

  • 数组初始化以后,长度就不可变了,不便于扩展
  • 数组中提供的属性和方法少,不便于进行添加、删除、插入等操作,且效率不高。同时无法直接获取存储元素的个数
  • 数组存储的数据是有序的、可以重复的。对于无序的不可重复的需求不能满足。存储数据的特点单一
  • 总结来说:存储数据类型单一,操作性差、不利于扩展

集合体系

集合分为两大类,一类是单列集合;一类是双列集合,两类的底层父接口下有非常多的实现类,不同的实现类,底层所采用的数据结构和算法都是不一样的;

所有的集合框架都包含如下内容:

  • **接口:**是代表集合的抽象数据类型。例如 Collection、List、Set、Map 等。之所以定义多个接口,是为了以不同的方式操作集合对象

  • **实现(类):**是集合接口的具体实现。从本质上讲,它们是可重复使用的数据结构,例如:ArrayList、LinkedList、HashSet、HashMap。

  • **算法:**是实现集合接口的对象里的方法执行的一些有用的计算,例如:搜索和排序。这些算法被称为多态,那是因为相同的方法可以在相似的接口上有着不同的实现。

img

单列集合

单列集合的顶层父接口是java.util.Collection类,这个类中具备的方法下层接口或者类都会具备此方法;

在这里插入图片描述

img

双列集合

双列集合的顶层接口是java.util.Map类;

在这里插入图片描述

img

2.Collection集合

Collection是单列集合的根接口,Collection 接口有 3 种子类型集合: ListSetQueue,再下面是一些抽象类,最后是具体实现类,常用的有 ArrayList、LinkedList、HashSet、LinkedHashSet、ArrayBlockingQueue等;也就是说Collection中包含的方法这些类中都会具备;

常用方法

Collection是所有单列集合的父接口,因此在Collection中定义了单列集合(List和Set)通用的一些方法,这些方法可用于操作所有的单列集合。方法如下:

  • public boolean add(E e): 把给定的对象添加到当前集合中 。
  • public void clear() :清空集合中所有的元素。
  • public boolean remove(E e): 把给定的对象在当前集合中删除。
  • public boolean contains(E e): 判断当前集合中是否包含给定的对象。
  • public boolean isEmpty(): 判断当前集合是否为空。
  • public int size(): 返回集合中元素的个数。
  • public Object[] toArray(): 把集合中的元素,存储到数组中。
4.Iterator迭代器
Iterator接口

在程序开发中,经常需要遍历集合中的所有元素。针对这种需求,JDK专门提供了一个接口java.util.IteratorIterator接口也是Java集合中的一员,但它与CollectionMap接口有所不同,Collection接口与Map接口主要用于存储元素,而Iterator主要用于迭代访问(即遍历)Collection中的元素,因此Iterator对象也被称为迭代器。

迭代:即Collection集合元素的通用获取方式。在取元素之前先要判断集合中有没有元素,如果有,就把这个元素取出来,继续在判断,如果还有就再取出出来。一直把集合中的所有元素全部取出。这种取出方式专业术语称为迭代。

  • public E next():返回迭代的下一个元素。
  • public boolean hasNext():如果仍有元素可以迭代,则返回 true。
package com.dfbz.demo01;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Iterator;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo02 {
    public static void main(String[] args) {
        // 使用多态方式 创建对象
        Collection<String> cities = new ArrayList<String>();

        // 添加元素到集合
        cities.add("黑龙江哈尔滨");
        cities.add("吉林长春");
        cities.add("辽宁沈阳");

        //使用迭代器 遍历   每个集合对象都有自己的迭代器
        Iterator<String> it = cities.iterator();
        
        //  泛型指的是 迭代出 元素的数据类型
        while (it.hasNext()) { //判断是否有迭代元素
            String s = it.next();//获取迭代出的元素
            System.out.println(s);
        }
    }
}

迭代器内部有个指针,默认指向第0行数据(没有指向任何数据),可以通过hashNext()方法来判断指针下一位指向的行是否有数据,通过next()方法可以让指针往下移动,通过hashNext()和next()方法我们可以利用while循环来变量整个迭代器的内容;

在这里插入图片描述

  • Iterator 仅用于遍历集合,Iterator 本身并不提供承装对象的能力。如果需要创建Iterator 对象,则必须有一个被迭代的集合。
  • 集合对象每次调用iterator()方法都得到一个全新的迭代器对象,默认游标都在集合的第一个元素之前。

迭代器的原理

  • 生成迭代器对象,相当于创建了一个指针,指针指向集合中第一个元素的前面
  • hasNext()方法:判断是否还有下一个元素
  • next()方法:指针下移,将下移义后的集合位置上的元素返回
5.foreach

foreach也称增强for,是JDK1.5以后出来的一个高级for循环,专门用来遍历数组和集合的。它的内部原理其实是个Iterator迭代器,所以在遍历的过程中,不能对集合中的元素进行增删操作。

for(元素的数据类型  变量 : Collection集合或数组){ 
  	//写操作代码
}

13.单列集合

1.List集合(有序)
概述

List接口是单列集合的一个重要分支,下面主要有两个实现 ArrayListLinkedListList类型接口的特点是存储的元素是有序的,即存放进去是什么顺序,取出来还是什么顺序,也就是基于线性存储,可以看作是一个可变数组;

List接口特点:

  1. List接口存储的数据是有序排列的,原来存储的时候是什么顺序,取出来就什么顺序(Set接口存储的是无序的);
  2. List接口为存储的每一个元素都分配了一个索引,通过索引我们可以精确的来访问某一个指定的元素;
  3. List接口存储的数据允许存在重复,这与Set接口不同(Set接口不允许存储相同的元素);

List集合类中元素有序、且可重复,集合中的每个元素都有其对应的顺序索引。List容器中的元素都对应一个整数型的序号记载其在容器中的位置,可以根据序号存取容器中的元素。这和数组特别相像,因此也常将List接口称作动态数组,长度可变。

List接口常用方法

List是Collection的子接口,因此Collection中存在的方法List都存在;因为List的特点是存在索引,因此除此之外List还添加了许多与索引相关的方法;

  • public boolean add(int index, E element): 将指定的元素,添加到该集合中的指定位置上。
  • public E get(int index):返回集合中指定位置的元素。
  • public boolean remove(int index): 移除列表中指定位置的元素, 返回的是被移除的元素。
  • public E set(int index, E element):用指定元素替换集合中指定位置的元素,返回值的更新前的元素。

注意这里的remove()方法是按照索引值删除元素的。Collection接口中的remove()方法是按照对象来删除的。属于方法重载。

ArrayList、LinkedList、Vector的异同

相同之处:

ArrayList/LinkedList/Vector三个都是List接口的实现类,其对象中的元素都具有List集合类的特征:元素有序、且可重复

不同之处:

  • Vector类是最早用于存储长度可变的数组元素的类,它在List接口出现之前就已将存在了。而ArrayList/LinkedList都是List接口出现后作为List接口的实现类存在的。List接口出现后,三者都作为其实现类存在。
  • ArrayList是List接口的主要实现类,它是线程不安全的,但效率高。其底层是基于动态数组Object[] elementData存储数据的。
  • LinkedList也是List接口的实现类,也是线程不安全的。其底层是基于链表存储的。对于随机访问get和set,ArrayList觉得优于LinkedList,因为LinkedList要移动指针。对于新增和删除操作add(特指插入)和remove,LinkedList比较占优势,因为ArrayList要移动数据
  • Vector作为List的古老实现类,它是线程安全的。其底层是基于动态数组Object[] elementData存储数据的。它与ArrayList唯一的区别在于Vector是同步类,属于强同步类,因此效率较低。Vector还有一个子类Stack(栈)。
  • 关于扩容:ArrayList和Vector的底层都是用一个对象数组来存储数据的,之所以可以存储长度不受限制。是因为当实例化一个类对象时,其底层会创建一个长度比初始化元素数量多默认值的一个数组,当有数据需要存储时,就依次存储在数组后面。当存储数据的数量超过数组长度时,就会创建一个新的长度足够的数组,并将原数组中的元素赋给新数组。这个过程称之为扩容对于Vector类,新数组的长度一般为原数组大小的2倍空间,而ArrayList是1.5倍
ArrayList 集合

底层基于Object[] elementData,扩容后新数组长度是原来的1.5倍

LinkedList 集合

LinkedList底层采用链表这种数据结构来实现的,因此增删速度较快,查询速度较慢;

LinkedList底层是一个双向链表,对比与单向链表多了一个指针指向上一个元素;

在这里插入图片描述

LinkedList常用方法

实际开发中对一个集合元素的添加与删除经常涉及到首尾操作,而LinkedList提供了大量首尾操作的方法。这些方法我们作为了解即可:

  • public void addFirst(E e):将指定元素插入此列表的开头。
  • public void addLast(E e):将指定元素添加到此列表的结尾。
  • public E getFirst():返回此列表的第一个元素。
  • public E getLast():返回此列表的最后一个元素。
  • public E removeFirst():移除并返回此列表的第一个元素。
  • public E removeLast():移除并返回此列表的最后一个元素。
  • public E pop():从此列表所表示的堆栈处弹出一个元素。
  • public void push(E e):将元素推入此列表所表示的堆栈。
  • public boolean isEmpty():如果列表不包含元素,则返回true。

LinkedList是List的子类,List中的方法LinkedList都是可以使用,这里就不做详细介绍;我们只需要了解LinkedList的特有方法即可。

在开发时,LinkedList集合也可以作为栈、队列的结构使用。

栈相关方法

  • public void push(E e):将元素推入此列表所表示的堆栈(栈顶),类似于addFirst()。

  • public E pop():从此列表所表示的堆栈(栈顶)处弹出一个元素,类似于removeFirst()。

队列相关方法

  • public boolean offer(E e):将元素添加到队列尾部,类似于addLast()。
  • public boolean offerFirst(E e):将元素添加到队列头部,类似于addFirst()。
  • public boolean offerLast(E e):将元素添加到队列尾部,类似于addLast()。
2.Set集合
Set集合概述

Set接口和List接口一样,继承与Collection接口,也是一个单列集合;Set集合中的方法和Collection基本一致;并没有对Collection接口进行功能上的扩充,只是底层实现的方式不同了(采用的数据结构不一样);

  • Set 接口实例存储的是无序的,不重复的数据。Set 集合不允许包含相同的元素,如果试把两个相同的元素加入同一个Set 集合中,则添加操作失败。
  • Set 判断两个对象是否相同不是使用 == 运算符,而是根据 equals() 方法。
  • Set接口没有提供额外的方法
  • JDK API中List接口的实现类常用的有:HashSet、LinkedHashSet和TreeSet
HashSet、LinkedHashSet、TreeSet的区别
  • HashSet:作为Set接口的主要实现类。线程不安全,可以存储null值。该类具有很好的存取、查找、删除性能
  • LinkedHashSet:是HashSet的子类,遍历其内部数据时,可以按照插入的方式进行。多用于需要频繁遍历的场合。
  • TreeSet:可以按照添加对象的指定属性进行排序。
HashSet

HashSet是Set接口的一个实现类,它所存储的元素是不可重复的,并且元素都是无序的(即存取顺序不一致)。java.util.HashSet底层的实现其实是一个HashMap支持;

HashSet底层数据结构在JDK8做了一次重大升级,JDK8之前采用的是Hsah表,也就是数组+链表来实现;到了JDK8之后采用数据+链表+红黑树来实现;

HashSet的底层也是数组,初始容量为16,当使用率超过0.75,就会扩容为原来的2倍。

我们知道hash表数据结构的特点是:根据元素(key)的hash值计算存储的位置,当hash值计算的槽位在同一个时,那么会导致同一个槽位存储的元素过多,会导致查询速度降低;

img

img

上述图中是一个hash表数据结构(数组+链表),也是JDK8之前的HashSet底层存储结构;

当存储的元素越来越多,hash也越来越多时,势必造成链表长度非常长,查找元素时性能会变得很低;在JDK8中当链表长度大于指定阈值8,并且数组容量达到了64时,将链表转换为红黑树,这样大大降低了查询的时间;

在这里插入图片描述

HashSet去重原理

HashSet是根据对象的哈希值来确定元素在集合中的存储位置,因此具有良好的存取和查找性能。保证元素唯一性的方式依赖于:hashCodeequals方法。

HashSet在存储元素时,都会调用对象的hashCode()方法计算该对象的hash值,如果hash值与集合中的其他元素一样,则调用equals方法对冲突的元素进行对比,如果equals方法返回true,说明两个对象是一致的,HashSet并不会存储该对象,反之则存储该元素;

一般情况下,不同对象的hash值计算出来的结果是不一样的,但还是有某些情况下,同一个对象的hash值计算成了同一个,这种情况我们称为hash冲突;当hash冲突时,HashSet会调用equals方法进行对比,默认情况下equals方法对比的是对象内存地址值,因此如果对象不是同一个,equals返回的都是false;

在这里插入图片描述

一般情况下不同的对象的hashCode都是不一样的:

package com.dfbz.demo01;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo01 {
    public static void main(String[] args) {

        System.out.println("hello".hashCode());         // 99162322
        System.out.println("hello world".hashCode());   // 1794106052
        System.out.println("abc".hashCode());           // 96354
        System.out.println("ABC".hashCode());           // 64578
    }
}

如果将上述几个字符串存储到HashSet中,仅通过hashCode就可以判断这几个对象应该存储,就不需要调用equals方法进行对比了,效率高;

tips:做一次hash算法的运行远比==去对对比内存地址值要快得多;

在自定义对象时,需要我们对hashcod和equal重写,使去重满足我们制定的规则

@Override
public boolean equals(Object o) {

    System.out.println("equals执行了...");
    // 为了测试,这样固定死是毫无意义的
    return false;
}

@Override
public int hashCode() {
    
    System.out.println("hashCode执行了...");
    // 为了测试,这样固定死是毫无意义的
    return 1;
}

TreeSet 集合

与HashSet一样,TreeSet存储的元素也是无序,并且唯一;存储的元素虽然是无序的,但TreeSet可以根据排序规则对存储的元素进行排序,可以对集合中的元素提供排序功能;需要注意的是TreeSet存储的元素必须实现了Comparable接口,否则将抛出ClasscastException

TreeSet的特点:

  1. TreeSet每存储一个元素都会将该元素提升为Comparable类型,如果元素未实现Comparable接口,则抛出ClasscastException异常;
  2. 存储的数据是无序的,即存取顺序不一致,但TreeSet提供排序功能;
  3. 存储的元素不再是唯一,具体结果根据compareTo方法来决定;

Comparable接口

Comparable接口是一个比较器,通过其compareTo方法进行两个对象的比较,具体比较的内容、规则等可以由开发人员来决定,compare方法的返回值有3类;

TreeSet底层依赖平衡二叉树算法,TreeSet得到CompareTo方法三类不同的值的含义如下:

  • 1)正数:返回正数代表存储在树的右边
  • 2)负数:存储在树的左边
  • 3)0:不存储这个元素
public interface Comparable<T> {
    public int compareTo(T o);
}
public class Person implements Comparable {

    private String name;
    private Integer age;

    @Override
    public int compareTo(Object o) {

        Person person = (Person) o;

        return this.getAge() - person.getAge();
    }
    
    // get/set/toString...
}

口诀:

  • 正序(从小到大):this-传递的对象
  • 倒序(从大到小):传递的对象-this

返回值大于0就将元素存储在右节点,小于0则存在左节点

TreeSet存储原理
  • TreeSet 是 SortedSet 接口的实现类,TreeSet 可以按照添加对象的指定属性进行排序,这就要求TreeSet中的数据必须是相同类的对象
  • TreeSet和TreeMap底层都是使用红黑树结构存储数据。
  • TreeSet 两种排序方法:自然排序定制排序,也就是Comparable借口和Comparator接口。默认情况下,TreeSet 采用自然排序。
  1. 自然排序中,比较两个对象是否相同的标准是,调用对象所在类的compareTo()方法,而不是equals方法;
  2. 定制排序中,比较两个对象是否相同的标准是,调用对象所在类的compare()方法,而不是equals方法;
LinkendHashSet 集合

LinkedHash继承与HashSet,和HashSet一样,同样是根据元素的hashCode值来决定元素的存储位置,其底层和HashSet所采用的数据结构是一致的;

与HashSet不同的是,**LinkedHsahSet底层新增了一个双向链表来保存节点的访问顺序,因此LinkedHashSet存储的元素是有序的;**当遍历该集合时候,LinkedHashSet将会以元素的添加顺序访问集合的元素。LinkedHashSet在迭代访问Set中的全部元素时,性能比HashSet好,但是插入时性能稍微弱于HashSet。

img

特点:存取有序且去重;

14.双列集合

img

1.Map双列集合
概述

因此 Map 集合里保存着两组值,一组值用于保存 Map 里的 Key,另外一组用于保存 Map 里的 Value,Map 中的 key 和 value 都可以是任何引用类型的数据;Map 中的 Key 不允许重复,HashMap对key的存取和 HashSet 一样,仍然采用的是哈希算法,所以如果使用 自定类作为 Map 的键对象,必须复写 equals 和 hashCode 方法。

我们之前说到过,HashSet底层实质就是一个HashMap;

在这里插入图片描述

查看HashSet的add方法源码:

HashSet的存储的元素都是HashMap的Key,此HashMap的value总是一个固定的Object;

在这里插入图片描述

所以,HashSet的去重原理实质上指的就是HashMap的Key的去重原理;

tips:Collection接口是单列集合的顶层接口,Map则是双列集合的顶层接口;

2.Map接口的共有方法

Map是所有双列集合的顶层父类,因此Map中具备的是所有双列集合的共性方法;常用的方法如下:

  • public V put(K key, V value): 把指定的键与指定的值添加到Map集合中。
  • public V remove(Object key): 把指定的键 所对应的键值对元素 在Map集合中删除,返回被删除元素的值。
  • public V get(Object key) 根据指定的键,在Map集合中获取对应的值。
  • boolean containsKey(Object key) 判断集合中是否包含指定的键。
  • public Set<K> keySet(): 获取Map集合中所有的键,存储到Set集合中。
  • public Set<Map.Entry<K,V>> entrySet(): 获取到Map集合中所有的键值对对象的集合(Set集合)。

数据的遍历

方法:

  • public V get(Object key) 根据指定的键,在Map集合中获取对应的值。
  • public Set<K> keySet(): 获取Map集合中所有的键,存储到Set集合中。

步骤:

  • 1)根据keySet()方法获取所有key的集合
  • 2)通过foreach方法遍历key集合,拿到每一个key
  • 3)通过get()方法,传递key获取key对应的value;
Entry对象

Map集合中几条记录存储的是两个对象,一个是key,一个是value,这两个对象加起来是map集合中的一条记录,也叫一个记录项;这个记录项在Java中被Entry对象所描述;一个Entry对象中包含有两个值,一个是key,另一个则是key对应的value,因此一个Map对象我们可以看做是多个Entry对象的集合,即一个Set<Entry>对象;

在这里插入图片描述

Entry是一个接口,是Map接口中的一个内部接口,源码如下:

interface Entry<K,V> {
        
        K getKey();

        V getValue();

        V setValue(V value);
    ....
}

HashMap中则提供了Node类对Entry提供了实现,可以看到一个Entry对象(Node对象)中包含有key、value等值:

在这里插入图片描述

Map接口中提供有方法获取该Map集合的Entry集合对象:

  • public Set<Map.Entry<K,V>> entrySet(): 获取到Map集合中所有的键值对对象的集合(Set集合)。
3.Map接口的去重

我们知道HashSet底层就是依靠HashMap的key去重原理来是实现的,因此Map接口的HashMap、LinkedHashMap等接口的去重都是和HashSet、LinkedHashSet一致;

在这里插入图片描述

4.Map的实现类比较

Map接口的常用实现类:HashMap、TreeMap、LinkedHashMap和Hashtable、Properties。其中,HashMap是 Map 接口使用频率最高的实现类

  • Hashtable类是最早用于存储key-value对的类,它在Map接口出现之前就已将存在了。而HashMap、TreeMap、LinkedHashMap都是Map接口出现后作为Map接口的实现类存在的。Map接口出现后,三者都作为其实现类存在。

  • HashMap是Map接口的主要实现类,它是线程不安全的,但效率高。它可以存储null的key-value对,而Hashtable是不能存储null的key-value对的。该类具有很好的存取、查找、删除性能。JDK8之后,其底层是数组+链表+红黑树来存储数据的

  • LinkedHashMap:是HashMap的子类,主要区别是在原有的HashMap底层结构的的基础上对每个元素都添加了一对引用,分别指向该元素前一个元素和后一个元素。这样在遍历其内部数据时,可以按照插入的方式进行,因此多用于需要频繁遍历的场合。

  • TreeMap:可以保证按照添加的key-value对进行排序,实现排序遍历。此时主要是按照key值进行自然排序或者定制排序的。其底层存储结构是红黑树。

  • Hashtable作为Map的古老实现类,它是线程安全的。Hashtable实现原理和HashMap相同,功能相同。底层都使用哈希表结构,查询速度快,很多情况下可以互用。它与HashMap的区别在于Hashtable是同步类,属于强同步类,因此效率较低。

    尽管它线程安全,但是在多线程问题时,还是会使用HashMap而不是Hashtable,至于HashMap的线程不安全问题,一般会将其转为Collection来解决。还有一个子类Properties

  • PropertiesHashtable一个子类。常用来处理配置文件,其keyvalue都是String类型。

5.HashMap

Jdk8之前,其底层是用数组+链表结构来存储数据的。与HashSet类似。

JDK8之后,其底层是数组+链表+红黑树来存储数据的。数据个数超过8个且当前node[]数组的长度超过64时,当前位置上的所有数据改为红黑树存储。(为了便于查找)

jdk8之前:初始数组大小为16,不够时扩容,每次扩容为原数组的两倍

img

jdk8之后:初始数组长度为16,当使用率超过0.75,扩容两倍

img

与HashSet的关系

HashSet的底层存储用的其实也是HashMap,相当于只用HashMap的key就构成了HashSet。不过HashSet里没有红黑树。那么对应的value是什么呢?源码中将HashSet时的Map中的value设为一个固定的Object对象obj,即所有的Key都指向这个空的对象。

6.LinkedHashMap

我们之前在学习LinkedHashSet时说过,LinkedHashSet是继承与HashSet的,在HashSet底层的基础上增加了一个循环链表,因此LinkedHashSet除了具备HashSet的特点外(唯一),存储的元素还是有序的;

LinkedHashMap继承与HashMap,并且LinkedHashSet底层就是借助于LinkedHashMap来实现的;

与HashMap相比,LinkedHashMap中node数组上的每个位置上的元素在存储时会多两个引用,一个指向它前一个进来的元素,一个指向后一个添加进来的元素,这样就出现了类似于保持了和插入顺序一致的排列的效果。实际上内部还是无序的。

对于频繁遍历Map中的元素的时候,LinkedHashMap拥有较好的性能。

7.TreeMap

TreeMap也是TreeSet的底层实现,创建TreeSet的同时也创建了一个TreeMap,在往TreeSet集合中做添加操作是,实质也是往TreeMap中添加操作,TreeSet要添加元素成为了TreeMap的key;

我们来回顾一下TreeSet的特点(也是TreeMap的key的特点):

  1. 必须实现Compareable接口;
  2. 存储的数据是无序的,但提供排序功能(Comparable接口);
  3. 存储的元素不再是唯一,具体结果根据Compare方法来决定;
  • TreeMap 是 SortedMap接口的实现类,TreeMap 可以按照key-vaue对的key进行排序,这就要求TreeMap中的key必须是相同类的对象
  • TreeSet和TreeMap底层都是使用红黑树结构存储数据。
  • TreeMap 两种排序方法:自然排序定制排序,也就是Comparable借口和Comparator接口。默认情况下,TreeSet 采用自然排序。

并发集合

15.泛型

泛型:把类型明确的工作延迟到创建对象或调用方法的时候才去明确的特殊的类型;

例如,我们知道集合是可以存储任意元素的,那么这样一想,add方法上的参数应该是Object(所有类的父类),但是这样会引入一个新的问题,我们知道,子类都是比父类强大的,我们在使用的时候肯定是希望获取的是当初存进去的具体子类对象;因此我们每次都需要进行强制转换;

在泛型出现之前(JDK1.5之前),对于集合容器类,任何类型的数据都可以添加其中,类型不安全。此外在读取集合容器中的对象时,会因为数据类型问题而出现类型转换,一方面类型转换比较繁琐,另一方面可能会在运行时因为类型转换问题出现ClassCastException类型异常

Java泛型可以保证如果程序在编译时没有发出警告,运行时就不会产生ClassCastException异常。同时,代码更加简洁、健壮

查看ArrayList的add方法:

class ArrayList<E>{ 
    public boolean add(E e){ }

    public E get(int index){ }
   	....
}

Collection类:

public interface Collection<E> extends Iterable<E> {
}

上面的E就是泛型,集合的定义者也不知道我们需要存储什么元素到集合中,具体的类型只能延迟到创建对象时来决定了

1.自定义泛型类

很明显,Collection、List、Set以及其下的子类都是泛型类,我们根据使用情况也可以定义泛型类;让泛型类的类型延迟到创建对象的时候指定;

  • 使用格式:
修饰符 class 类名<代表泛型的变量> {}

举例自定义泛型类:

package com.dfbz.demo02;

public class GetClass<C> {

    // 使用泛型类型,C具体的类型还不明确,等到GetClass对象时再明确C的类型
    private C c;

    public C getC() {
        return c;
    }

    public void setC(C c) {
        this.c = c;
    }
}

package com.dfbz.demo02;

import com.dfbz.demo01.City;

public class Demo01 {

    public static void main(String[] args) {

        // 创建对象,并明确泛型类型为City
        GetClass<City> getClass = new GetClass<>();

        // setC(City city)
        getClass.setC(new City("新疆","新","西北"));

        // City getC()
        City city = getClass.getC();

        city.intro();
    }
}

注意点:

  1. 如果定义了泛型类,则实例化的时候就应该指明名类的泛型,否则默认为Object类型

  2. 泛型参数T只能是类,不能用基本数据类型填充。但可以使用包装类填充。

  3. 使用泛型的主要优点是能够在编译时而不是在运行时检测类型错误。

  4. 泛型类可能有多个泛型参数,此时应将多个参数一起放在尖括号内,参数之间用逗号隔开。比如:<E1,E2,E3>

  5. 如果泛型结构是一个接口或抽象类,则不可创建泛型类的对象(还是接口和抽象类的特性)。

  6. 在类/接口上声明的泛型,在本类或本接口中即代表某种类型,可以作为非静态属性的类型、非静态方法的参数类型、非静态方法的返回值类型。但在静态方法中不能使用类的泛型

    静态方法的类型是确定的,但泛型类的类型是在泛型类实例化的时候才确定。因此静态方法不能使用泛型类的泛型参数。

  7. 异常类不能是泛型的

  8. 泛型不同的引用不能相互赋值

2.泛型方法

方法也可以被泛型化,不管此时定义在其中的类是不是泛型类。在泛型方法中可以定义泛型参数,此时,参数的类型就是传入数据的类型。泛型方法的泛型参数与泛型方法所在类是否有泛型参数、有哪些泛型参数无关。

泛型方法的语法格式:

[访问权限] <泛型> 返回类型方法名([泛型标识 参数名称]) 抛出的异常
public class Demo03 {
    // 泛型方法 printArray
    public static < E > void printArray( E[] inputArray )
    {
        // 输出数组元素
        for ( E element : inputArray ){
            System.out.printf( "%s ", element );
        }
        System.out.println();
    }
    public static void main( String args[] )
    {
        // 创建不同类型数组: Integer, Double 和 Character
        Integer[] intArray = { 1, 2, 3, 4, 5 };
        Double[] doubleArray = { 1.1, 2.2, 3.3, 4.4 };
        Character[] charArray = { 'H', 'E', 'L', 'L', 'O' };
        System.out.println( "整型数组元素为:" );
        printArray( intArray  ); // 传递一个整型数组
        System.out.println( "\n双精度型数组元素为:" );
        printArray( doubleArray ); // 传递一个双精度型数组
        System.out.println( "\n字符型数组元素为:" );
        printArray( charArray ); // 传递一个字符型数组
    }
}
-----------------------------------
运行结果:
    整型数组元素为:
    1 2 3 4 5 
    双精度型数组元素为:
    1.1 2.2 3.3 4.4 
    字符型数组元素为:
    H E L L O

注意点

  1. 泛型方法可以被声明为静态的,因为泛型方法的泛型参数是在调用方法时确定的,而不是在类实例化的时候确定。
3.泛型类的继承
子类与父类的泛型关系

父类有泛型,子类可以选择保留泛型也可以选择指定泛型类型:

  • 子类不保留父类的泛型:按需实现
    • 没有类型 擦除
    • 具体类型
  • 子类保留父类的泛型:泛型子类
    • 全部保留
    • 部分保留
class Father<T1, T2> {
}
// 子类不保留父类的泛型
// 1)擦除父类的泛型
class Son1 extends Father {// 等价于class Son extends Father<Object,Object>{
}
//  子类擦除父类的泛型参数,定义自己的泛型参数A,B
class Son2<A, B> extends Father{//等价于class Son extends Father<Object,Object>{
}
// 2)将父类的泛型参数指定为确定类型
class Son3 extends Father<Integer, String> {
}
//  子类将父类的泛型参数指定为确定类型,定义自己的泛型参数A,B
class Son4<A, B> extends Father<Integer, String> {
}
// 子类保留父类的泛型
// 1)全部保留,与父类的类型参数完全一致
class Son5<T1, T2> extends Father<T1, T2> {
}
//  子类保留父类的泛型参数,同时扩展自己的泛型参数A,B
class Son6<T1, T2, A, B> extends Father<T1, T2> {
}
// 2)部分保留,指定其中部分泛型参数为确定类型,部分泛型参数保留
class Son7<T2> extends Father<Integer, T2> {
}
//  子类指定父类的部分泛型参数为确定类型,同时保留父类的部分泛型参数,同时扩展自己的泛型参数A,B,
class Son8<T2, A, B> extends Father<Integer, T2> {
}

泛型对继承关系的影响

如果B是A的一个子类型(子类或者子接口),而G是具有泛型声明的类或接口,则G和G不具备父子关系!

比如:String是Object的子类,但是List并不是List的子类,这两个List是并列的,因此不能互相赋值。

如果B是A的一个子类型(子类或者子接口),而G是具有泛型声明的类或接口,则A 是B 的父类!,可以赋值。

4.泛型通配符的使用

当我们明确了泛型类型后,在进行方法传参时不仅要符合形参本身的类型,还要符合形参中泛型的类型;

例如:

public void show(List<String> list);

在调用show方法时,需要传递List<String>这样的类型,泛型必须要匹配为String,如果只是List接口类型,但泛型不是String那么也会造成编译失败;这样调用方法的局限性就很大了;泛型通配符正是来解决这一问题的;

通配符的使用

泛型的通配符:不知道使用什么类型来接收的时候,此时可以使用?,?表示未知通配符

package com.dfbz.demo05;

import com.dfbz.demo01.City;

import java.util.ArrayList;
import java.util.Collection;

public class Demo01 {
    public static void main(String[] args) {
        Collection<Integer> list1 = new ArrayList<Integer>();
        getElement(list1);

        Collection<String> list2 = new ArrayList<String>();
        getElement(list2);
        
        Collection<City> list3= new ArrayList();
        getElement(list3);
    }

    // ?代表可以接收任意类型
    public static void getElement(Collection<?> collection){
        // 被?通配符匹配后的类型都提升为Object类型了
        for (Object obj : collection) {
            System.out.println(obj);
        }
    }
}

需要注意的是,被<?>接收过的类型都将提升为Object类型;

泛型上下边界

我们在指定泛型类时是可以任意设置的,只要是类就可以设置。但是在Java的泛型中可以指定一个泛型的上限下限

利用泛型的通配符可以指定泛型的边界;

泛型的上限

  • 格式类型名称 <? extends 类> 对象名称
  • 含义只能接收该类型及其子类

泛型的下限

  • 格式类型名称 <? super 类> 对象名称
  • 含义只能接收该类型及其父类型

Number类的继承体系:

在这里插入图片描述

可以看到基本数据类型的包装类都是继承与Number类;

  • 测试泛型上下边界:
package com.dfbz.demo05;

import java.util.ArrayList;
import java.util.Collection;

public class Demo02 {
    public static void main(String[] args) {
        Collection<Integer> list1 = new ArrayList<Integer>();
        Collection<String> list2 = new ArrayList<String>();
        Collection<Number> list3 = new ArrayList<Number>();
        Collection<Object> list4 = new ArrayList<Object>();

        getElement1(list1);     // Integer 是Number类型的子类
//        getElement1(list2);     // 报错,String不是Number类型的子类
        getElement1(list3);     // Number类型可以传递
//        getElement1(list4);     // 报错,Object不是Number类型的子类

//        getElement2(list1);     // 报错,Integer不是Number类型的父类
//        getElement2(list2);     // 报错,String不是Number类型的父类
        getElement2(list3);     // Number类型可以传递
        getElement2(list4);     // Object是Number类型的父类
    }

    // 泛型的上限:此时的泛型?,必须是Number类型或者Number类型的子类
    public static void getElement1(Collection<? extends Number> coll) {
    }

    // 泛型的下限:此时的泛型?,必须是Number类型或者Number类型的父类
    public static void getElement2(Collection<? super Number> coll) {
    }
}

16.异常

1.什么是异常

程序运行过程中出现的问题在Java中被称为异常,异常本身也是一个Java类,封装着异常信息;我们可以通过异常信息来快速定位问题所在;我们也可以针对性的定制异常,如用户找不到异常、密码错误异常、页面找不到异常、支付失败异常、文件找不到异常等等…

当程序出现异常时,我们可以提取异常信息,然后进行封装优化等操作,提示用户;

注意:语法错误并不是异常,语法错了编译都不能通过(但Java有提供编译时异常),不会生成字节码文件,根本不能运行;

默认情况下,出现异常时JVM默认的处理方式是中断程序执行,因此我们需要控制异常,当出现异常后进行相应修改,提供其他方案等操作,不要让程序中断执行;

我们之前有见到过很多的异常:

  • 空指针异常:java.lang.NullPointerException
String str=null;
str.toString();
  • 数字下标越界异常:java.lang.ArrayIndexOutOfBoundsException
int[] arr = {1, 3, 4};
System.out.println(arr[3]);
  • 类型转换异常:java.lang.ClassCastException
class A {
}

class B extends A {
}

class C extends A {
}
public static void main(String[] args) {
    A a = new B();
    C c = (C) a;
}
  • 算数异常:java.lang.ArithmeticException
int i=1/0;
  • 日期格式化异常:java.text.ParseException
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
Date parse = sdf.parse("2000a10-24");
2.异常体系

Java程序运行过程中所发生的异常事件可分为两类:

在这里插入图片描述

  • Error:表示严重错误,一般是JVM系统内部错误、资源耗尽等严重情况,无法通过代码来处理;
  • Exception:表示异常,一般是由于编程不当导致的问题,可以通过Java代码来处理,使得程序依旧正常运行;

tips:我们平常说的异常指的就是Exception;因为Exception可以通过代码来控制,而Error一般是系统内部问题,代码处理不了;

3.异常分类

异常的分类是根据是在编译器检查异常还是在运行时检查异常;

  • 编译时期异常:在编译时期就会检查该异常,如果没有处理异常,则编译失败;
  • 运行时期异常:在运行时才出发异常,编译时不检测异常;

在这里插入图片描述

  • 编译时异常举例:

在这里插入图片描述

  • 运行时异常:

在这里插入图片描述

4.异常的处理

Java程序的执行过程中如出现异常,会自动生成一个异常类对象,该异常对象将被提交给Java运行时系统(JVM),这个过程称为抛出(throw)异常。

如果一个方法内抛出异常,该异常会被抛到调用方法中。如果异常没有在调用方法中处理,它继续被抛给这个调用方法的调用者。这个过程将一直继续下去,直到异常被处理。这一过程称为捕获(catch)异常。如果一个异常回到main()方法,并且main()也不处理,则程序运行终止。

流程如下:

在这里插入图片描述

异常的捕获

1)try...catch(){}

try {
	// 可能会出现异常的代码
} catch (Exception1 e) {
	// 处理异常1
} catch (Exception2 e) {
	// 处理异常2
} catch (ExceptionN e) {
	// 处理异常N
}

2:try...catch(){}...finally{}

try {
	// 可能会出现异常的代码
} catch (Exception1 e) {
	// 处理异常1
} catch (Exception2 e) {
	// 处理异常2
} catch (ExceptionN e) {
	// 处理异常N
} finally {
	// 不管是否出现异常都会执行的代码
}

3:try...finally{}

try {
	// 可能会出现异常的代码
}  finally {
	// 不管是否出现异常都会执行的代码
}

tips:try…catch语句是可以单独使用的;即:不要finally代码块;

异常的常用方法

在Throwable类中具备如下几个常用异常信息提示方法:

  • public void printStackTrace():获取异常的追踪信息;

    包含了异常的类型,异常的原因,还包括异常出现的位置,在开发和调试阶段,都得使用printStackTrace。

  • public String getMessage():异常的错误信息;

异常触发被抓捕时,异常的错误信息都被封装到了catch代码块中的Exception类中了,我可以通过该对象获取异常错误信息;

示例代码:

package com.dfbz.demo01;

public class Demo02 {
    public static void main(String[] args) {
        method();

        System.out.println("我继续执行~");
    }

    public static void method() {
        try {
            int i=1/0;
        } catch (Exception e) {
            System.out.println("异常的错误信息: " + e.getMessage());
            // 打印异常的追踪信息
            e.printStackTrace();
        }
    }
}

在这里插入图片描述

异常的追踪信息可以帮助我们追踪异常的调用链路,一步一步找出异常所涉及到的方法,在实际开发非常常用;

异常的抛出

我们已经学习过出现异常该怎么抓捕了,有时候异常就当做提示信息一样,在调用者调用某个方法出现异常后及时针对性的进行处理,目前为止异常都是由JVM自行抛出,当然我们可以选择性的自己手动抛出某个异常;

Java提供了一个throw关键字,它用来抛出一个指定的异常对象;抛给上一级;

自己抛出的异常和JVM抛出的异常是一样的效果,都要进行处理,如果是自身抛出的异常一直未处理,最终抛给JVM时程序一样会终止执行;

语法格式:

throw new 异常类名(参数);

示例:

throw new NullPointerException("调用方法的对象是空的!");

throw new ArrayIndexOutOfBoundsException("该索引在数组中不存在,已超出范围");

示例代码:

package com.dfbz.demo01;

public class Demo03 {
    public static void main(String[] args) {
        method(null);
        System.out.println("我还会执行吗?");
    }

    public static void method(Object object) {
        if (object == null) {
            // 手动抛出异常(抛出异常后,后面的代码将不会被执行)
            throw new NullPointerException("这个对象是空的!不能调用方法!");
        }
        System.out.println(object.toString());
    }
}

运行结果:

在这里插入图片描述

手动抛出的异常和JVM抛出的异常是一个效果,也需要我来处理抛出的异常;

修改代码:

package com.dfbz.demo01;

public class Demo03 {
    public static void main(String[] args) {
        try {
            method(null);
        } catch (Exception e) {
            System.out.println("调用method方法出现异常了");
        }
        System.out.println("我还会执行吗?");
    }

    public static void method(Object object) {
        if (object == null) {
            // 手动抛出异常(抛出异常后,后面的代码将不会被执行)
            throw new NullPointerException("这个对象是空的!不能调用方法!");
        }
        System.out.println("如果出现了异常我是不会执行了,你能执行到这里说明没有异常");
        System.out.println(object.toString());
    }
}

运行结果:

在这里插入图片描述

声明异常

在定义方法时,可以在方法上声明异常,用于提示调用者;

Java提供throws关键字来声明异常;关键字throws运用于方法声明之上,用于表示当前方法不处理异常,而是提醒该方法的调用者来处理异常(抛出异常);

语法格式:

... 方法名(参数) throws 异常类名1,异常类名2{   }	

代码示例:

package com.dfbz.demo01;


import java.text.ParseException;

public class Demo04 {
    public static void main(String[] args) {

        // 可以处理,也可以不处理
        method("你好~");

        // 编译时异常必须处理
        try {
            method2("hello~");
        } catch (ParseException e) {
            System.out.println("出现异常啦!");
        }
    }

    // 调用此方法可能会出现空指针异常,提示调用者处理异常
    public static void method(Object obj) throws NullPointerException {
        System.out.println(obj.toString());
    }

    // 抛出的不是运行时异常,调用者调用该方法时必须处理
    public static void method2(Object obj) throws ParseException {
        System.out.println(obj.toString());
    }

    // 也可以同时抛出多个异常
    public static void method3(Object obj) throws ClassCastException,ArithmeticException {
        System.out.println(obj.toString());
    }
}
5.异常的注意事项

1)多个异常使用捕获又该如何处理呢?

  1. 多个异常分别处理。
  2. 多个异常一次捕获,多次处理。
  3. 多个异常一次捕获一次处理。

一般我们是使用一次捕获多次处理方式,格式如下:

package com.dfbz.demo02;

public class Demo01 {
    public static void main(String[] args) {
        try {
            int i = 1 / 0;
        } catch (ArithmeticException e) {
            e.printStackTrace();
        } catch (Exception e) {         // 如果有多层catch父类异常一定要放在子类异常的下面
            e.printStackTrace();
        }
    }
}

注意:这种异常处理方式,要求多个catch中的异常不能相同,并且若catch中的多个异常之间有子父类异常的关系,那么子类异常要求在上面的catch处理,父类异常在下面的catch处理。

  • 2)如果finally有return语句,则永远返回finally中的结果。我们在开发过程中应该避免该情况;
package com.dfbz.demo02;

public class Demo02 {
    public static void main(String[] args) {
        int result = method();

        System.out.println("method方法的返回值: " + result);
    }

    public static int method() {
        try {
            int i = 10;
            return 1;
        } catch (Exception e) {
            e.printStackTrace();
            return 2;
        } finally {
            return 3;           // 不管是否出现异常都是返回3
        }
    }
}

如果父类抛出了多个异常,那么子类重写父类方法时,不能抛出比父类大的异常(异常类的父类)或者不抛出异常;

  • 3)子类在重写方法时,不可以抛出比父类还大的异常,可以抛出同级异常或者不抛异常;
package com.dfbz.demo02;

public class Demo03 {
    public static void main(String[] args) {
        Zi zi = new Zi();
    }
}

class Fu {

    public void method() throws Exception {
        System.out.println("fu ... method");
    }

    public void method2() throws ClassCastException {
        System.out.println("fu ... method2");
    }

    public void method3() throws NullPointerException {
        System.out.println("fu ... method3");
    }

    public void method4() throws ArithmeticException {
        System.out.println("fu ... method3");
    }
}

class Zi extends Fu {

    // 正常运行,子类可以抛比父类大或者同级的异常
    public void method() throws Exception {
        System.out.println("zi ... method");
    }

    // 正常运行
    public void method2() throws ClassCastException {
        System.out.println("zi ... method2");
    }

//    public void method3() throws Exception {       // 报错,子类不能抛比父类大的异常
//        System.out.println("zi ... method2");
//    }

    // 正常运行,可以选择不抛出异常
    public void method4() {       
        System.out.println("zi ... method2");
    }
}

  • 子类在重写方法时,父类方法没有抛出异常,则子类方法也不能抛出异常;
class Fu {
    public void method() {
    }
}

class Zi extends Fu {
    // 报错,父类方法没有抛出异常,则子类方法也不能抛出异常
//    public void method() throws Exception {
//    }
}

6.自定义异常

我们说了Java中不同的异常类,分别表示着某一种具体的异常情况,那么在开发中总是有些异常情况是Java中没有定义好的,此时我们根据自己业务的异常情况来定义异常类。

我们前面提到过异常分类编译时异常和运行时异常:

1)继承于java.lang.Exception的类为编译时异常,编译时必须处理;

2)继承于java.lang.RuntimeException的类为运行时异常,编译时可不处理;

  • 自定义用户名不存在异常:
package com.dfbz.demo04;

public class UsernameNotFoundException extends RuntimeException {

    /**
     * 空参构造
     */
    public UsernameNotFoundException() {
    }

    /**
     * @param message 表示异常提示
     */
    public UsernameNotFoundException(String message) {
        // 调用父类的构造方法
        super(message);
    }
}
  • 测试类:
package com.dfbz.demo04;

public class Demo01 {
    // 定义内置账户
    public static String[] users = {"xiaohui", "xiaolan", "xiaoliu"};

    public static void main(String[] args) {
        findByUsername("abc");
    }

    public static void findByUsername(String username) throws UsernameNotFoundException {

        for (String user : users) {
            if (username.equals(user)) {
                System.out.println("找到了: " + user);
            }
        }
        // 用户名没找到
        throw new UsernameNotFoundException("没有用户: " + username);
    }
}

在这里插入图片描述

17.排序比较器

Java实现对象排序的方式有两种:

  • **自然排序:**java.lang.Comparable
  • **定制排序:**java.util.Comparator

Java中的个对象正常情况下只能进行==或者 !=的比较,而不能进行大小的比较。实际开发中却有比较对象大小的需求,这时就需要使用Comparable接口或者Comparator接口。

主要是用于排序

1.自然排序:Comparable接口

Comparable接口强行对实现它的每个类的对象进行整体排序。这种排序被称为类的自然排序

Comparable接口的实现

实现 Comparable 的类必须重写 compareTo(Object obj)方法,两个对象即通过compareTo(Object obj)方法的返回值来比较大小。

重写 compareTo(Object obj)方法的规则

  • 如果当前对象this大于形参对象obj,则返回正整数
  • 如果当前对象this小于形参对象obj,则返回负整数
  • 如果当前对象this等于形参对象obj,则返回零。

例如:return this.age-obj.age;按升序排列。结果取反则降序排列

对于String类、包装类、集合等,它们都已经实现了Comparablei接口,对于这些类的对象,可以直接使用类名.sort()方法进行自然排序。

对于自定义的类,如果需要排序,应该让自定义类实现Comparable接口,重写compareTo(Object obj)方法,在重写时指明排序的标准(一般是以对象的某个属性作为标准,当某一属性相同时可以比较其他属性,其中会有比较的嵌套)。

import java.util.*;
public class Demo01 {
    public static void main(String[] args) {
        //String类已经实现了Comparable接口,可以调用sort方法实现自然排序
        String[] arr = new String[]{"AA","FF","DD","MM","HH","BB"};
        Arrays.sort(arr);
        System.out.println(Arrays.toString(arr));
        //对于自定义的类的对象进行排序,需要实现omparable接口,并重写comparableTo()方法
        Goods[] arr1 = new Goods[4];
        arr1[0] = new Goods("harry",20);
        arr1[1] = new Goods("Alpha",50);
        arr1[2] = new Goods("cmd",20);
        arr1[3] = new Goods("berry",36);
        Arrays.sort(arr1);
        System.out.println(Arrays.toString(arr1));
    }
}
//若要比较,自定义类要实现Comparable接口
class Goods implements Comparable{
    private String name;
    private double price;
    @Override
    //重写自定义类Goods的CompareTo方法
    //按照价格从低到高排序,价格相同则按名称从高到低排序,默认是从低到高,从高到底需要加负号
    public int compareTo(Object o) {
        //判断元素是都是Goods子类
        if (o instanceof Goods) {
            //是的话,就强制转换为Goods类,类型一致方可比较
            Goods goods = (Goods) o;
            //如果当前对象this大于形参对象obj,则返回正整数
            if (this.price > goods.price) {
                return 1;
                //如果当前对象this小于形参对象obj,则返回负整数
            } else if (this.price < goods.price) {
                return -1;
            } else {
                //如果当前对象this等于形参对象obj,则比较name属性,name属性是String类,可以使用String类的compareTo方法
                return -this.name.compareTo(goods.name);
            }
        }
        //若类型不一致,抛出异常
        throw new RuntimeException("传入的类型不一致1");
    }
    @Override
    public String toString() {
        return "Goods{" +
                "name='" + name + '\'' +
                ", price=" + price +
                '}';
    }
    public Goods() {
    }
    public Goods(String name, double price) {
        this.name = name;
        this.price = price;
    }
}
-------------------------------------
运行结果:
    [AA, BB, DD, FF, HH, MM]
    [Goods{name='harry', price=20.0}, Goods{name='cmd', price=20.0}, Goods{name='berry', price=36.0}, Goods{name='Alpha', price=50.0}]
2.定制排序:Comparator

当元素的类型没有实现java.lang.Comparable接口而又不方便修改代码,或者实现了java.lang.Comparable接口的排序规则不适合当前的操作,那么可以考虑使用 Comparator 的对象来排序,强行对多个对象进行整体排序的比较。

重写compare(Object o1,Object o2)方法,比较o1和o2的大小:

  • 如果方法返回正整数,则表示o1大于o2
  • 如果返回0,表示相等
  • 返回负整数,表示o1小于o2

可以将 Comparator 传递给 sort 方法(如 Collections.sort 或 Arrays.sort),从而允许在排序顺序上实现精确控制。

还可以使用 Comparator 来控制某些数据结构(如有序 set或有序映射)的顺序,或者为那些没有自然顺序的对象 collection 提供排序

import java.util.*;
public class Demo01 {
    public static void main(String[] args) {
        Goods[] arr1 = new Goods[4];
        arr1[0] = new Goods("harry",20);
        arr1[1] = new Goods("Alpha",50);
        arr1[2] = new Goods("cmd",20);
        arr1[3] = new Goods("berry",36);
        //创建一个Comparator对象,重写其中的compare方法,在重写compare方法时定义排序标准
        Arrays.sort(arr1, new Comparator() {
            @Override
            public int compare(Object o1, Object o2) {
                Goods g1 = (Goods) o1;
                Goods g2 = (Goods) o2;
                //因为name是String类型,String已经实现了Comparable接口,重写了compareTo方法,这里可以使用。
                return g1.getName().compareTo(g2.getName());
            }
        });
        System.out.println(Arrays.toString(arr1));
    }
}
class Goods {
    private String name;
    private double price;
    public String getName() {
        return name;
    }
    public void setName(String name) {
        this.name = name;
    }
    public double getPrice() {
        return price;
    }
    public void setPrice(double price) {
        this.price = price;
    }
    @Override
    public String toString() {
        return "Goods{" +
                "name='" + name + '\'' +
                ", price=" + price +
                '}';
    }
    public Goods() {
    }
    public Goods(String name, double price) {
        this.name = name;
        this.price = price;
    }
}
---------------------
运行结果:
    [Goods{name='Alpha', price=50.0}, Goods{name='berry', price=36.0}, Goods{name='cmd', price=20.0}, Goods{name='harry', price=20.0}]

总结:

  1. Comparable接口的方式一旦定义,保证Comparable接口实现类的对象在任何位置都可以比较大小。
  2. comparator接口则属于临时性比较。

18.多线程和锁

程序的并行指的是多个应用程序真正意义上的同时执行

程序的并发指的是多个应用程序交替执行

1.Java中的多线程

Java使用java.lang.Thread类代表线程,所有的线程对象都必须是Thread类或其子类的实例。每个线程的作用是完成一定的任务,实际上就是执行一段程序代码。

继承Thread类都将变为线程类,调用Thread类中的start()方法即开启线程;当线程开启后,将会执行Thread类中的run方法,因此我们要做的就是重写Thread中的run方法,将线程要执行的任务由我们自己定义;

  • 定义线程类:
package com.dfbz.demo01;

/**
 * @author lscl
 * @version 1.0
 * @intro: 继承Thread类称为线程类
 */
public class MyThread extends Thread {

    public MyThread() {
    }

    /**
     * 重写父类的构造方法,传递线程名称给父类
     *
     * @param name
     */
    public MyThread(String name) {
        super(name);
    }

    /*
            重写run方法,当线程开启后,将执行run方法中的程序代码
         */
    @Override
    public void run() {
        for (int i = 0; i < 1000; i++) {
            System.out.println(getName() + "线程正在执行: " + i);
        }
    }
}

  • 测试类:
package com.dfbz.demo01;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo01 {
    public static void main(String[] args) {

        MyThread thread = new MyThread("线程1");
        // 开启新的线程
        thread.start();

        for (int i = 0; i < 1000; i++) {
            System.out.println("main线程正执行: " + i);
        }
    }
}

运行结果:

在这里插入图片描述

运行测试代码,观察是否交替执行;如果没有,可能是因为执行任务太少,CPU分配的一点点时间片就足以将线程中的任务全部执行完毕,可以扩大循环次数;观察效果;

线程执行流程

在这里插入图片描述

需要注意的是:当开启一个新线程之后(start方法被调用),JVM会在栈内存中开辟一块新的内存空间,每个线程都有自己独立的栈空间,进行方法的弹栈和压栈。线程和线程之间栈内存独立,堆内存和方法区内存共享。一个线程一个栈。

线程的调度

调度策略

  • 时间片

  • 抢占式:高优先级的线程抢占CPU

  • Java的调度方法

    • 同优先级线程组成先进先出队列(先到先服务),使用时间片策略
    • 高优先级,使用优先调度的抢占式策略
2.线程类
Thread 类

常用方法

构造方法:

  • public Thread():分配一个新的线程对象。
  • public Thread(String name):分配一个指定名字的新的线程对象。
  • public Thread(Runnable target):分配一个带有指定目标新的线程对象。
  • public Thread(Runnable target,String name):分配一个带有指定目标新的线程对象并指定名字。

常用方法:

  • public String getName():获取当前线程名称。
  • public void start():导致此线程开始执行; Java虚拟机调用此线程的run方法。
  • public void run():此线程要执行的任务在此处定义代码。
  • public static void sleep(long millis):使当前正在执行的线程以指定的毫秒数暂停(暂时停止执行)。
  • public static Thread currentThread():返回对当前正在执行的线程对象的引用。

image-20201026212532239

使用Runnable创建线程

翻阅源码得知,Thread执行的run方法实质就是执行Runnable接口中的run方法,因此我们可以传递一个Runnable对象给Thread,此Runnable封装了我们要执行的任务;

采用java.lang.Runnable也是非常常见的一种,我们只需要重写run方法即可。

步骤如下:

  1. 定义Runnable接口的实现类,并重写该接口的run()方法,该run()方法的方法体同样是该线程的线程执行体。
  2. 创建Runnable实现类的实例,并以此实例作为Thread的target来创建Thread对象,该Thread对象才是真正的线程对象。
  3. 调用线程对象的start()方法来启动线程;
  • 定义Runnable接口:
package com.dfbz.demo02;

/**
 * @author lscl
 * @version 1.0
 * @intro: 创建一个类实现Runnable接口
 */
public class MyRunnable implements Runnable {
    @Override
    public void run() {
        for (int i = 0; i < 1000; i++) {
            // 获取当前线程对象的引用
            Thread thread = Thread.currentThread();

            System.out.println(thread.getName() + "执行: " + i);
        }
    }
}

  • 测试类:
package com.dfbz.demo02;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo01 {
    public static void main(String[] args) {

        // 任务对象
        MyRunnable runnable = new MyRunnable();

        // 将任务对象传递给线程执行
        Thread thread = new Thread(runnable,"线程1");

        // 开启线程
        thread.start();

        for (int i = 0; i < 1000; i++) {
            System.out.println("main线程执行: " + i);
        }

    }
}
Thread和Runnable的区别

如果一个类继承Thread,则不适合资源共享。但是如果实现了Runable接口的话,则很容易的实现资源共享。

总结:

实现Runnable接口比继承Thread类所具有的优势:

  1. 适合多个相同的程序代码的线程去共享同一个资源。
  2. 可以避免java中的单继承的局限性。
  3. 增加程序的健壮性,实现解耦操作,代码可以被多个线程共享,代码和线程独立。
  4. 线程池只能放入实现Runable或Callable类线程,不能直接放入继承Thread的类。

扩充:在Java中,每次程序运行至少启动2个线程。一个是main线程,一个是垃圾收集线程。因为每当使用Java命令执行一个类的时候,实际上都会启动一个JVM,每一个JVM其实在就是在操作系统中启动了一个进程。

使用匿名内部类创建线程
package com.dfbz.demo03;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo03 {

    public static void main(String[] args) {

        /**

         相当于:
         public class Xxx implements Runnable{
            @Override public void run() {
                for (int i = 0; i < 1000; i++) {
                    System.out.println("线程1执行: " + i);
                }
            }
         }
         Runnable runnable = new Xxx();
         */
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 1000; i++) {
                    System.out.println("线程1执行: " + i);
                }
            }
        };

        // 创建一个线程类,并传递Runnable的子类
        Thread thread = new Thread(runnable);

        // 开启线程
        thread.start();

        for (int i = 0; i < 1000; i++) {
            System.out.println("main线程: " + i);
        }
    }
}

实现Callable接口
/**
 * 创建线程的方式三:实现Callable接口。 --- JDK 5.0新增
 *
 * 如何理解实现Callable接口的方式创建多线程比实现Runnable接口创建多线程方式强大?
 * 1. call()可以有返回值的。
 * 2. call()可以抛出异常,被外面的操作捕获,获取异常的信息
 * 3. Callable是支持泛型的
 *
 * @author xiexu
 * @create 2020-09-14 下午 6:38
 */
//1.创建一个实现Callable的实现类
class NumThread implements Callable{

    //2.实现call()方法,将此线程需要执行的操作声明在call()中
    @Override
    public Object call() throws Exception {
        int sum = 0;
        //要求:遍历100以内的数,返回偶数的和
        for (int i = 1; i <= 100; i++) {
            if(i % 2 == 0){
                System.out.println(i);
                sum += i;
            }
        }
        return sum;
    }
}

public class ThreadNew {

    public static void main(String[] args) {
        //3.创建Callable接口实现类的对象
        NumThread numThread = new NumThread();

        //4.将此Callable接口实现类的对象作为参数传递到FutureTask的构造器中,
        // 创建FutureTask的对象
        FutureTask futureTask = new FutureTask(numThread);

        //5.将FutureTask的对象作为参数传递到Thread类的构造器中,
        // 创建Thread对象,并调用start()
        //FutureTask实现了Runnable接口,
        // 所以Thread类构造器中的参数还是Runnable接口的实现类
        new Thread(futureTask).start();

        try {
            //6.获取Callable中call()的返回值
            //get()返回值即为FutureTask构造器参数Callable接口的实现类重写的call()的返回值。
            Object sum = futureTask.get();
            System.out.println("总和为:" + sum);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
    }

}

  • 与使用Runnable相比, Callable功能更强大些
    • 相比run( )方法,可以有返回值
    • 方法可以抛出异常
    • 支持泛型的返回值
    • 需要借助FutureTask类,比如获取返回结果
  • Future接口
    • 可以对具体Runnable、Callable任务的执行结果进行取消、查询是否完成、获取结果等。
    • FutrueTask是Futrue接口的唯一的实现类
    • FutureTask 同时实现了Runnable, Future接口。它既可以作为 Runnable被线程执行,又可以作为Future得到Callable的返回值
3.线程的操作
线程的休眠sleep
  • public static void sleep(long millis):让当前线程睡眠指定的毫秒数
package com.dfbz.demo04;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo01 {
    public static void main(String[] args) {
        // 使用匿名内部类开启1个线程
        new Thread() {
            @Override
            public void run() {
                for (int i = 0; i < 100; i++) {
                    //当i等于50的时候让当前线程睡眠1秒钟(1000毫秒)
                    if (i == 50) {
                        try {
                            Thread.sleep(1000);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                    System.out.println(Thread.currentThread().getName() + ": " + i);
                }
            }
        }.start();

        // 使用匿名内部类开启第2个线程
        new Thread() {
            @Override
            public void run() {
                for (int i = 0; i < 100; i++) {
                    System.out.println(Thread.currentThread().getName() + ": " + i);
                }
            }
        }.start();
    }
}

线程的加入join

多条线程时,当指定线程调用join方法时,线程执行权交给该线程,并且当前线程必须等该线程执行完毕之后才会执行但有可能被其他线程抢去CPU执行权.

  • public final void join():让线程在当前线程优先执行,直至t线程执行完毕时,再执行当前线程.
  • public final void join(long millis):让线程执行millis毫秒,然后将线程执行器抛出,给其他线程争抢
守护线程

Java中的线程分为两类:一种是守护线程,一种是用户线程

  • 它们在几乎每个方面都是相同的,唯一的区别是判断JVM何时离开。
  • 守护线程是用来服务用户线程的通过在start( )方法前调用thread.setDaemon(true)可以把一个用户线程变成一个守护线程
  • Java垃圾回收就是一个典型的守护线程
  • 若JVM中都是守护线程,当前JVM将退出
  • 形象理解:兔死狗烹,鸟尽弓藏

当用户线程(非守护线程)行完毕时,守护线程也会停止执行但由于CPU运行速度太快,当用户线程执行完毕时,将信息传递给守护线程,会有点时间差,而这些时间差会导致还会执行一点守护线程

需要注意的是:不管开启多少个线程(用户线程),守护线程总是随着第一个用户线程的停止而停止

  • public final void setDaemon(boolean on):设置线程是否为守护线程
package com.dfbz.demo04;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo03 {
    public static void main(String[] args) throws InterruptedException {

        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 2000; i++) {
                    System.out.println("守护线程1: " + i);
                }
            }
        });

        //将t1设置为守护线程
        t1.setDaemon(true);

        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 1000; i++) {

                    System.out.println("用户线程2: " + i);
                }
            }
        });

        Thread t3 = new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 1000; i++) {

                    System.out.println("用户线程3: " + i);
                }
            }
        });

        //开启三条线程,不管是t2还是t3线程执行完毕,守护线程都会停止
        t1.start();
        t2.start();
        t3.start();
    }
}

程优先级

默认情况下,所有的线程优先级默认为5,最高为10,最低为1。优先级高的线程更容易让线程在抢到线程执行权;

通过如下方法可以设置指定线程的优先级:

  • public final void setPriority(int newPriority):设置线程的优先级。
线程礼让 yield

在多线程执行时,线程礼让,告知当前线程可以将执行权礼让给其他线程,礼让给优先级相对高一点的线程,但仅仅是一种告知,并不是强制将执行权转让给其他线程,当前线程将CPU执行权礼让出去后,也有可能下次的执行权还在原线程这里;如果想让原线程强制让出执行权,可以使用join()方法

  • public static void yield():将当前线程的CPU执行权礼让出来;
package com.dfbz.demo04;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo05 {
    public static void main(String[] args) throws InterruptedException {

        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 100; i++) {
                    System.out.println("线程1: " + i);
                }
            }
        });
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 100; i++) {
                    if (i == 10) {
                        //当i等于10的时候该线程礼让(礼让之后有可能下次线程执行权还被线程2抢到了)
                        Thread.yield();
                    }
                    System.out.println("线程2: " + i);
                }
            }
        });

        t1.start();
        t2.start();
    }
}
4.线程安全
线程安全问题

我们前面的操作线程与线程间都是互不干扰,各自执行,不会存在线程安全问题。当多条线程操作同一个资源时,就会产生线程安全问题;

线程同步

当我们使用多个线程访问同一资源的时候,且多个线程中对资源有写的操作,就容易出现线程安全问题。要解决上述多线程并发访问一个资源的安全性问题:也就是解决重复票与不存在票问题,Java中提供了同步机制(synchronized)来解决。

Java中提供了三种方式完成同步操作:

  1. 同步代码块。
  2. 同步方法。
  3. 锁机制。
同步代码块
  • 同步代码块synchronized关键字可以用于方法中的某个区块中,表示只对这个区块的资源实行互斥访问。

语法:

synchronized(同步锁){
     需要同步操作的代码
}

同步锁

对象的同步锁只是一个概念,可以想象为在对象上标记了一个锁;

  1. 锁对象可以是任意类型。
  2. 多个线程对象 要使用同一把锁。

注意:在任何时候,最多允许一个线程拥有同步锁,谁拿到锁就进入代码块,其他的线程只能在外等着(BLOCKED)。

在这里插入图片描述

使用同步代码块改造代码:

package com.dfbz.demo01;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Ticket implements Runnable {
    //票数
    private Integer ticket = 100;

    //锁对象
    private Object obj = new Object();
    @Override
    public void run() {
        while (true) {
            // 加上同步代码块,把需要同步的代码放入代码块中,同步代码块中的锁对象必须保证一致!
            synchronized (obj) {
                if (ticket <= 0) {
                    break;      // 票卖完了
                }
                System.out.println(Thread.currentThread().getName() + "正在卖第: " + (101 - ticket) + "张票");
                ticket--;
            }
        }
    }
}
同步方法
  • 同步方法:使用synchronized修饰的方法,就叫做同步方法,保证A线程执行该方法的时候,其他线程只能在方法外等着。

注意:同步方法也是有锁对象的,对于静态方法的锁对象的当前类的字节码对象(.class),对于非静态的方法的锁对象是this;

语法:

public synchronized void method(){
   	可能会产生线程安全问题的代码
}

使用同步方法:

package com.dfbz.demo02;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */

public class Demo01 {
    public static void main(String[] args) {
        Shower s = new Shower();

        //开启线程1调用show方法
        new Thread() {
            @Override
            public void run() {
                while (true) {
                    s.show();
                }
            }
        }.start();

        //开启线程2调用show2方法
        new Thread() {
            @Override
            public void run() {
                while (true) {
                    s.show2();
                }
            }
        }.start();

    }
}

class Shower {

    //非静态同步方法的锁对象默认是this
    public synchronized void show() {

        System.out.print("犯");
        System.out.print("我");
        System.out.print("中");
        System.out.print("华");
        System.out.print("者");
        System.out.println();
    }

    public void show2() {
        //使用this锁也能够保证代码同步
        synchronized (this) {
            System.out.print("虽");
            System.out.print("远");
            System.out.print("必");
            System.out.print("诛");
            System.out.println();
        }
    }
}
Lock锁

java.util.concurrent.locks.Lock机制提供了比synchronized代码块和synchronized方法更广泛的锁定操作,同步代码块/同步方法具有的功能Lock都有,除此之外更强大,更体现面向对象。

Lock锁也称同步锁,加锁与释放锁方法化了,如下:

  • public void lock():加同步锁。
  • public void unlock():释放同步锁。
package com.dfbz.demo03;

import java.util.concurrent.locks.ReentrantLock;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo01 {
    public static void main(String[] args) {
        //创建锁对象
        ReentrantLock lock = new ReentrantLock();

        new Thread() {
            @Override
            public void run() {

                while (true) {
                    //开启锁
                    lock.lock();
                    System.out.print("虽");
                    System.out.print("远");
                    System.out.print("必");
                    System.out.print("诛");
                    System.out.println();
                    //释放锁
                    lock.unlock();
                }
            }
        }.start();


        new Thread() {
            @Override
            public void run() {

                while (true) {
                    //开启锁
                    lock.lock();
                    System.out.print("犯");
                    System.out.print("我");
                    System.out.print("中");
                    System.out.print("华");
                    System.out.print("者");
                    System.out.println();

                    //释放锁
                    lock.unlock();
                }

            }
        }.start();
    }
}

线程死锁

多线程同步的时候,如果同步代码嵌套,使用相同锁,就有可能出现死锁;

在这里插入图片描述

5.线程的等待与唤醒
线程的等待
  • public final void wait():让当前线程进入等待状态,并且释放锁对象

注意:wait方法是锁对象来调用,调用wait()之后将释放当前锁,并且让当前锁对象对应的线程处于等待(Waiting)状态;

  • public final native void notify():随机唤醒一条锁对象对应线程中的一条(此线程必须是睡眠状态)

注意:notify()也是锁对象来调用,并不是当前线程对象调用

因为wait需要释放锁,所以必须在synchronized中使用,没有锁时使用会抛出IllegalMonitorStateException(正在等待的对象没有锁)

tips:

  1. wait方法与notify方法必须要由同一个锁对象调用。因为:对应的锁对象可以通过notify唤醒使用同一个锁对象调用的wait方法后的线程。
  2. wait方法与notify方法是属于Object类的方法的。因为:锁对象可以是任意对象,而任意对象的所属类都是继承了Object类的。
  3. wait方法与notify方法必须要在同步代码块或者是同步函数中使用。因为:必须要通过锁对象调用这2个方法。

案例:线程1执行一次"犯我中华者",线程2执行一次"虽远必诛",交替执行

package com.dfbz.demo01;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo01 {
    public static void main(String[] args) {
        Shower s = new Shower();

        new Thread() {
            @Override
            public void run() {
                try {
                    s.show1();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }.start();

        new Thread() {
            @Override
            public void run() {

                try {
                    s.show2();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }.start();


    }
}

class Shower {

    int count = 1;

    public void show1() throws InterruptedException {
        for (int i = 0; i < 100; i++) {
            synchronized (Object.class) {
                while (count != 1) {
                    Object.class.wait();
                }
                Thread.sleep(10);
                System.out.print("犯");
                System.out.print("我");
                System.out.print("中");
                System.out.print("华");
                System.out.print("者");
                System.out.println();
                count = 2;                //count=1
                Object.class.notify();
            }
        }
    }

    public void show2() throws InterruptedException {
        for (int i = 0; i < 100; i++) {
            synchronized (Object.class) {
                while (count != 2) {
                    Object.class.wait();
                }
                Thread.sleep(10);
                System.out.print("虽");
                System.out.print("远");
                System.out.print("必");
                System.out.print("诛");
                System.out.println();
                count = 1;
                Object.class.notify();              //随机唤醒一条当前锁的线程
            }
        }
    }
}

唤醒与全部唤醒

实现需求:线程1执行一次"我是中国人",线程2执行一次"犯我中华者",线程3执行一次"虽远必诛",交替执行

  • public final native void notify():唤醒在当前锁对象中随机的一条线程
  • public final native void notifyAll():唤醒当前锁对象对应的所有线程(效率低)
package com.dfbz.demo02;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */

/**
 * 线程通信
 */
public class Demo01 {
    public static void main(String[] args) {
        Shower s = new Shower();

        new Thread() {
            @Override
            public void run() {

                try {
                    s.show1();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }


            }
        }.start();

        new Thread() {
            @Override
            public void run() {

                try {
                    s.show2();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }.start();
        new Thread() {
            @Override
            public void run() {

                try {
                    s.show3();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }.start();

    }
}

class Shower {

    int count = 1;

    public void show1() throws InterruptedException {
        for (int i = 0; i < 100; i++) {
            synchronized (Object.class) {
                while (count != 1) {
                    Object.class.wait();
                }
                Thread.sleep(10);
                System.out.print("我");
                System.out.print("是");
                System.out.print("中");
                System.out.print("国");
                System.out.print("人");
                System.out.println();
                count = 2;
                Object.class.notifyAll();    //唤醒该锁对应的全部线程
            }
        }
    }

    public void show2() throws InterruptedException {
        for (int i = 0; i < 100; i++) {
            synchronized (Object.class) {
                while (count != 2) {
                    Object.class.wait();
                }
                Thread.sleep(10);
                System.out.print("犯");
                System.out.print("我");
                System.out.print("中");
                System.out.print("华");
                System.out.print("者");
                System.out.println();
                count = 3;                //count=1
                Object.class.notifyAll();
            }
        }
    }

    public void show3() throws InterruptedException {
        for (int i = 0; i < 100; i++) {
            synchronized (Object.class) {
                while (count != 3) {
                    Object.class.wait();
                }
                Thread.sleep(10);
                System.out.print("虽");
                System.out.print("远");
                System.out.print("必");
                System.out.print("诛");
                System.out.println();
                count = 1;
                Object.class.notifyAll();              // 唤醒该锁对应的全部线程
            }
        }
    }
}

6.Lock锁的监视器

上述案例中,通过synchronized同步代码块加上锁对象也可以实现线程间的通信,我们不管下次执行是哪个线程,都是使用notifyAll()唤醒全部线程,即使不是该线程执行也会唤醒当前锁对应的全部线程,我们能不能指定的唤醒某条线程呢?答案是可以的,借助Lock锁实现!

ReentrantLock相关方法如下:

  • public Condition newCondition():获取用于监视线程的监视器;

Condition相关方法如下:

  • void await():让当前执行的线程进行等待(监视器来调用),一旦调用了此方法,该监视器会监视本线程,用于后续的唤醒;
  • void signal():让当前执行的线程唤醒(监视器来调用);
package com.dfbz.demo03;

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo01 {
    public static void main(String[] args) {

        Printer2 p = new Printer2();
        new Thread() {
            @Override
            public void run() {

                try {
                    p.show1();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

            }
        }.start();

        new Thread() {
            @Override
            public void run() {

                try {
                    p.show2();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

            }
        }.start();

        new Thread() {
            @Override
            public void run() {

                try {
                    p.show3();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

            }
        }.start();
    }
}

class Printer2 {

    //创建锁对象
    ReentrantLock lock = new ReentrantLock();

    //创建三个监视器对象,用于监视三条线程
    Condition c1;
    Condition c2;
    Condition c3;

    public Printer2() {
        c1 = lock.newCondition();
        c2 = lock.newCondition();
        c3 = lock.newCondition();
    }

    int count = 1;

    public void show1() throws InterruptedException {

        for (int i = 0; i < 100; i++) {
            lock.lock();                //开启锁
            while (count != 1) {
                c1.await();             //使用c1监视器让当前线程等待
            }
            Thread.sleep(10);
            System.out.print("我");
            System.out.print("是");
            System.out.print("中");
            System.out.print("国");
            System.out.print("人");
            System.out.println();
            count = 2;
            c2.signal();                //唤醒c2监视器监视的线程
            lock.unlock();              //释放锁
        }

    }

    public void show2() throws InterruptedException {
        for (int i = 0; i < 100; i++) {
            lock.lock();
            while (count != 2) {
                c2.await();                 //使用c2监视器监视该线程
            }
            Thread.sleep(10);
            System.out.print("犯");
            System.out.print("我");
            System.out.print("中");
            System.out.print("华");
            System.out.print("者");
            System.out.println();
            count = 3;
            c3.signal();                    //唤醒c3监视器监视的线程
            lock.unlock();
        }

    }


    public void show3() throws InterruptedException {
        for (int i = 0; i < 100; i++) {
            lock.lock();
            while (count != 3) {
                c3.await();                 //使用c3监视器监视该线程
            }
            Thread.sleep(10);
            System.out.print("虽");
            System.out.print("远");
            System.out.print("必");
            System.out.print("诛");
            System.out.println();
            count = 1;
            c1.signal();                    //唤醒c1监视器监视的线程
            lock.unlock();
        }
    }
}

  • wait( ):令当前线程挂起并放弃CPU、同步资源并等待,使别的线程可访问并修改共享资源,而当前线程排队等候其他线程调用notify( )或notifyAll( )方法唤醒,唤醒后等待重新获得对监视器的所有权后才能继续执行。
  • notify( ):唤醒正在排队等待同步资源的线程中优先级最高者结束等待
  • notifyAll ( ):唤醒正在排队等待资源的所有线程结束等待
  • 这三个方法只有在synchronized方法或synchronized代码块中才能使用,否则会报 java.lang.IllegalMonitorStateException异常。
  • 因为这三个方法必须有锁对象调用,而任意对象都可以作为synchronized的同步锁, 因此这三个方法只能在Object类中声明。
7.线程状态

当线程被创建并启动以后,它既不是一启动就进入了执行状态,也不是一直处于执行状态。在线程的生命周期中,有几种状态呢?在API中java.lang.Thread.State这个枚举中给出了六种线程状态:

这里先列出各个线程状态发生的条件,下面将会对每种状态进行详细解析

线程状态导致状态发生条件
NEW(新建)线程刚被创建,但是并未启动。还没调用start方法。
Runnable(可运行)线程可以在java虚拟机中运行的状态,可能正在运行自己代码,也可能没有,这取决于操作系统处理器。
Blocked(锁阻塞)当一个线程试图获取一个对象锁,而该对象锁被其他的线程持有,则该线程进入Blocked状态;当该线程持有锁时,该线程将变成Runnable状态。
Waiting(无限等待)一个线程在等待另一个线程执行一个(唤醒)动作时,该线程进入Waiting状态。进入这个状态后是不能自动唤醒的,必须等待另一个线程调用notify或者notifyAll方法才能够唤醒。
Timed Waiting(计时等待)同waiting状态,有几个方法有超时参数,调用他们将进入Timed Waiting状态。这一状态将一直保持到超时期满或者接收到唤醒通知。带有超时参数的常用方法有Thread.sleep 、Object.wait。
Teminated(被终止)因为run方法正常退出而死亡,或者因为没有捕获的异常终止了run方法而死亡。
线程状态变化

在这里插入图片描述

Runnable被称为可运行状态,一旦线程调用start()方法,线程就处于可运行状态(Runnable)。一个可运行的线程能正在运行也可能没有运行。有些教科书上讲可运行状态分为了就绪状态运行状态,即线程开启后进入就绪状态,当线程抢到CPU执行权后进入运行状态(Java规范没有将正在运行作为一个单独的状态,一个正在运行的线程仍然处于可运行状态)

  • JDK中用Thread.State类定义了线程的几种状态

  • 要想实现多线程,必须在主线程中创建新的线程对象。Java语言使用Thread类及其子类的对象来表示线程,在它的一个完整的生命周期中通常要经历如下的五种状态:

    • 新建:当一个Thread类或其子类的对象被声明并创建时,新生的线程对象处于新建状态
    • 就绪:处于新建状态的线程被start( )后,将进入线程队列等待CPU时间片,此时它已具备了运行的条件,只是没分配到CPU资源
    • 运行:当就绪的线程被调度并获得CPU资源时,便进入运行状态,run( )方法定义了线程的操作和功能
    • 阻塞:在某种特殊情况下,被人为挂起或执行输入输出操作时,让出CPU并临时中止自己的执行,进入阻塞状态
    • 死亡:线程完成了它的全部工作或线程被提前强制性地中止或出现异常导致结束

image-20201026220112044

image-20201026220233210

8.线程池
线程池概述

创建线程与消耗消除是非常消耗系统资源的操作,如果需要并发的线程非常多,并且每个线程都是执行一个时间很短的任务就结束了,那么这样势必会造成很大资源的浪费(好不容易容易创建出来的线程立马就关了)。

有了线程池之后,当线程使用完毕后,不是立即销毁,而是归还到线程池中,下次需要线程来执行任务时,直接去线程池中获取一条线程即可,这样线程就得到了很大程度上的复用;

总结线程池有如下优点:

  • 1)降低系统资源消耗,通过重用已存在的线程,降低线程创建和销毁造成的消耗;
  • 2)提高系统响应速度,当有任务到达时,通过复用已存在的线程,无需等待新线程的创建便能立即执行;
  • 3)方便线程并发数的管控。因为线程若是无限制的创建,可能会导致内存占用过多而产生OOM,并且会造成cpu过度切换(cpu切换线程是有时间成本的)
  • 4、提供更强大的功能,延时定时线程池。
线程池的使用

要配置一个线程池是比较复杂的,尤其是对于线程池的原理不是很清楚的情况下,很有可能配置的线程池不是较优的,因此在java.util.concurrent.Executors线程工厂类里面提供了一些静态工厂,生成一些常用的线程池。官方建议使用Executors工程类来创建线程池对象。

Executors类中有个创建线程池的方法如下:

  • public static ExecutorService newFixedThreadPool(int nThreads):返回线程池对象。

获取到了一个线程池ExecutorService 对象,那么怎么使用呢,在这里定义了一个使用线程池对象的方法如下:

  • public Future<?> submit(Runnable task):获取线程池中的某一个线程对象,并执行

Future接口:用来记录线程任务执行完毕后产生的结果。线程池创建与使用。

示例代码:

package com.dfbz.demo01;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class MyRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + "执行了");
    }
}

线程池测试类:

package com.dfbz.demo01;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo01 {
    public static void main(String[] args) {
        // 创建线程池对象
        ExecutorService threadPool = Executors.newFixedThreadPool(2);//包含2个线程对象
        // 创建Runnable实例对象
        MyRunnable task = new MyRunnable();

        // 从线程池中获取线程对象,然后调用MyRunnable中的run()
        threadPool.submit(task);
        // 再获取个线程对象,调用MyRunnable中的run()
        threadPool.submit(task);
        threadPool.submit(task);
        // 注意:submit方法调用结束后,程序并不终止,是因为线程池控制了线程的关闭。
        // 将使用完的线程又归还到了线程池中
        // 关闭线程池
        threadPool.shutdown();
    }
}

在这里插入图片描述

ThreadPoolExecutor的构造方法

Java中的线程池顶层接口是Executor接口,ThreadPoolExecutor是这个接口的实现类。

// 五个参数的构造函数
public ThreadPoolExecutor(int corePoolSize, //该线程池中核心线程数最大值
                          int maximumPoolSize,//该线程池中线程总数最大值 
                          long keepAliveTime,//非核心线程闲置超时时长
                          TimeUnit unit,//keepAliveTime的单位。
                          BlockingQueue<Runnable> workQueue)//阻塞队列,维护着等待执行的Runnable任务对象。

// 六个参数的构造函数-1
public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory)//创建线程的工厂 ,用于批量创建线程,统一在创建线程时设置一些参数,如是否守护线														程、线程的优先级等。如果不指定,会新建一个默认的线程工厂。

// 六个参数的构造函数-2
public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          RejectedExecutionHandler handler)//拒绝处理策略,线程数量大于最大线程数就会采用拒绝处理策略

// 七个参数的构造函数
public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler)

  • int corePoolSize:该线程池中核心线程数最大值

    核心线程:线程池中有两类线程,核心线程和非核心线程。核心线程默认情况下会一直存在于线程池中,即使这个核心线程什么都不干(铁饭碗),而非核心线程如果长时间的闲置,就会被销毁(临时工)。

  • int maximumPoolSize:该线程池中线程总数最大值

    该值等于核心线程数量 + 非核心线程数量。

  • long keepAliveTime非核心线程闲置超时时长

    非核心线程如果处于闲置状态超过该值,就会被销毁。如果设置allowCoreThreadTimeOut(true),则会也作用于核心线程。

  • TimeUnit unit:keepAliveTime的单位。

    TimeUnit是一个枚举类型 ,包括以下属性:

    NANOSECONDS : 1微毫秒 = 1微秒 / 1000 ; MICROSECONDS :1微秒 = 1毫秒 / 1000 ;MILLISECONDS : 1毫秒 = 1秒 /1000 ;SECONDS : 秒; MINUTES : 分 ;HOURS : 小时 ;DAYS : 天

  • BlockingQueue workQueue:阻塞队列,维护着等待执行的Runnable任务对象

    常用的几个阻塞队列:

    1. LinkedBlockingQueue

      链式阻塞队列,底层数据结构是链表,默认大小是Integer.MAX_VALUE,也可以指定大小。

    2. ArrayBlockingQueue

      数组阻塞队列,底层数据结构是数组,需要指定队列的大小。

    3. SynchronousQueue

      同步队列,内部容量为0,每个put操作必须等待一个take操作,反之亦然。

    4. DelayQueue

      延迟队列,该队列中的元素只有当其指定的延迟时间到了,才能够从队列中获取到该元素 。

  • ThreadFactory threadFactory

    创建线程的工厂 ,用于批量创建线程,统一在创建线程时设置一些参数,如是否守护线程、线程的优先级等。如果不指定,会新建一个默认的线程工厂。

  • RejectedExecutionHandler handler

    拒绝处理策略,线程数量大于最大线程数就会采用拒绝处理策略,四种拒绝处理的策略为 :

    1. ThreadPoolExecutor.AbortPolicy默认拒绝处理策略,丢弃任务并抛出RejectedExecutionException异常。
    2. ThreadPoolExecutor.DiscardPolicy:丢弃新来的任务,但是不抛出异常。
    3. ThreadPoolExecutor.DiscardOldestPolicy:丢弃队列头部(最旧的)的任务,然后重新尝试执行程序(如果再次失败,重复此过程)。
    4. ThreadPoolExecutor.CallerRunsPolicy:由调用线程处理该任务。

ThreadPoolExecutor的策略

线程池本身有一个调度线程,这个线程就是用于管理布控整个线程池里的各种任务和事务,例如创建线程、销毁线程、任务队列管理、线程队列管理等等。

故线程池也有自己的状态。ThreadPoolExecutor类中使用了一些final int常量变量来表示线程池的状态 ,分别为RUNNING、SHUTDOWN、STOP、TIDYING 、TERMINATED。

  • 线程池创建后处于RUNNING状态。

  • 调用shutdown()方法后处于SHUTDOWN状态,线程池不能接受新的任务,清除一些空闲worker,会等待阻塞队列的任务完成。

  • 调用shutdownNow()方法后处于STOP状态,线程池不能接受新的任务,中断所有线程,阻塞队列中没有被执行的任务全部丢弃。此时,poolsize=0,阻塞队列的size也为0。

  • 当所有的任务已终止,ctl记录的”任务数量”为0,线程池会变为**TIDYING(整洁)**状态。接着会执行 终止terminated()函数。

    ThreadPoolExecutor中有一个控制状态的属性叫ctl,它是一个AtomicInteger类型的变量。线程池状态就是通过AtomicInteger类型的成员变量ctl来获取的。

    获取的ctl值传入runStateOf方法,与~CAPACITY位与运算(CAPACITY是低29位全1的int变量)。

    ~CAPACITY在这里相当于掩码,用来获取ctl的高3位,表示线程池状态;而另外的低29位用于表示工作线程数

  • 线程池处在TIDYING状态时,执行完terminated()方法之后,就会由 TIDYING -> TERMINATED, 线程池被设置为TERMINATED状态。

线程池主要的任务处理流程

总结一下处理流程

  1. 线程总数量 < corePoolSize(核心线程),无论线程是否空闲,都会新建一个核心线程执行任务(让核心线程数量快速达到corePoolSize,在核心线程数量 < corePoolSize时)。注意,这一步需要获得全局锁。
  2. 线程总数量 >= corePoolSize时,新来的线程任务会进入任务队列中等待,然后空闲的核心线程会依次去缓存队列中取任务来执行(体现了线程复用)。
  3. 当缓存队列满了,说明这个时候任务已经多到爆棚,需要一些“临时工”来执行这些任务了。于是会创建非核心线程去执行这个任务。注意,这一步需要获得全局锁。
  4. 缓存队列满了, 且总线程数达到了maximumPoolSize,则会采取上面提到的拒绝策略进行处理。

整个过程如图所示:

在这里插入图片描述

ThreadPoolExecutor如何做到线程复用的?

我们知道,一个线程在创建的时候会指定一个线程任务,当执行完这个线程任务之后,线程自动销毁。但是线程池却可以复用线程,即一个线程执行完线程任务后不销毁,继续执行另外的线程任务。那么,线程池如何做到线程复用呢?

原来,ThreadPoolExecutor在创建线程时,会将线程封装成工作线程worker,并放入工作线程组中,然后这个worker反复从阻塞队列中拿任务去执行。

sleep 与 wait的区别

sleep是线程类(Thread)的静态方法。

sleep的作用是让线程休眠制定的时间,在时间到达时恢复,也就是说sleep将在接到时间到达事件事恢复线程执行。

wait()方法是Object类里的方法。

当一个线程执行wait()方法时,它就进入到一个和该对象相关的等待池中(进入等待队列,也就是阻塞的一种,叫等待阻塞),同时释放对象锁,并让出CPU资源,待指定时间结束后返还得到对象锁。

wait()使用notify()方法、notiftAll()方法或者等待指定时间来唤醒当前等待池中的线程。

notify的作用只负责唤醒线程,线程被唤醒后有权利重新参与线程的调度。

wait()方法、notify()方法和notiftAll()方法用于协调多线程对共享数据的存取,所以只能在同步方法或者同步块中使用,否则抛出IllegalMonitorStateException。

两者的区别

(1)属于不同的两个类,sleep()方法是线程类(Thread)的静态方法,wait()方法是Object类里的方法。

(2)sleep()方法不会释放锁,wait()方法释放对象锁。

(3)sleep()方法可以在任何地方使用,wait()方法则只能在同步方法或同步块中使用。

(4)sleep()必须捕获异常,wait()方法、notify()方法和notiftAll()方法不需要捕获异常。

(5)sleep()使线程进入阻塞状态(线程睡眠),wait()方法使线程进入等待队列(线程挂起),也就是阻塞类别不同。

(6) 它们都可以被interrupted方法中断。

interrupt()中断线程 (Thread中的方法。)

wait(1000)与sleep(1000)的区别

Thread.Sleep(1000)

意思是在未来的1000毫秒内本线程不参与CPU竞争,1000毫秒过去之后,这时候也许另外一个线程正在使用CPU,那么这时候操作系统是不会重新分配CPU的,直到那个线程挂起或结束。

即使这个时候恰巧轮到操作系统进行CPU 分配,那么当前线程也不一定就是总优先级最高的那个,CPU还是可能被其他线程抢占去。

另外值得一提的是Thread.Sleep(0)的作用,就是触发操作系统立刻重新进行一次CPU竞争,竞争的结果也许是当前线程仍然获得CPU控制权,也许会换成别的线程获得CPU控制权。

wait(1000)

表示将锁释放1000毫秒,到时间后如果锁没有被其他线程占用,则再次得到锁,然后wait方法结束,执行后面的代码,如果锁被其他线程占用,则等待其他线程释放锁。

注意,设置了超时时间的wait方法一旦过了超时时间,并不需要其他线程执行notify也能自动解除阻塞,但是如果没设置超时时间的wait方法必须等待其他线程执行notify。

19.io流

1.File类

java.io.File 类是文件和目录路径名的抽象表示,主要用于文件和目录的创建、查找和删除等操作。

构造方法
  • public File(String pathname) :通过将给定的路径名字符串转换为抽象路径名来创建新的 File实例。
  • public File(String parent, String child) :从父路径名字符串和子路径名字符串创建新的 File实例。
  • public File(File parent, String child) :从父抽象路径名和子路径名字符串创建新的 File实例。
package com.dfbz.demo01;

import java.io.File;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo01 {
    public static void main(String[] args) {
        // 文件路径名
        String pathname = "D:\\aaa.txt";
        File file1 = new File(pathname);

        // 文件路径名
        String pathname2 = "D:\\aaa\\bbb.txt";
        File file2 = new File(pathname2);

        // 通过父路径和子路径字符串
        String parentDir = "d:\\aaa";
        String childName = "bbb.txt";
        File file3 = new File(parentDir, childName);

        // 通过父级File对象和子路径字符串
        File parentFile = new File("d:\\aaa");
        String child = "bbb.txt";
        File file4 = new File(parentFile, child);
    }
}
成员方法

获取文件信息方法

  • public String getAbsolutePath() :返回此File的绝对路径名字符串。

  • public String getPath() :将此File转换为路径名字符串。

  • public String getName() :返回由此File表示的文件或目录的名称。

  • public long length() :返回由此File表示的文件的长度,如果是文件夹则返回0。

判断文件的方法

  • public boolean exists() :此File表示的文件或目录是否实际存在。
  • public boolean isDirectory() :此File表示的是否为目录。
  • public boolean isFile() :此File表示的是否为文件。

文件的创建与删除方法

  • public boolean createNewFile() :当且仅当具有该名称的文件尚不存在时,创建一个新的空文件。
  • public boolean delete() :删除由此File表示的文件或目录。
  • public boolean mkdir() :创建由此File表示的目录。
  • public boolean mkdirs() :创建由此File表示的目录,包括任何必需但不存在的父目录。

目录的遍历

  • public String[] list() :返回一个String数组,表示该File目录中的所有子文件或目录。
  • public File[] listFiles() :返回一个File数组,表示该File目录中的所有的子文件或目录。
2.IO概述
IO流简介

I(Input)O(Output):中文翻译为输入输出,我们知道计算机的数据不管是软件、视频、音乐、游戏等最终都是存储在硬盘中的,当我们打开后,由CPU将硬盘中的数据读取到内存中来运行。这样一个过程就产生了I/O(输入/输出)

在这里插入图片描述

水的流向我们成为水流,数据的流动成为数据流,我们简称为流;数据的流向根据流向的不同,我们分为输入(Input)流和输出(Output)流,简称输入输出流,或称IO流;

IO流的分类

根据数据的流向分为:输入流输出流

  • 输入流 :把数据从其他设备上读取到内存中的流。
  • 输出流 :把数据从内存 中写出到其他设备上的流。

根据操作数据单位的不同分为:字节流字符流

  • 字节流 :以字节为单位,读写数据的流。
  • 字符流 :以字符为单位,读写数据的流。

在Java中描述流的底层父类:

抽象基类输入流输出流
字节流字节输入流(InputStream)字节输出流(OutputStream)
字符流字符输入流(Reader)字符输出流(Writer)

Java的IO流共涉及40多个类,都是从如下4个抽象基类派生的。由这四个类派生出来的子类名称都是以其父类名作为子类名后缀。

3.字节流

字节流有两个顶层接口父类,分别是字节输入流(InputStream)和字节输出流(OuputStream)

在这里插入图片描述

字节输出流

OutputStream是所有字节输出的顶层父类,该父类提供如下公共方法:

  • public void close() :关闭此输出流并释放与此流相关联的任何系统资源。
  • public void flush() :刷新此输出流并强制任何缓冲的输出字节被写出。
  • public void write(byte[] b):将 b.length字节从指定的字节数组写入此输出流。
  • public void write(byte[] b, int off, int len) :从指定的字节数组写入 len字节,从偏移量 off开始输出到此输出流。
  • public abstract void write(int b) :将指定的字节输出流。

tips:close方法,当完成流的操作时,必须调用此方法,释放系统资源。

FileOutputStream类

OutputStream是抽象类,不可以实例化对象,我们使用它的子类FileOutputStream;FileOutputStream可以关联一个文件,用于将数据写出到文件。

FileOutputStream的构造方法如下:
  • public FileOutputStream(File file):创建文件输出流以写入由指定的 File对象表示的文件。
  • public FileOutputStream(String name): 创建文件输出流以指定的名称写入文件。

tips:当创建一个流对象时,需要指定一个文件路径,如果该文件以及存在则会清空文件中的数据,如果不存在则创建一个新的文件;

package com.dfbz.demo01;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo01 {
    public static void main(String[] args) throws FileNotFoundException {
        // 使用File对象创建流对象
        File file = new File("a.txt");
        FileOutputStream fos1 = new FileOutputStream(file);

        // 使用文件名称创建流对象
        FileOutputStream fos2 = new FileOutputStream("b.txt");
    }
}

写出字节
  1. 写出字节write(int b) 方法,每次可以写出一个字节数据,代码使用演示:

    package com.dfbz.demo01;
    
    import java.io.FileOutputStream;
    import java.io.IOException;
    
    /**
     * @author lscl
     * @version 1.0
     * @intro:
     */
    public class Demo02 {
        public static void main(String[] args) throws IOException {
            // 使用文件名称创建流对象
            FileOutputStream fos = new FileOutputStream("abc.txt");
            // 写出数据
            fos.write(97); // 写出第1个字节(a)
            fos.write(98); // 写出第2个字节(b)
            fos.write(99); // 写出第3个字节(c)
            // 关闭资源
            fos.close();
        }
    }
    
    

tips:虽然参数为int类型四个字节,但是只会保留一个字节的信息写出

  1. 写出字节数组write(byte[] b),每次可以写出数组中的数据,
  2. 写出指定长度字节数组write(byte[] b, int off, int len) ,每次写出从off索引开始,len个字节,代码使用演示:
数据追加续写

经过以上的演示,每次程序运行,创建输出流对象,都会清空目标文件中的数据。如何保留目标文件中数据,还能继续添加新数据呢?

  • public FileOutputStream(File file, boolean append): 创建文件输出流以写入由指定的 File对象表示的文件。
  • public FileOutputStream(String name, boolean append): 创建文件输出流以指定的名称写入文件。

这两个构造方法,参数中都需要传入一个boolean类型的值,true 表示追加数据,false 表示清空原有数据。这样创建的输出流对象,就可以指定是否追加续写了.

写出换行

Windows系统里,换行符号是\r\n 。以指定是否追加续写了,代码使用演示:

package com.dfbz.demo01;

import java.io.FileOutputStream;
import java.io.IOException;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo06 {
    public static void main(String[] args) throws IOException {
        // 使用文件名称创建流对象
        FileOutputStream fos = new FileOutputStream("abc.txt");
        // 定义字节数组
        byte[] words = {97, 98, 99, 100, 101};
        // 遍历数组
        for (int i = 0; i < words.length; i++) {
            // 写出一个字节
            fos.write(words[i]);
            // 写出一个换行, 换行符号转成数组写出
            fos.write("\r\n".getBytes());
        }
        // 关闭资源
        fos.close();
    }
}
  • 回车符\r和换行符\n
    • 回车符:回到一行的开头(return)。
    • 换行符:下一行(newline)。
  • 系统中的换行:
    • Windows系统里,每行结尾是 回车+换行 ,即\r\n
    • Unix系统里,每行结尾只有 换行 ,即\n
    • Mac系统里,每行结尾是 回车 ,即\r。从 Mac OS X开始与Linux统一。
字节输入流

java.io.InputStream抽象类是表示字节输入流的所有类的超类,可以读取字节信息到内存中。它定义了字节输入流的基本共性功能方法。

  • public void close() :关闭此输入流并释放与此流相关联的任何系统资源。
  • public abstract int read(): 从输入流读取数据的下一个字节。
  • public int read(byte[] b): 从输入流中读取一些字节数,并将它们存储到字节数组 b中 。

tips:close方法,当完成流的操作时,必须调用此方法,释放系统资源。

FileInputStream类

java.io.FileInputStream类是文件输入流,从文件中读取字节。

构造方法
  • FileInputStream(File file): 通过打开与实际文件的连接来创建一个 FileInputStream ,该文件由文件系统中的 File对象 file命名。
  • FileInputStream(String name): 通过打开与实际文件的连接来创建一个 FileInputStream ,该文件由文件系统中的路径名 name命名。

当你创建一个流对象时,必须传入一个文件路径。该路径下,如果没有该文件,会抛出FileNotFoundException

package com.dfbz.demo02;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo01 {
    public static void main(String[] args) throws FileNotFoundException {
        // 使用File对象创建流对象
        File file = new File("a.txt");
        FileInputStream fos = new FileInputStream(file);

        // 使用文件名称创建流对象
        FileInputStream fos2 = new FileInputStream("b.txt");
    }
}

读取字节数据

将abc.txt文件的内容改为abcde

  1. 读取字节read方法,每次可以读取一个字节的数据,提升为int类型,读取到文件末尾,返回-1,代码使用演示:
package com.dfbz.demo02;

import java.io.FileInputStream;
import java.io.IOException;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo03 {
    public static void main(String[] args) throws IOException {
        // 使用文件名称创建流对象
        FileInputStream fis = new FileInputStream("abc.txt");
        // 定义变量,保存数据
        int b;
        // 循环读取,只要读取的不是-1就继续读
        while ((b = fis.read()) != -1) {
            System.out.println((char) b);
        }
        // 关闭资源
        fis.close();
    }
}

tips:虽然读取了一个字节,但是会自动提升为int类型。

  1. 使用字节数组读取read(byte[] b),每次读取b的长度个字节到数组中,返回读取到的有效字节个数,读取到末尾时,返回-1 ,代码使用演示:
package com.dfbz.demo02;

import java.io.FileInputStream;
import java.io.IOException;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo04 {
    public static void main(String[] args) throws IOException {
        // 使用文件名称创建流对象.
        FileInputStream fis = new FileInputStream("abc.txt"); // 文件中为abcde
        // 定义变量,作为有效个数
        int len ;
        // 定义字节数组,作为装字节数据的容器
        byte[] b = new byte[2];
        // 循环读取
        while ((len = fis.read(b)) != -1) {
            // 每次读取后,把数组变成字符串打印
            System.out.println(new String(b));
        }
        // 关闭资源
        fis.close();
    }
}

运行结果:

在这里插入图片描述

发现d出现了两次,这是由于最后一次读取时,只读取到了一个有效字节“e”,替换了原数组的0下标的“c”,图解分析如下:

在这里插入图片描述

我们在转换的时候不能全部转换,而是只转换有效的字节,所以要通过len(实际读取到的字节个数) ,获取有效的字节,来决定到底转换多少个字节;

package com.dfbz.demo02;

import java.io.FileInputStream;
import java.io.IOException;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo05 {
    public static void main(String[] args) throws IOException {
        // 使用文件名称创建流对象.
        FileInputStream fis = new FileInputStream("abc.txt"); // 文件中为abcde
        // 定义变量,作为有效个数
        int len;
        // 定义字节数组,作为装字节数据的容器
        byte[] b = new byte[2];
        // 循环读取
        while ((len = fis.read(b)) != -1) {
            // 每次读取后,把数组的有效字节部分,变成字符串打印
            System.out.println(new String(b, 0, len));//  len 每次读取的有效字节个数
        }
        // 关闭资源
        fis.close();
    }
}

4.字符流

计算机都是按照字节进行存储的,我们之前学习过编码表,通过编码表可以将字节转换为对应的字符,但是世界上有非常多的编码表,不同的编码表规定的单个字符所占用的字节可能都不一样,且一个字符都是由多个字节组成的,为此我们不能再基于字节的操作单位来操作文本文件了,因为这样太过麻烦,我们希望基于字符来操作文件,一次操作读取一个“字符”而不是一个“字节”,这样在操作文本文件时非常便捷;

字符输入流

java.io.Reader抽象类是表示用于读取字符流的所有类的超类,可以读取字符信息到内存中。它定义了字符输入流的基本共性功能方法。

  • public void close() :关闭此流并释放与此流相关联的任何系统资源。
  • public int read(): 从输入流读取一个字符。
  • public int read(char[] cbuf): 从输入流中读取一些字符,并将它们存储到字符数组 cbuf中 。
FileReader类

java.io.FileReader类是读取字符文件的便利类。构造时使用系统默认的字符编码和默认字节缓冲区。

tips:

  1. 字符编码:字节与字符的对应规则。Windows系统的中文编码默认是GBK编码表。idea中默认是UTF-8
  2. 字节缓冲区:一个字节数组,用来临时存储字节数据。
构造方法
  • FileReader(File file): 创建一个新的 FileReader ,给定要读取的File对象。
  • FileReader(String fileName): 创建一个新的 FileReader ,给定要读取的文件的名称。

当你创建一个流对象时,必须传入一个文件路径。类似于FileInputStream 。

package com.dfbz.demo01;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileReader;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo01 {
    public static void main(String[] args) throws FileNotFoundException {
        // 使用File对象创建流对象
        File file = new File("a.txt");
        FileReader fr = new FileReader(file);

        // 使用文件名称创建流对象
        FileReader fr2 = new FileReader("b.txt");
    }
}

读取字符数据
  1. 读取字符read方法,每次可以读取一个字符的数据,提升为int类型,读取到文件末尾,返回-1,循环读取,代码使用演示:
package com.dfbz.demo01;

import java.io.FileReader;
import java.io.IOException;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo02 {
    public static void main(String[] args) throws IOException {
        // 使用文件名称创建流对象
        FileReader fr = new FileReader("read.txt");
        // 定义变量,保存数据
        int b;
        // 循环读取
        while ((b = fr.read()) != -1) {
            System.out.println((char) b);
        }
        // 关闭资源
        fr.close();
    }
}
输出结果:
我
是
中
国
人

tips:虽然读取了一个字符,但是会自动提升为int类型。

  1. 使用字符数组读取read(char[] cbuf),每次读取b的长度个字符到数组中,返回读取到的有效字符个数,读取到末尾时,返回-1 ,代码使用演示:
package com.dfbz.demo01;

import java.io.FileReader;
import java.io.IOException;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo04 {
    public static void main(String[] args) throws IOException {
        // 使用文件名称创建流对象
        FileReader fr = new FileReader("read.txt");
        // 定义变量,保存有效字符个数
        int len;
        // 定义字符数组,作为装字符数据的容器
        char[] cbuf = new char[2];
        // 循环读取
        while ((len = fr.read(cbuf)) != -1) {
            System.out.println(new String(cbuf, 0, len));
        }
        // 关闭资源
        fr.close();
    }
}


输出结果:
我是
中国
人

字符输出流

java.io.Writer抽象类是表示用于写出字符流的所有类的超类,将指定的字符信息写出到目的地。它定义了字节输出流的基本共性功能方法。

  • void write(int c) 写入单个字符。
  • void write(char[] cbuf)写入字符数组。
  • abstract void write(char[] cbuf, int off, int len)写入字符数组的某一部分,off数组的开始索引,len写的字符个数。
  • void write(String str)写入字符串。
  • void write(String str, int off, int len) 写入字符串的某一部分,off字符串的开始索引,len写的字符个数。
  • void flush()刷新该流的缓冲。
  • void close() 关闭此流,但要先刷新它。
FileWriter类

java.io.FileWriter类是写出字符到文件的便利类。构造时使用系统默认的字符编码和默认字节缓冲区。

构造方法
  • FileWriter(File file): 创建一个新的 FileWriter,给定要读取的File对象。
  • FileWriter(String fileName): 创建一个新的 FileWriter,给定要读取的文件的名称。

当你创建一个流对象时,必须传入一个文件路径,类似于FileOutputStream。

基本写出数据

写出字符write(int b) 方法,每次可以写出一个字符数据,代码使用演示:

package com.dfbz.demo02;

import java.io.FileWriter;
import java.io.IOException;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo02 {
    public static void main(String[] args) throws IOException {
        // 使用文件名称创建流对象
        FileWriter fw = new FileWriter("fw.txt");
        // 写出数据
        fw.write(97); // 写出第1个字符
        fw.write('b'); // 写出第2个字符
        fw.write('C'); // 写出第3个字符
        fw.write(22909); // 写出第4个字符,中文编码表中22909对应一个汉字"好"。

      	/*
        【注意】关闭资源时,与FileOutputStream不同。
      	 如果不关闭,数据只是保存到缓冲区,并未保存到文件。
        */
         fw.close();
    }
}

输出结果:
abC好

tips:

  1. 虽然参数为int类型四个字节,但是只会保留一个字符的信息写出。
  2. 未调用close方法,数据只是保存到了缓冲区,并未写出到文件中。
关闭和刷新

因为内置缓冲区的原因,如果不关闭输出流,无法写出字符到文件中。但是关闭的流对象,是无法继续写出数据的。如果我们既想写出数据,又想继续使用流,就需要flush 方法了。

  • flush :刷新缓冲区,流对象可以继续使用。
  • close:先刷新缓冲区,然后通知系统释放资源。流对象不可以再被使用了。

即便是flush方法写出了数据,操作的最后还是要调用close方法,释放系统资源。

写出其他数据
  1. 写出字符数组write(char[] cbuf)write(char[] cbuf, int off, int len) ,每次可以写出字符数组中的数据,用法类似FileOutputStream,代码使用演示:
package com.dfbz.demo02;

import java.io.FileWriter;
import java.io.IOException;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo04 {
    public static void main(String[] args) throws IOException {
        // 使用文件名称创建流对象
        FileWriter fw = new FileWriter("fw.txt");
        // 字符串转换为字节数组
        char[] chars = "我是中国人".toCharArray();

        // 写出字符数组
        fw.write(chars); // 我是中国人

        // 写出从索引2开始,2个字节。索引2是'中',两个字节,也就是'中国'。
        fw.write(chars,2,2); // 中国

        // 关闭资源
        fw.close();
    }
}
  1. 写出字符串write(String str)write(String str, int off, int len) ,每次可以写出字符串中的数据,更为方便,代码使用演示:
package com.dfbz.demo02;

import java.io.FileWriter;
import java.io.IOException;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo05 {
    public static void main(String[] args) throws IOException {        // 使用文件名称创建流对象
        FileWriter fw = new FileWriter("fw.txt");            // 字符串
        String msg = "我是中国人";                // 写出字符数组
        fw.write(msg); //我是中国人      		// 写出从索引2开始,2个字节。索引2是'中',两个字节,也就是'中国'。
        fw.write(msg, 2, 2);    // 中国
        // 关闭资源
        fw.close();
    }
}
  1. 续写和换行:操作类似于FileOutputStream。
package com.dfbz.demo02;

import java.io.FileWriter;
import java.io.IOException;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo06 {
    public static void main(String[] args) throws IOException {
        // 使用文件名称创建流对象,可以续写数据
        FileWriter fw = new FileWriter("fw.txt", true);
        // 写出字符串
        fw.write("我是");
        // 写出换行
        fw.write("\r\n");
        // 写出字符串
        fw.write("中国人");
        // 关闭资源
        fw.close();
    }
}

输出结果:
我是
中国人

字符流,只能操作文本文件,不能操作图片,视频等非文本文件。当我们单纯读或者写文本文件时 使用字符流 其他情况使用字节流

5.属性集

java.util.Properties 继承于Hashtable ,来表示一个持久的属性集。它使用键值结构存储数据,每个键及其对应值都是一个字符串。该类也被许多Java类使用,比如获取系统属性时,System.getProperties 方法就是返回一个Properties对象。

Properties类
构造方法
  • public Properties() :创建一个空的属性列表。
基本的存储方法
  • public Object setProperty(String key, String value) : 保存一对属性。
  • public String getProperty(String key) :使用此属性列表中指定的键搜索属性值。
  • public Set<String> stringPropertyNames() :所有键的名称的集合。
与流相关的方法
  • public void load(InputStream inStream): 从字节输入流中读取键值对。

参数中使用了字节输入流,通过流对象,可以关联到某文件上,这样就能够加载文本中的数据了。

文本数据格式:

filename=a.txt
length=209385038
location=D:\a.txt
package com.dfbz.demo03;

import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.Properties;
import java.util.Set;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo02 {
    public static void main(String[] args) throws IOException {
        // 创建属性集对象
        Properties prop = new Properties();
        // 加载文本中信息到属性集
        prop.load(new FileInputStream("prop.txt"));
        // 遍历集合并打印
        Set<String> strings = prop.stringPropertyNames();
        for (String key : strings ) {
            System.out.println(key+" -- "+prop.getProperty(key));
        }
    }
}

输出结果:
filename -- a.txt
length -- 209385038
location -- D:\a.txt

文本中的数据,必须是键值对形式,可以使用空格、等号、冒号等符号分隔。

6.转换流
字符编码和字符集
编码与解码

计算机中储存的信息都是用二进制数表示的,而我们在屏幕上看到的数字、英文、标点符号、汉字等字符是二进制数转换之后的结果。按照某种规则,将字符存储到计算机中,称为编码 。反之,将存储在计算机中的二进制数按照某种规则解析显示出来,称为解码 。比如说,按照A规则存储,同样按照A规则解析,那么就能显示正确的文本符号。反之,按照A规则存储,再按照B规则解析,就会导致乱码现象。

字符集与编码
  • 字符集(charset)字符集简单来说就是指字符的集合,例如所有的英文字母是一个字符集,所有的汉字是一个字符集,当然,把全世界所有语言的符号都放在一起,也可以称为一个字符集。计算机中的字符包括文字、图形符号、数学符号等;
编码的问题

当文本写入时的编码与读取时的编码不一致时就会出现乱码的现象;

准备两个文件,一个采用GB2312编码,一个采用UTF-8编码

准备Java代码分别读取两个文件:

发现在读取GB2312时出现中文乱码

在这里插入图片描述

这是因为IDEA默认情况下都是采用UTF-8进行编码与解码,平常我们在操作时感觉不到编码的问题;而我们手动编辑了一个文本文件以GB2312的编码格式保存,此时再使用UTF-8编码进行读取就出现乱码问题;

转换输入流

转换流java.io.InputStreamReader,是Reader的子类,是从字节流到字符流的桥梁。它读取字节,并使用指定的字符集将其解码为字符。它的字符集可以由名称指定,也可以接受平台的默认字符集。

构造方法

  • InputStreamReader(InputStream in): 创建一个使用默认字符集的字符流。
  • InputStreamReader(InputStream in, String charsetName): 创建一个指定字符集的字符流。
InputStreamReader isr = new InputStreamReader(new FileInputStream("in.txt"));
InputStreamReader isr2 = new InputStreamReader(new FileInputStream("in.txt") , "GBK");

使用转换输入流读取

package com.dfbz.demo01;

import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStreamReader;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo02 {
    public static void main(String[] args) throws IOException {
        // 定义文件路径,文件为gbk编码
        String filename = "C:\\Users\\Horizon\\Desktop\\hello.txt";
        // 创建流对象,指定GBK编码(默认UTF-8编码)
        InputStreamReader isr = new InputStreamReader(new FileInputStream(filename), "GB2312");

        // 定义变量,保存字符
        int read;

        // 使用指定编码字符流读取,正常解析
        while ((read = isr.read()) != -1) {
            System.out.print((char) read);// 你好
        }
        isr.close();
    }
}
转换输出流

转换流java.io.OutputStreamWriter ,是Writer的子类,是从字符流到字节流的桥梁。使用指定的字符集将字符编码为字节。它的字符集可以由名称指定,也可以接受平台的默认字符集。

构造方法

  • OutputStreamWriter(OutputStream in): 创建一个使用默认字符集的字符流。
  • OutputStreamWriter(OutputStream in, String charsetName): 创建一个指定字符集的字符流。
OutputStreamWriter isr = new OutputStreamWriter(new FileOutputStream("out.txt"));
OutputStreamWriter isr2 = new OutputStreamWriter(new FileOutputStream("out.txt") , "GBK");

UTF-8编码表中一个中文汉字占3个字节,GBK则占2个字节;

7.缓冲流

计算机访问外部设备或文件,要比直接访问内存慢的多。如果我们每次调用read()方法或者writer()方法访问外部的设备或文件,CPU就要花上最多的时间是在等外部设备响应,而不是数据处理。为此,我们开辟一个内存缓冲区的内存区域,程序每次调用read()方法或writer()方法都是读写在这个缓冲区中。当这个缓冲区被装满后,系统才将这个缓冲区的内容一次集中写到外部设备或读取进来给CPU。使用缓冲区可以有效的提高CPU的使用率,能提高整个计算机系统的效率。

缓冲流的分类
输入缓冲流输出缓冲流
字节缓冲流BufferedInputStreamBufferedOutputStream
字符缓冲流BufferedReaderBufferedWriter

缓冲流在读取/写出数据时,内置有一个默认大小为8192字节/字符的缓冲区数组,当发生一次IO时读满8192个字节再进行操作,从而提高读写的效率。

字节缓冲流
  • public BufferedInputStream(InputStream in) :创建一个 新的缓冲输入流。
  • public BufferedOutputStream(OutputStream out): 创建一个新的缓冲输出流。

我们分别使用普通流和缓冲流对一个大小为10.4MB的文件进行拷贝,查看两种方案所花费的时间;

在这里插入图片描述

package com.dfbz.demo01;

import java.io.*;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo02 {
    public static void main(String[] args) throws FileNotFoundException {
        // 记录开始时间
        long start = System.currentTimeMillis();
        // 创建流对象
        try (
                BufferedInputStream bis = new BufferedInputStream(new FileInputStream("D:\\apache-tomcat-8.0.43.zip"));
                BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream("D:\\apache-tomcat-8.0.43_bak.zip"));
        ){
            // 读写数据
            int b;
            while ((b = bis.read()) != -1) {
                bos.write(b);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        // 记录结束时间
        long end = System.currentTimeMillis();
        System.out.println("缓冲流复制时间:"+(end - start)+" 毫秒");
    }
}

在这里插入图片描述

可以看出,缓冲流拷贝文件的效率比普通流高太多太多,因此我们在做大文件拷贝时应该尽量选用缓冲流;

字符缓冲流

在字节缓冲流中,内部维护了一个8192大小的一个字节数组,字符流则是内部维护了一个8192大小的字符数组;在一次IO时读取8192个字符,提升读取/写入性能。另外,字符缓冲流在普通流的基础上添加了一些独特的方法,让我们读取/写出字符更加方便;

  • public BufferedReader(Reader in) :创建一个 新的缓冲输入流。
  • public BufferedWriter(Writer out): 创建一个新的缓冲输出流。

示例代码:

// 创建字符缓冲输入流
BufferedReader br = new BufferedReader(new FileReader("br.txt"));

// 创建字符缓冲输出流
BufferedWriter bw = new BufferedWriter(new FileWriter("bw.txt"));

字符缓冲流的基本方法与普通字符流调用方式一致,不再阐述,我们来看它们具备的特有方法。

  • BufferedReader:public String readLine(): 读一行文字。
  • BufferedWriter:public void newLine(): 写一行行分隔符,由系统属性定义符号。

newLine方法示例代码如下:

package com.dfbz.demo02;

import java.io.*;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo01 {
    public static void main(String[] args) throws IOException {
        // 创建流对象
        BufferedWriter bw = new BufferedWriter(new FileWriter("test.txt"));
        // 写出数据
        bw.write("我是");
        // 写出换行
        bw.newLine();

        bw.write("中国");
        bw.newLine();

        bw.write("人");
        bw.newLine();

        // 释放资源
        bw.close();
    }
}

readLine方法示例代码如下:

package com.dfbz.demo02;

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo02 {
    public static void main(String[] args) throws IOException {
        // 创建流对象
        BufferedReader br = new BufferedReader(new FileReader("test.txt"));
        // 定义字符串,保存读取的一行文字
        String line  = null;
        // 循环读取,读取到最后返回null
        while ((line = br.readLine())!=null) {
            System.out.println(line);
        }
        // 释放资源
        br.close();
    }
}
8.序列流
序列化概述

Java 提供了一种对象序列化的机制。用一个字节序列可以表示一个对象,该字节序列包含该对象的数据、对象的类型和对象中存储的属性等信息。字节序列写出到文件之后,相当于文件中持久保存了一个对象的信息。

反之,该字节序列还可以从文件中读取回来,重构对象,对它进行反序列化。对象的数据、对象的类型和对象中存储的数据信息,都可以用来在内存中创建对象。看图理解序列化:

在这里插入图片描述

对象输出流

java.io.ObjectOutputStream 类,将Java对象的原始数据类型写出到文件,实现对象的持久存储。

  • public ObjectOutputStream(OutputStream out): 创建一个指定OutputStream的ObjectOutputStream。

示例代码:

FileOutputStream fileOut = new FileOutputStream("goods.txt");
ObjectOutputStream out = new ObjectOutputStream(fileOut);

对象的序列化

一个对象要想序列化,必须满足两个条件:

  • 该类必须实现java.io.Serializable 接口,Serializable 是一个标记接口,不实现此接口的类将不会使任何状态序列化或反序列化,会抛出NotSerializableException
  • 该类的所有属性必须是可序列化的。如果有一个属性不需要可序列化的,则该属性必须注明是瞬态的,使用transient 关键字修饰。

写出对象方法:

  • public final void writeObject (Object obj) : 将指定的对象写出。
package com.dfbz.demo01;

import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectOutputStream;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo01 {
    public static void main(String[] args) {
        Goods goods = new Goods("赣南脐橙",20.0D,20000);
        try {
            // 创建序列化流对象
            ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("goods.txt"));
            // 写出对象
            oos.writeObject(goods);     // title、price被序列化,store没有被序列化。
            // 释放资源
            oos.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}
对象输入流

ObjectInputStream反序列化流,将之前使用ObjectOutputStream序列化的原始数据恢复为对象。

  • public ObjectInputStream(InputStream in): 创建一个指定InputStream的ObjectInputStream。

如果能找到一个对象的class文件,我们可以进行反序列化操作,调用ObjectInputStream读取对象的方法:

  • public final Object readObject () : 读取一个对象。
package com.dfbz.demo01;

import java.io.FileInputStream;
import java.io.IOException;
import java.io.ObjectInputStream;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo02 {
    public static void main(String[] args) {
        Goods goods = null;
        try {
            // 创建反序列化流
            FileInputStream fis = new FileInputStream("goods.txt");
            ObjectInputStream ois = new ObjectInputStream(fis);
            // 读取一个对象
            goods = (Goods) ois.readObject();
            // 释放资源
            fis.close();
            ois.close();
        } catch (IOException | ClassNotFoundException e) {
            // 捕获其他异常
            e.printStackTrace();
        }
        
        System.out.println(goods.toString());   // Goods{title='赣南脐橙', price=20.0, store=null}
        
    }
}
序列化版本号

当JVM反序列化对象时,能找到class文件,但是class文件在序列化对象之后发生了修改,那么反序列化操作也会失败,抛出一个InvalidClassException异常。发生这个异常的原因如下:

  • 1)该类的序列版本号与从流中读取的类描述符的版本号不匹配
  • 2)该类包含未知数据类型
  • 3)该类没有可访问的无参数构造方法

Serializable 接口给需要序列化的类,提供了一个序列版本号。serialVersionUID 该版本号的目的在于验证序列化的对象和对应类是否版本匹配。

package com.dfbz.demo01;

import java.io.Serializable;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Goods implements Serializable {

    // 序列化版本号
    private static final long serialVersionUID = 1L;

    // 标题
    private String title;

    // 价格
    private Double price;
    
    private transient Integer store;        // transient修饰的成员不会被序列化
}

对象在序列化时,如果指定了序列号,那么在反序列化时会默认读取到上次序列化时的序列化,即使类已经修改过也没有关系;

9. 打印流

打印流java.io.PrintStream类是OutputStream的一个子类,因此也是属于字节输出流。打印流的功能主要是将数据打印(输出)到控制台,方便我们输出的;

我们平时向控制台输出内容都是借助打印流来完成的:

在这里插入图片描述

打印流的使用
  • public PrintStream(String fileName): 使用指定的文件名创建一个新的打印流。

示例代码:

PrintStream ps = new PrintStream("ps.txt")

打印流输出内容

  • public void println(String x):将指定的字符串输出
  • public void println(int x):将指定的整形输出

我们之前通过System.out获取到的就是一个打印流,因此打印流的方法不多赘述;

示例代码:

package com.dfbz.demo01;

import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.PrintStream;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo01 {
    public static void main(String[] args) throws FileNotFoundException {
        PrintStream ps = new PrintStream("ps.txt");

        // 打印并换行
        ps.println("我是");
        ps.println("中国");
        ps.println("人");

        ps.close();
    }
}

会输出到文件ps.txt中

标准输入输出流
  • System.inSystem.out分别代表了系统标准的输入输出设备

  • 默认输入设备是:键盘,输出设备是:显示器

  • System.in的类型是InputStream

  • System.out的类型是PrintStreamPrintStream是OutputStream的子类FilterOutputStream的子类

  • 重定向:通过System类的setIn,setOut方法对默认设备进行改变。

    • public static void setIn(InputStream in)
    • public static void setOut(PrintStream out)
标准输入流

标准输入流是一个缓冲字节输入流(BufferedInputStream),他默认指向的是控制台(键盘),通过System.in获取;

我们之前在用Scanner的时候,构造方法如下:

在这里插入图片描述

System.in获取的就是标准输入流(指向键盘),因此Scanner总是接受我们键盘输入的数据,我们可以更改Scanner读取的流对象:

package com.dfbz.demo01;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.util.Scanner;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo02 {
    public static void main(String[] args) throws FileNotFoundException {

        Scanner sc = new Scanner(new FileInputStream("ps.txt"));

        String str = sc.next();
        System.out.println(str);

        str=sc.next();          // 此时读取的不是控制台的数据,而是ps.txt中的数据
        System.out.println(str);

        str=sc.next();
        System.out.println(str);

        str=sc.next();              // 读取到结尾 抛出java.util.NoSuchElementException
        System.out.println(str);
    }
}

也可以更改标准输入流:

package com.dfbz.demo01;

import java.io.FileInputStream;
import java.io.FileNotFoundException;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo03 {
    public static void main(String[] args) throws FileNotFoundException {
        // 更改标准输入流        
        System.setIn(new FileInputStream("StandardInputStream.txt"));
    }
}
标准输出流

标准输出流是一个打印流(PrintStream),他默认指向的是控制台,通过System.out获取标准输出流,因此我们之前总是使用System.out.println往控制台输出内容;

我们也可以更改标准输出流,让其不在输出到控制台,而是输出到我们制定的地方:

package com.dfbz.demo01;

import java.io.*;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo04 {
    public static void main(String[] args) throws FileNotFoundException {

        // 修改标准输出流
        System.setOut(new PrintStream("StandardOutputStream.txt"));

        // 此时输出到StandardOutputStream.txt文件中,而不是控制台
        System.out.println("我是中国人");
        System.out.println("犯我中华者");
        System.out.println("虽远必诛");
    }
}

BIO、NIO、AIO

https://blog.csdn.net/weixin_55604133/article/details/117048887

IO模型主要分类:

  • 同步(synchronous) IO和异步(asynchronous) IO
  • 阻塞(blocking) IO和非阻塞(non-blocking)IO
  • 同步阻塞(blocking-IO)简称BIO
  • 同步非阻塞(non-blocking-IO)简称NIO
  • 异步非阻塞(synchronous-non-blocking-IO)简称AIO

1.BIO (同步阻塞I/O模式)

数据的读取写入必须阻塞在一个线程内等待其完成。

这里使用那个经典的烧开水例子,这里假设一个烧开水的场景,有一排水壶在烧开水,BIO的工作模式就是, 叫一个线程停留在一个水壶那,直到这个水壶烧开,才去处理下一个水壶。但是实际上线程在等待水壶烧开的时间段什么都没有做。

2.NIO(同步非阻塞)

同时支持阻塞与非阻塞模式,但这里我们以其同步非阻塞I/O模式来说明,那么什么叫做同步非阻塞?如果还拿烧开水来说,NIO的做法是叫一个线程不断的轮询每个水壶的状态,看看是否有水壶的状态发生了改变,从而进行下一步的操作。

3.AIO (异步非阻塞I/O模型)

异步非阻塞与同步非阻塞的区别在哪里?异步非阻塞无需一个线程去轮询所有IO操作的状态改变,在相应的状态改变后,系统会通知对应的线程来处理。对应到烧开水中就是,为每个水壶上面装了一个开关,水烧开之后,水壶会自动通知我水烧开了。

9012c03d820618974295a445ffa79440.png

5.同步与异步的区别

  • 同步

发送一个请求,等待返回,再发送下一个请求,同步可以避免出现死锁,脏读的发生。

  • 异步

发送一个请求,不等待返回,随时可以再发送下一个请求,可以提高效率,保证并发。

6.阻塞和非阻塞

  • 阻塞

传统的IO流都是阻塞式的。也就是说,当一个线程调用read()或者write()方法时,该线程将被阻塞,直到有一些数据读读取或者被写入,在此期间,该线程不能执行其他任何任务。在完成网络通信进行IO操作时,由于线程会阻塞,所以服务器端必须为每个客户端都提供一个独立的线程进行处理,当服务器端需要处理大量的客户端时,性能急剧下降。

  • 非阻塞

JavaNIO是非阻塞式的。当线程从某通道进行读写数据时,若没有数据可用时,该线程会去执行其他任务。线程通常将非阻塞IO的空闲时间用于在其他通道上执行IO操作,所以单独的线程可以管理多个输入和输出通道。因此NIO可以让服务器端使用一个或有限几个线程来同时处理连接到服务器端的所有客户端。

BIO、NIO、AIO 有什么区别?

  • BIO:线程发起IO请求,不管内核是否准备好IO操作,从发起请求起,线程一直阻塞,直到操作完成。

  • NIO:线程发起IO请求,立即返回;内核在做好IO操作的准备之后,通过调用注册的回调函数通知线程做IO操作,线程开始阻塞,直到操作完成。

  • AIO:线程发起IO请求,立即返回;内存做好IO操作的准备之后,做IO操作,直到操作完成或者失败,通过调用注册的回调函数通知线程做IO操作完成或者失败。

  • BIO是一个连接一个线程。

  • NIO是一个请求一个线程。

  • AIO是一个有效请求一个线程。

  • BIO:同步并阻塞,服务器实现模式为一个连接一个线程,即客户端有连接请求时服务器端就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销,当然可以通过线程池机制改善。

  • NIO:同步非阻塞,服务器实现模式为一个请求一个线程,即客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有I/O请求时才启动一个线程进行处理。

  • AIO:异步非阻塞,服务器实现模式为一个有效请求一个线程,客户端的I/O请求都是由OS先完成了再通知服务器应用去启动线程进行处理。

适用场景分析

  • BIO方式适用于连接数目比较小且固定的架构,这种方式对服务器资源要求比较高,并发局限于应用中,JDK1.4以前的唯一选择,但程序直观简单易理解。
  • NIO方式适用于连接数目多且连接比较短(轻操作)的架构,比如聊天服务器,并发局限于应用中,编程比较复杂,JDK1.4开始支持。
  • AIO方式使用于连接数目多且连接比较长(重操作)的架构,比如相册服务器,充分调用OS参与并发操作,编程比较复杂,JDK7开始支持。

3.进阶

1.反射

Java反射机制概述
  • Reflection(反射)是被视为动态语言的关键,反射机制允许程序在执行期借助于Reflection API取得任何类的内部信息,并能直接操作任意对象的内部属性及方法。
  • 加载完类之后,在堆内存的方法区中就产生了一个Class类型的对象(一个类只有一个Class对象),这个对象就包含了完整的类的结构信息。我们可以通过这个对象看到类的结构。这个对象就像一面镜子,透过这个镜子看到类的结构,所以,我们形象的称之为:反射。

image-20201204163820495

动态语言

  • 是一类在运行时可以改变其结构的语言:例如新的函数、对象、甚至代码可以被引进,已有的函数可以被删除或是其他结构上的变化。通俗点说就是在运行时代码可以根据某些条件改变自身结构。主要动态语言:Object-C、C#、JavaScript、PHP、Python、Erlang。

静态语言

  • 与动态语言相对应的,运行时结构不可变的语言就是静态语言。如Java、C、C++。

Java不是动态语言,但Java可以称之为“准动态语言”。即Java有一定的动态性,我们可以利用反射机制、字节码操作获得类似动态语言的特性。Java的动态性让编程的时候更加灵活!

Java反射机制提供的功能

  • 在运行时判断任意一个对象所属的类
  • 在运行时构造任意一个类的对象
  • 在运行时判断任意一个类所具有的成员变量和方法
  • 在运行时获取泛型信息
  • 在运行时调用任意一个对象的成员变量和方法
  • 在运行时处理注解
  • 生成动态代理

反射的主要API

  • java.lang.Class:代表一个类
  • java.lang.reflect.Method:代表类的方法
  • java.lang.reflect.Field:代表类的成员变量
  • java.lang.reflect.Constructor:代表类的构造器

JVM如何构造实例

javac Person.java
java Person

img

类加载器

img

JAVA反射机制是在运行状态中,对于任意一个类,都能够知道这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意一个方法和属性;这种动态获取的信息以及动态调用对象的方法的功能称为java语言的反射机制。

要想解剖一个类,必须先要获取到该类的字节码文件对象。而解剖使用的就是Class类中的方法.所以先要获取到每一个字节码文件对应的Class类型的对象.

以上的总结就是什么是反射

反射就是把java类中的各种成分映射成一个个的Java对象

例如:一个类有:成员变量、方法、构造方法、包等等信息,利用反射技术可以对一个类进行解剖,把个个组成部分映射成一个个对象。(其实:一个类中这些成员方法、构造方法、在加入类中都有一个类来描述)

如图是类的正常加载过程:反射的原理在与class对象。

熟悉一下加载的时候:Class对象的由来是将class文件读入内存,并为之创建一个Class对象。

img

Class对象

反射是Java中的一种机制,可以通过Java代码对一个类进行解析;例如获取类的属性、方法、构造方法等

Java中一个类被加载到内存中后被java.lang.Class类所描述,该类又称字节码类,我们可以通过该类获取所描述的属性、方法、构造方法等;也就是说使用反射就必须先获取到Class对象(字节码对象);

Class类相关方法
  • public String getSimpleName(): 获得简单类名,只是类名,没有包
  • public String getName(): 获取完整类名,包含包名+类名
  • public T newInstance() :创建此 Class 对象所表示的类的一个新实例。要求:类必须有public的无参数构造方法
Constructor类

我们获取到一个类的字节码对象时,可以通过该字节码对象获取类的成员变量、成员方法、构造方法等,java.lang.reflect.Constructor类就是用于描述一个构造方法的;类中的每一个构造方法都是Constructor的对象,通过Constructor对象可以实例化对象。

  • public Constructor getConstructor(Class... parameterTypes)根据参数类型获取构造方法对象,只能获得public修饰的构造方法。如果不存在对应的构造方法,则会抛出 java.lang.NoSuchMethodException 异常。
  • Constructor getDeclaredConstructor(Class... parameterTypes):根据参数类型获取构造方法对象**,能获取所有的构造方法(public、默认、protected、private )**。如果不存在对应的构造方法,则会抛出 java.lang.NoSuchMethodException 异常。
  • Constructor[] getConstructors(): 获取所有的public修饰的构造方法
  • Constructor[] getDeclaredConstructors():获取所有构造方法,包括public、默认、protected、private

4.注解

jvm

image-20200705111640511

image-20200705112416101

1. 类加载器

.class文件-> 加载->链接(验证,准备,解析)->初始化

加载:将.class加载到内存中;将字节流代表的静态存储结构转化为方法区的运行时数据结构

链接:

  • 验证:因为.class文件是可以修改的,所以需要对加载的文件进行验证,看是否符合规范。保证虚拟机自身的安全
  • 准备:为类变量分配内存空间,并设置默认初始值,类变量也就是static修饰的成员变量。final修饰的类变量在给阶段直接显示赋值,不需要设置默认初始值。类变量分配到方法区中,实例变量随着对象一起分配到堆中。
  • 解析:字节码文件中使用的是字符引用,我们只能知道某个操作对应的引用的字符表示,无法知道该引用的地址,所有也就无法调用该引用。该阶段将字符引用解析为直接引用,将字符转化为了地址,可以找到该引用的内容,也就可以执行了。

初始化:执行中的代码,是静态变量的赋值操作和静态代码块的内容。而且该类初始化之前要将其父类也初始化。

加载的内容保存在方法区中:类的信息在方法区中,字面量保存到运行时常量池中。

类加载器中类的加载过程:

加载类主要靠:

  • defineclass加载.class文件到内存,
  • findclass用于写类的加载逻辑
  • loadclass实现双亲委派机制。设置调用findclass的时机来实现双亲委派机制

可以直接调用findclass来加载类,这样就绕过了loadclass破坏双亲委派机制,也可以重写loadclass的逻辑来破坏双亲委派机制

protected Class<?> loadClass(String name, boolean resolve)
            throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // 首先检查这个classsh是否已经加载过了
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    // c==null表示没有加载,如果有父类的加载器则让父类加载器加载
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        //如果父类的加载器为空 则说明递归到bootStrapClassloader了
                        //bootStrapClassloader比较特殊无法通过get获取
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {}
                if (c == null) {
                    //如果bootstrapClassLoader 仍然没有加载过,则递归回来,尝试自己去加载class
                    long t1 = System.nanoTime();
                    c = findClass(name);
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

new,forname().instance(),loadclass的区别

  • new得到的是强类型的类
  • forname.instance:调用的是无参构造,且返回的是object
  • forname加载类时,会执行初始化
  • loadclass加载时,类只处于加载阶段,后序的连接和初始化都没做

2.运行时数据区

1.PC寄存器

  • 任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的Java方法的JVM指令地址;或者,如果是在执行native方法,则是未指定值(undefined)

PC寄存器用来存储指向下一条指令的地址,也就是即将要执行的指令代码。由执行引擎读取下一条指令,并执行该指令。

image-20200705155728557

使用 PC寄存器 存储字节码指令地址有什么用呢?

或者问

为什么使用 PC寄存器 来记录当前线程的执行地址呢?

  • 因为线程是一个个的顺序执行流,CPU需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行
  • JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令

PC寄存器为什么被设定为私有的?

  • 我们都知道所谓的多线程在一个特定的时间段内只会执行其中某一个线程的方法,CPU会不停地做任务切换,这样必然导致经常中断或恢复,如何保证分毫无差呢?
  • 为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法自然是为每一个线程都分配一个PC寄存器,这样一来各个线程之间便可以进行独立计算,从而不会出现相互干扰的情况
  • 由于CPU时间片轮限制,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某个线程中的一条指令。
  • 这样必然导致经常中断或恢复,如何保证分毫无差呢?每个线程在创建后,都会产生自己的程序计数器和栈帧,程序计数器在各个线程之间互不影响。

时间片:

image-20200705161849557

  • CPU时间片即CPU分配给各个程序的时间,每个线程被分配一个时间段,称作它的时间片。
  • 在宏观上:我们可以同时打开多个应用程序,每个程序并行不悖,同时运行。
  • 但在微观上:由于只有一个CPU,一次只能处理程序要求的一部分,如何处理公平,一种方法就是引入时间片,每个程序轮流执行。

简单一句话:宏观并行,微观并发

2.虚拟机栈

内存中的栈与堆

  • 栈解决程序的运行问题,即程序如何执行,或者说如何处理数据
  • 堆解决的是数据存储的问题,即数据怎么放,放哪里

Java虚拟机栈(Java Virtual Machine Stack),早期也叫Java栈。每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次的Java方法调用

  • 栈是线程私有的
  • 一个方法对应一个栈帧的入栈和出栈

作用

主管Java程序的运行,它保存方法的局部变量(8 种基本数据类型、对象的引用地址)、部分结果,并参与方法的调用和返回。

  • 局部变量 VS 成员变量(属性)

  • 基本数据类型变量 VS 引用类型变量(类、数组、接口)

  • JVM直接对Java栈的操作只有两个:

    • 每个方法执行,伴随着进栈(入栈、压栈)
    • 执行结束后的出栈工作
  • 对于栈来说不存在垃圾回收 (GC) 问题(栈存在溢出的情况)

面试题:栈中可能出现的异常

  • Java 虚拟机规范允许Java栈的大小是动态的或者是固定不变的。
    • 如果采用固定大小的Java虚拟机栈,那每一个线程的Java虚拟机栈容量可以在线程创建的时候独立选定。
    • 如果线程请求分配的栈容量超过Java虚拟机栈允许的最大容量,Java虚拟机将会抛出一个StackoverflowError 异常。简称:栈溢出
    • 如果Java虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那Java虚拟机将会抛出一个 OutOfMemoryError 异常

栈中存储什么?

  • 每个线程都有自己的栈,栈中的数据都是以栈帧(Stack Frame)为基本单位存储的
  • 在这个线程上正在执行的每个方法都各自对应一个栈帧(Stack Frame)。
    • 一个方法的执行对应一个栈帧的入栈,一个方法的执行结束对应一个栈帧的出栈
  • 栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息。

栈的运行原理

  • JVM直接对Java栈的操作只有两个,就是对栈帧的压栈出栈遵循先进后出(后进先出)原则

  • 在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为

    当前栈帧(Current Frame)
    
    • 与当前栈帧相对应的方法就是当前方法(Current Method)
    • 定义这个方法的类就是当前类(Current Class)
  • 执行引擎运行的所有字节码指令只针对当前栈帧进行操作。

  • 如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,成为新的当前帧

  • 不同线程中所包含的栈帧是不允许存在相互引用的即不可能在一个栈帧之中引用另外一个线程的栈帧

  • 如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。

  • Java方法有两种返回函数的方式,但不管使用哪种方式,都会导致栈帧被弹出

    • 一种是正常的函数返回,使用 return 指令
    • 另外一种是抛出异常

栈帧的内部结构

  • 每个栈帧中存储着:
    • 局部变量表(Local Variables)
    • 操作数栈(Operand Stack)(或表达式栈)
    • 动态链接(Dynamic Linking)(或指向运行时常量池的方法引用)
    • 方法返回地址(Return Address)(或方法正常退出或者异常退出的定义)
    • 一些附加信息
局部变量表

局部变量表:Local Variables,也被称之为局部变量数组本地变量表

  • 定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类基本数据类型对象引用(reference),以及 returnAddress(返回值) 类型

  • 局部变量表,最基本的存储单元是Slot(变量槽)

  • 局部变量表中存放编译期可知的各种基本数据类型(8种),引用类型(reference),returnAddress类型的变量。

  • 在局部变量表里,32位以内的类型只占用一个slot(包括 引用类型、returnAddress类型),64位的类型(long和double)占用两个slot。

    • byte、short、char 在存储前被转换为int,boolean 也被转换为int,0 表示false,非0 表示true
    • long 和 double 则占据两个Slot
  • JVM会为局部变量表中的每一个Slot都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值。

  • 如果需要访问局部变量表中一个64bit的局部变量值时,只需要使用前一个索引即可。(比如:访问long或double类型变量)

  • 如果当前帧是由构造方法或者实例方法(非静态方法) 创建的,那么该对象引用this 将会存放在index为0 的slot处,其余的参数按照参数表顺序继续排列。

image-20200705212454445

在构造器以及实例方法中,对象引用this 都会存放在索引为0的位置

变量槽是局部变量表的最小存储单元,变量槽可以放置基本数据类型、引用数据类型、returnAddress数据类型。每一个变量槽都占据的32bit的内存空间。

  • 基本数据类型:基本数据类型包括byte、boolean、short、int、float、long、double、char,变量槽只能存储32bit,所以64bit的long、double用2个连续变量槽存储,其他类型都用一个变量槽存储。

  • 引用数据类型: 引用数据类型包括对象类型和数组类型,变槽量存储了引用类型的引用地址,占用一个变量槽。

  • returnAddress数据类型:returnAddress指向了一条字节码指令的地址,也占用一个变量槽,这种类型基本被虚拟机淘汰。

    局部变量表是实际是一个数字数组,byte、short、char都会转为为int类型,boolean则用0和非0表示。每一个变量槽都有自己对应的索引,索引从0开始到最大长度处结束,如果是成员方法,this变量始终会占据0索引对应的位置,对于long和double类型,则用两个变量槽中的第一个索引对应,验证这点可在上面的字节码指令代码看到i变量的Slot一列索引是3,之后是z变量的5并非4。

Slot 的重复利用

栈帧中的局部变量表中的槽位是可以重用的,如果一个局部变量出了其作用域,那么在其作用域之后声明新的局部变量就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。

一:在方法中声明的变量,即该变量是局部变量,每当程序调用方法时,系统都会为该方法建立一个方法栈,其所在方法中声明的变量就放在方法栈中,当方法结束系统会释放方法栈,其对应在该方法中声明的变量随着栈的销毁而结束,这就局部变量只能在方法中有效的原因

在方法中声明的变量可以是基本类型的变量,也可以是引用类型的变量。

​ (1)当声明是基本类型的变量的时,其变量名及值(变量名及值是两个概念)是放在方法栈中

​ (2)当声明的是引用变量时,所声明的变量(该变量实际上是在方法中存储的是内存地址值)是放在方法的栈中,该变量所指向的对象是放在堆类存中的。

二:在类中声明的变量是成员变量,也叫全局变量,放在堆中的(因为全局变量不会随着某个方法执行结束而销毁)。

​ 同样在类中声明的变量即可是基本类型的变量 也可是引用类型的变量

​ (1)当声明的是基本类型的变量其变量名及其值放在堆内存中的

​ (2)引用类型时,其声明的变量仍然会存储一个内存地址值,该内存地址值指向所引用的对象。引用变量名和对应的对象仍然存储在相应的堆中

局部变量表保存着基本数据类型值和引用数据类型的引用,通过slot的访问索引就可以去获取指定slot中存储的变量值。

操作数栈
  • 每一个独立的栈帧除了包含局部变量表以外,还包含一个后进先出(Last - In - First -Out)的 操作数栈,也可以称之为表达式栈(Expression Stack)

  • 操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈(push)和 出栈(pop)

    • 某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈。使用它们后再把结果压入栈,比如:执行复制、交换、求和等操作
  • 操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。

  • 操作数栈就是JVM执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个时候方法的操作数栈是空的这个时候数组是创建好并且是长度固定的,但数组的内容为空

  • 每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的Code属性中,为maxstack的值。

  • 栈中的任何一个元素都是可以任意的Java数据类型

    • 32bit的类型占用一个栈单位深度
    • 64bit的类型占用两个栈单位深度
  • 操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈和出栈操作来完成一次数据访问

  • 如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。

  • 操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译器期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证。

  • 另外,我们说Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈

栈顶缓存技术(Top Of Stack Cashing)

  • 前面提过,基于栈式架构的虚拟机所使用的零地址指令更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也就意味着将需要更多的指令分派(instruction dispatch)次数和内存读/写次数
  • 由于操作数是存储在内存中的,因此频繁地执行内存读/写操作必然会影响执行速度。为了解决这个问题,HotSpot JVM的设计者们提出了栈顶缓存(Tos,Top-of-Stack Cashing)技术,将栈顶元素全部缓存在物理CPU的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率
  • 寄存器的主要优点:指令更少,执行速度快
动态链接(Dynamic Linking)

动态链接(或指向运行时常量池的方法引用)

  • 每一个栈帧内部都包含一个指向运行时常量池该栈帧所属方法的引用
  • 包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking),比如:invokedynamic指令
  • 在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在class文件的常量池里
  • 比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用

image-20201121175452179

在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在class文件的常量池里

为什么要用常量池呢?

  • 因为在不同的方法,都可能调用常量或者方法,所以只需要存储一份即可,然后记录其引用即可,节省了空间
  • 常量池的作用:就是为了提供一些符号和常量,便于指令的识别
方法的调用:解析和分派
静态链接与动态链接

在JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关

  • 静态链接:
    • 当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期确定,且运行期保持不变时,这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接
  • 动态链接:
    • 如果被调用的方法在编译期无法被确定下来,也就是说,只能够在程序运行期将调用的方法的符号转换为直接引用,由于这种引用转换过程具备动态性,因此也被称之为动态链接。
方法的绑定机制

静态链接和动态链接对应的方法的绑定机制为:早期绑定(Early Binding)晚期绑定(Late Binding)绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次。

  • 早期绑定
    • 早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用。
  • 晚期绑定
    • 如果被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式也就被称之为晚期绑定。
虚方法和非虚方法
  • 如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的。这样的方法称为非虚方法
  • 静态方法、私有方法、final 方法、实例构造器、父类方法都是非虚方法
  • 其他方法称为虚方法。

动态类型语言和静态类型语言

  • 动态类型语言和静态类型语言两者的区别就在于对类型的检查是在编译期还是在运行期,满足前者就是静态类型语言,反之是动态类型语言。
  • 说的再直白一点就是,静态类型语言是判断变量自身的类型信息;动态类型语言是判断变量值的类型信息,变量没有类型信息,变量值才有类型信息,这是动态语言的一个重要特征。
Java语言:String info = "mogu blog";     		(Java是静态类型语言的,会先编译再进行类型检查)
JS语言:var name = "shkstart";  var name = 10;	(运行时才进行检查)
Python语言:info = 130.5;  (动态类型语言)
方法返回地址(return address)
  • 存放 调用该方法的pc寄存器的值

  • 一个方法的结束,有两种方式:

    • 正常执行完成
    • 出现未处理的异常,非正常退出
  • 无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的pc计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息

  • 本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。

  • 正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值

  • 在方法执行过程中遇到异常(Exception),并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,简称异常完成出口。

    • 方法执行过程中,抛出异常时的异常处理,存储在一个异常处理表,方便在发生异常的时候找到处理异常的代码
一些附加信息

栈帧中还允许携带与Java虚拟机实现相关的一些附加信息。例如:对程序调试提供支持的信息。

举例栈溢出的情况?(StackOverflowError)

  • 通过 -Xss 设置栈的大小
  • 递归很容易出现栈溢出

调整栈大小,就能保证不出现溢出么?

  • 不能保证不出现溢出,只能让栈溢出出现的时间晚一点,不可能不出现

分配的栈内存越大越好么?

  • 不是,一定时间内降低了栈溢出的概率,但是会挤占其它的线程空间,因为整个虚拟机的内存空间是有限的

垃圾回收是否涉及到虚拟机栈?

  • 不涉及

方法中定义的局部变量是否线程安全?

何为线程安全?

  • 如果只有一个线程才可以操作此数据,则必是线程安全的。
  • 如果有多个线程操作此数据,则此数据是共享数据。如果不考虑同步机制的话,会存在线程安全问题。

具体问题具体分析:

  • 如果对象是在内部产生,并在内部消亡,没有返回到外部,那么它就是线程安全的,反之则是线程不安全的。
本地方法栈
  • Java虚拟机栈用于管理Java方法的调用,而本地方法栈用于管理本地方法的调用

  • 本地方法栈,也是线程私有的

  • 允许被实现成

    固定或者是可动态扩展的内存大小

    (在内存溢出方面和虚拟机栈相同)

    • 如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java虚拟机将会抛出一个StackoverflowError 异常。
    • 如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,那么Java虚拟机将会抛出一个OutofMemoryError异常。
  • 本地方法一般是使用C语言实现的。

  • 它的具体做法是Native Method Stack中登记native方法,在Execution Engine 执行时加载本地方法库。

image-20200706174708418

  • 当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限。
    • 本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区
    • 它甚至可以直接使用本地处理器中的寄存器
    • 直接从本地内存的堆中分配任意数量的内存
  • 并不是所有的JVM都支持本地方法。因为Java虚拟机规范并没有明确要求本地方法栈的使用语言、具体实现方式、数据结构等。如果JVM产品不打算支持native方法,也可以无需实现本地方法栈。
  • 在Hotspot JVM中,直接将本地方法栈和虚拟机栈合二为一。

3.本地方法接口

本地方法

  • 简单地讲,一个Native Method是一个Java调用非Java代码的接囗
  • 一个Native Method是这样一个Java方法:该方法的实现由非Java语言实现,比如C。
  • 这个特征并非Java所特有,很多其它的编程语言都有这一机制,比如在C++中,你可以用extern "C"告知C++编译器去调用一个C的函数。
  • “A native method is a Java method whose implementation is provided by non-java code.”(本地方法是一个Java的方法,它的具体实现是非Java代码的实现)
  • 在定义一个native method时,并不提供实现体(有些像定义一个Java interface),因为其实现体是由非java语言在外面实现的。
  • 本地接口的作用是融合不同的编程语言为Java所用,它的初衷是融合C/C++程序

image-20201126161748829

为什么要使用Native Method?

4.堆

对堆的认识

  • 一个JVM实例只存在一个堆内存,堆也是Java内存管理的核心区域。

  • Java堆区

    在JVM启动的时候即被创建

    ,其空间大小也就确定了,

    堆是JVM管理的最大一块内存空间
    

    • 堆内存的大小是可以调节的
  • 《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的

  • 所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区(Thread Local Allocation Buffer,TLAB)。

  • 《Java虚拟机规范》中对Java堆的描述是:所有的对象实例以及数组都应当在运行时分配在堆上。(The heap is the run-time data area from which memory for all class instances and arrays is allocated)

  • 从实际使用角度看的:“几乎”所有的对象实例都在这里分配内存。因为还有一些对象是在栈上分配的(逃逸分析,标量替换)

  • 数组和对象可能永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置。

  • 在方法结束后,

    堆中的对象不会马上被移除
    

    ,仅仅在垃圾收集的时候才会被移除。

    • 也就是触发了GC的时候,才会进行回收
    • 如果堆中对象马上被回收,那么用户线程就会收到影响,因为有 stop the word
  • 堆,是GC(Garbage Collection,垃圾收集器)执行垃圾回收的重点区域

image-20200706201904057

对象分配过程

  • new的对象先放伊甸园区。此区有大小限制。
  • 当伊甸园的空间填满时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园区进行垃圾回收(MinorGC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区。
  • 然后将伊甸园中的剩余对象移动到幸存者0区。
  • 如果再次触发垃圾回收,此时将伊甸园区和幸存者0区进行垃圾回收,剩下的对象就会放到幸存者1区。
  • 如果再次经历垃圾回收,此时会重新放回幸存者0区,接着再去幸存者1区。
  • 啥时候能去养老区呢?可以设置次数。默认是15次。可以设置新生区进入养老区的年龄限制,设置 JVM 参数:-XX:MaxTenuringThreshold=N 进行设置
  • 在养老区,相对悠闲。当养老区内存不足时,再次触发GC:Major GC,进行养老区的内存清理
  • 若养老区执行了Major GC之后,发现依然无法进行对象的保存,就会产生OOM异常。

思考:幸存区满了咋办?

  • 特别注意,在Eden区满了的时候,才会触发MinorGC,而幸存者区满了后,不会触发MinorGC操作
  • 如果Survivor区满了后,将会触发一些特殊的规则,也就是可能直接晋升老年代

对象分配的特殊情况

  • 如果来了一个新对象,先看看 Eden 是否放的下?
    • 如果 Eden 放得下,则直接放到 Eden 区
    • 如果 Eden 放不下,则触发 YGC ,执行垃圾回收,看看还能不能放下?放得下最好当然最好咯~~~
  • 将对象放到老年区又有两种情况:
    • 如果 Eden 执行了 YGC 还是无法放不下该对象,那没得办法,只能说明是超大对象,只能直接怼到老年代
    • 那万一老年代都放不下,则先触发重 GC ,再看看能不能放下,放得下最好,但如果还是放不下,那只能报 OOM 啦~~~
  • 如果 Eden 区满了,将对象往幸存区拷贝时,发现幸存区放不下啦,那只能便宜了某些新对象,让他们直接晋升至老年区

image-20200707091058346

分代收集思想(面试必问)

Minor GC、Major GC、Full GC

我们都知道,JVM调优的一个环节,也就是垃圾收集,我们需要尽量的避免垃圾回收,因为在垃圾回收的过程中,容易出现STW(Stop the World)的问题,而 Major GC 和 Full GC出现STW的时间,是Minor GC的10倍以上

JVM在进行GC时,并非每次都对上面三个内存( 新生代、老年代;方法区 )区域一起回收的,大部分时候回收的都是指新生代。针对Hotspot VM的实现,它里面的GC按照回收区域又分为两大种类型:一种是部分收集(Partial GC),一种是整堆收集(FullGC)

  • 部分收集:不是完整收集整个Java堆的垃圾收集。其中又分为:
    • 新生代收集( Minor GC/Young GC ):只是新生代( Eden、S0/S1 )的垃圾收集
    • 老年代收集( Major GC/Old GC ):只是老年代的垃圾收集。
      • 目前,只有CMS GC会有单独收集老年代的行为
      • 注意,很多时候Major GC会和Full GC混淆使用,需要具体分辨是老年代回收还是整堆回收
    • 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集。
      • 目前,只有G1 GC会有这种行为
  • 整堆收集(Full GC):收集整个java堆和方法区的垃圾收集。

Young/Minor GC

年轻代 GC(Minor GC)触发机制

  • 当年轻代空间不足时,就会触发Minor GC,这里的年轻代满指的是Eden区满,Survivor区满不会触发GC。(每次Minor GC会清理年轻代的内存)
  • 因为Java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。这一定义既清晰又易于理解。
  • Minor GC会引发STW暂停其它用户的线程,等待垃圾回收线程结束,用户线程才恢复运行

Major GC

老年代 GC(MajorGC/Full GC)触发机制

  • 指发生在老年代的GC,对象从老年代消失时,我们说 “Major Gc” 或 “Full GC” 发生了
  • 出现了MajorGc,经常会伴随至少一次的Minor GC
    • 但非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行Major GC的策略选择过程
    • 也就是在老年代空间不足时,会先尝试触发Minor GC,如果之后空间还不足,则触发Major GC
  • Major GC的速度一般会比Minor GC慢10倍以上,STW的时间更长
  • 如果Major GC后,内存还不足,就报OOM了

Full GC

Full GC 触发机制(后面细讲)

触发Full GC执行的情况有如下五种:

  • 调用System.gc( )时,系统建议执行Full GC,但是不必然执行
  • 老年代空间不足
  • 方法区空间不足
  • 通过Minor GC后进入老年代的平均大小 大于 老年代的可用内存
  • 由Eden区、survivor space0(From Space)区 向survivor space1(To Space)区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存 小于 该对象大小

说明:Full GC 是开发或调优中尽量要避免的。这样STW时间会短一些

为什么要把Java堆分代?不分代就不能正常工作了吗?

  • 经研究,不同对象的生命周期不同。70%-99%的对象是临时对象。

    • 新生代:有Eden、两块大小相同的Survivor(又称为from/to,s0/s1)构成,to总为空。
    • 老年代:存放新生代中经历多次GC之后仍然存活的对象。
  • 其实不分代完全可以,

    分代的唯一理由就是优化GC性能

    • 如果没有分代,那所有的对象都在一块,就如同把一个学校的人都关在一个教室。GC的时候要找到哪些对象没用,这样就会对堆的所有区域进行扫描。
    • 而很多对象都是朝生夕死的,如果分代的话,把新创建的对象放到某一地方,当GC的时候先把这块存储“朝生夕死”对象的区域进行回收,这样就会腾出很大的空间出来。

image-20200707101511025

image-20200707101543871

内存分配策略或对象提升(Promotion)规则

  • 如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并将对象年龄设为1。
  • 对象在Survivor区中每熬过一次MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁,其实每个JVM、每个GC都有所不同)时,就会被晋升到老年代
  • 对象晋升老年代的年龄阀值,可以通过选项**-XX:MaxTenuringThreshold**来设置

针对不同年龄段的对象分配原则如下所示:

  • 优先分配到Eden
  • 大对象直接分配到老年代:
    • 尽量避免程序中出现过多的大对象
  • 长期存活的对象分配到老年代
  • 动态对象年龄判断:
    • 如果Survivor区中相同年龄的所有对象大小的总和 大于 Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。
  • 空间分配担保:
    • -XX:HandlePromotionFailure ,也就是经过Minor GC后,所有的对象都存活,因为Survivor比较小,所以就需要将Survivor无法容纳的对象,存放到老年代中。

为对象分配内存: TLAB

为什么有 TLAB

问题:堆空间都是共享的么?

不一定,因为还有TLAB这个概念,在堆中划分出一块区域,为每个线程所独占

为什么有TLAB(Thread Local Allocation Buffer)?

  • TLAB:Thread Local Allocation Buffer,也就是为每个线程单独分配了一个缓冲区
  • 堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据
  • 由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的
  • 为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度。

什么是 TLAB

  • 从内存模型而不是垃圾收集的角度,对Eden区域继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间内
  • 多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略
  • 据我所知所有OpenJDK衍生出来的JVM都提供了TLAB的设计。

image-20201127135611986

TLAB 分配过程

  • 尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实是将TLAB作为内存分配的首选
  • 在程序中,开发人员可以通过选项“-XX:UseTLAB”设置是否开启TLAB空间。
  • 默认情况下,TLAB空间的内存非常小,仅占有整个Eden空间的1%,当然我们可以通过选项“-XX:TLABWasteTargetPercent”设置TLAB空间所占用Eden空间的百分比大小。
  • 一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存。

空间分配担保

在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。

  • 如果大于,则此次Minor GC是安全的
  • 如果小于,则虚拟机会查看**-XX:HandlePromotionFailure**设置值是否允许担保失败。
    • 如果HandlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小
      • 如果大于,则尝试进行一次Minor GC,但这次Minor GC依然是有风险的;
      • 如果小于,则进行一次Full GC。
    • 如果HandlePromotionFailure=false,则进行一次Full GC。
致命面试题

堆是分配对象存储的唯一选择吗?

在《深入理解Java虚拟机》中关于Java堆内存有这样一段描述:

  • 随着JIT编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。
  • 在Java虚拟机中,对象是在Java堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分析(Escape Analysis)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无须进行垃圾回收了。这也是最常见的堆外存储技术。
  • 此外,前面提到的基于OpenJDK深度定制的TaoBao VM( 淘宝虚拟机 ),其中创新的GCIH(GC invisible heap)技术实现off-heap,将生命周期较长的Java对象从heap中移至heap外,并且GC不能管理GCIH内部的Java对象,以此达到降低GC的回收频率和提升GC的回收效率的目的。

逃逸分析

如何将堆上的对象分配到栈,需要使用逃逸分析手段。

  • 这是一种可以有效减少Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。
  • 通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。
  • 逃逸分析的基本行为就是分析对象动态作用域:
    • 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸
    • 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。

逃逸分析参数设置

  • 在JDK 1.7 版本之后,HotSpot中默认就已经开启了逃逸分析
  • 如果使用的是较早的版本,开发人员则可以通过:
    • 选项“-XX:+DoEscapeAnalysis"显式开启逃逸分析
    • 通过选项“-XX:+PrintEscapeAnalysis"查看逃逸分析的筛选结果

开发中能使用局部变量的,就不要使用在方法外定义

逃逸分析之代码优化

使用逃逸分析,编译器可以对代码做如下优化:

  • 栈上分配:将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会发生逃逸,对象可能是栈上分配的候选,而不是堆上分配
    • JIT编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。
    • 分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无须进行垃圾回收了。
    • 常见的栈上分配的场景:
      • 在逃逸分析中,已经说明了,分别是给成员变量赋值、方法返回值、实例引用传递。
  • 同步省略:如果一个对象被发现只有一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
    • 线程同步的代价是相当高的,同步的后果是降低并发性和性能
    • 在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。
    • 如果没有,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除
    • 注意:字节码文件中并没有进行优化,可以看到加锁和释放锁的操作依然存在,同步省略操作是在解释运行时发生的
  • 分离对象或标量替换:有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。
    • 标量(scalar)是指一个无法再分解成更小的数据的数据。Java中的原始数据类型就是标量
    • 相对的,那些还可以分解的数据叫做聚合量(Aggregate),Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量。
    • 在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换

逃逸分析的不足

  • 关于逃逸分析的论文在1999年就已经发表了,但直到JDK1.6才有实现,而且这项技术到如今也并不是十分成熟的。
  • 其根本原因就是无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。
  • 虽然这项技术并不十分成熟,但是它也是即时编译器优化技术中一个十分重要的手段。注意到有一些观点,认为通过逃逸分析,JVM会在栈上分配那些不会逃逸的对象,这在理论上是可行的,但是取决于JVM设计者的选择。
  • 据我所知,Oracle Hotspot JVM中并未这么做,这一点在逃逸分析相关的文档里已经说明,所以可以明确所有的对象实例都是创建在堆上。
  • Oracle Hotspot JVM是通过标量替换实现逃逸分析的
  • 目前很多书籍还是基于JDK7以前的版本,JDK已经发生了很大变化,intern字符串的缓存和静态变量曾经都被分配在永久代上,而永久代已经被元数据区取代。但是intern字符串缓存和静态变量并不是被转移到元数据区,而是直接在堆上分配,所以这一点同样符合前面一点的结论:对象实例都是分配在堆上
堆小结
  • 年轻代是对象的诞生、成长、消亡的区域,一个对象在这里产生、应用,最后被垃圾回收器收集、结束生命。
  • 老年代放置长生命周期的对象,通常都是从Survivor区域筛选拷贝过来的Java对象。
  • 当然,也有特殊情况,我们知道普通的对象可能会被分配在TLAB上
  • 如果对象较大,无法分配在 TLAB 上,则JVM会试图直接分配在Eden其他位置上
  • 如果对象太大,完全无法在新生代找到足够长的连续空闲空间,JVM就会直接分配到老年代
  • 当GC只发生在年轻代中,回收年轻代对象的行为被称为Minor GC
  • 当GC发生在老年代时则被称为Major GC或者Full GC
  • 一般的,Minor GC的发生频率要比Major GC高很多,即老年代中垃圾回收发生的频率将大大低于年轻代

5.方法区

image-20201127215445650

栈、堆、方法区的交互关系

  • Person 类的 .class 信息存放在方法区中
  • person 变量存放在 Java 栈的局部变量表中
  • 真正的 person 对象存放在 Java 堆中

image-20201127220645705

  • 在 person 对象中,有个指针指向方法区中的 person 类型数据,表明这个 person 对象是用方法区中的 Person 类 new 出来的

第10章_方式2:使用直接指针访问

方法区的理解

image-20201127221039734

方法区的位置

  • 《Java虚拟机规范》中明确说明:尽管所有的方法区在逻辑上是属于堆的一部分,但一些简单的实现可能不会选择去进行垃圾收集或者进行压缩。
  • 但对于HotSpotJVM而言,方法区还有一个别名叫做Non-Heap(非堆),目的就是要和堆分开。
  • 所以,方法区可以看作是一块独立于Java堆的内存空间

image-20201127221429305

方法区的理解

方法区主要存放的是 Class,而堆中主要存放的是实例化的对象

  • 方法区(Method Area)与Java堆一样,是各个线程共享的内存区域

  • 多个线程同时加载统一个类时,只能有一个线程能加载该类,其他线程只能等等待该线程加载完毕,然后直接使用该类,即类只能加载一次

  • 方法区在JVM启动的时候被创建,并且它的实际物理内存空间和Java堆区一样都可以是不连续的。

  • 方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展

  • 方法区是接口,元空间或者永久代是方法区的实现

  • 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误:

    • java.lang.OutofMemoryError:PermGen space(JDK7之前)
    • 或者
    • java.lang.OutOfMemoryError:Metaspace(JDK8之后)
  • 举例说明方法区 OOM

    • 加载大量的第三方的jar包
    • Tomcat部署的工程过多(30~50个)
    • 大量动态的生成反射类
  • 关闭JVM就会释放这个区域的内存。

Hotspot中方法区的演进过程

  • 在 JDK7 及以前,习惯上把方法区,称为永久代。JDK8开始,使用元空间取代了永久代。JDK 1.8之后,元空间存放在堆外内存中
  • 我们可以将方法区类比为Java中的接口,将永久代或元空间类比为Java中具体的实现类
  • 本质上,方法区和永久代并不等价。仅是对Hotspot而言的可以看作等价。《Java虚拟机规范》对如何实现方法区,不做统一要求。例如:BEAJRockit / IBM J9 中不存在永久代的概念。
  • 现在来看,当年使用永久代,不是好的idea。导致Java程序更容易OOm(超过-XX:MaxPermsize上限)
  • 而到了JDK8,终于完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间(Metaspace)来代替
  • 元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代最大的区别在于:元空间不在虚拟机设置的内存中,而是使用本地内存
  • 永久代、元空间二者并不只是名字变了,内部结构也调整了
  • 根据《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出OOM异常

第08章_堆和方法区图

JDK7 永久代

  • 通过-XX:Permsize来设置永久代初始分配空间。默认值是20.75M
  • -XX:MaxPermsize来设定永久代最大可分配空间。32位机器默认是64M,64位机器模式是82M
  • 当JVM加载的类信息容量超过了这个值,会报异常OutofMemoryError:PermGen space。

JDK8 元空间

  • 元数据区大小可以使用参数 -XX:MetaspaceSize-XX:MaxMetaspaceSize 指定

  • 默认值依赖于平台,Windows下,-XX:MetaspaceSize 约为21M,-XX:MaxMetaspaceSize的值是-1,即没有限制

  • 与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元数据区发生溢出,虚拟机一样会抛出异常OutOfMemoryError:Metaspace

  • -XX:MetaspaceSize:设置初始的元空间大小。对于一个 64位 的服务器端 JVM 来说,其默认的 -XX:MetaspaceSize值为21MB。这就是初始的高水位线,一旦触及这个水位线,Full GC将会被触发并卸载没用的类(即这些类对应的类加载器不再存活),

    然后这个高水位线将会重置

    。新的高水位线的值取决于GC后释放了多少元空间。

    • 如果释放的空间不足,那么在不超过MaxMetaspaceSize时,适当提高该值。
    • 如果释放空间过多,则适当降低该值。
  • 如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次。通过垃圾回收器的日志可以观察到Full GC多次调用。为了避免频繁地GC,建议将-XX:MetaspaceSize设置为一个相对较高的值

如何解决OOM?

  • 要解决OOM异常或heap space的异常,一般的手段是首先通过内存映像分析工具(如Eclipse Memory Analyzer)对dump出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)
  • 内存泄漏就是有大量的引用指向某些对象,但是这些对象以后不会使用了,但是因为它们还和GC ROOT有关联,所以导致以后这些对象也不会被回收,这就是内存泄漏的问题
  • 如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots的引用链。于是就能找到泄漏对象是通过怎样的路径与GC Roots相关联并导致垃圾收集器无法自动回收它们的。掌握了泄漏对象的类型信息,以及GC Roots引用链的信息,就可以比较准确地定位出泄漏代码的位置。
  • 如果不存在内存泄漏,换句话说就是内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-Xmx与-Xms),与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。

方法区结构

image-20200708161728320

《深入理解Java虚拟机》书中对方法区(Method Area)存储内容描述如下:它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。

image-20200708161856504

类型信息

对每个加载的类型(类class、接口interface、枚举enum、注解annotation),JVM必须在方法区中存储以下类型信息:

  • 这个类型的完整有效名称(全类名=包名.类名)

  • 这个类型直接父类的完整有效名(对于interface或是java.lang.Object,都没有父类)

  • 这个类型的修饰符(public,abstract,final的某个子集)

  • 这个类型直接接口的一个有序列表

  • 在运行时方法区中,类信息中记录了哪个加载器加载了该类,同时类加载器也记录了它加载了哪些类

域(Field)信息

  • JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序。

  • 域信息通俗来讲是类的成员变量

  • 域的相关信息包括:

    • 域名称
    • 域类型
    • 域修饰符(public,private,protected,static,final,volatile,transient的某个子集)
  • 域信息特殊情况

    • non-final 类型的类变量

      • 静态变量和类关联在一起,随着类的加载而加载,他们成为类数据在逻辑上的一部分
      • 类变量被类的所有实例共享,即使没有类实例时,你也可以访问它
      • 全局常量就是使用 static final 进行修饰,被声明为final的类变量的处理方法则不同,每个全局常量在编译的时候就会被分配了。

方法(Method)信息

JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序:

  • 方法名称
  • 方法的返回类型(包括 void 返回类型),void 在 Java 中对应的类为 void.class
  • 方法参数的数量和类型(按顺序)
  • 方法的修饰符(public,private,protected,static,final,synchronized,native,abstract的一个子集)
  • 方法的字节码(bytecodes)、操作数栈、局部变量表及大小(abstract和native方法除外)
  • 异常表(abstract和native方法除外),异常表记录每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引

运行时常量池

运行时常量池 VS 常量池

  • 方法区,内部包含了运行时常量池
  • 字节码文件,内部包含了常量池
  • 要弄清楚方法区,需要理解清楚ClassFile,因为加载类的信息都在方法区。
  • 要弄清楚方法区的运行时常量池,需要理解清楚ClassFile中的常量池。

image-20200708171151384

常量池

  • 一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述符信息外
  • 还包含一项信息就是常量池表Constant Pool Table),包括各种字面量和对类型、域和方法的符号引用

为什么需要常量池?

  • 一个java源文件中的类、接口,编译后产生一个字节码文件。而Java中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方式,可以存到常量池
  • 这个字节码包含了指向常量池的引用。在动态链接的时候会用到运行时常量池,之前有介绍

常量池中有什么?

  • 数量值
  • 字符串值
  • 类引用
  • 字段引用
  • 方法引用

常量池总结

常量池,可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息

运行时常量池

  • 运行时常量池(Runtime Constant Pool)是方法区的一部分
  • 常量池表(Constant Pool Table)是Class字节码文件的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中
  • 运行时常量池,在加载类和接口到虚拟机后,就会创建对应的运行时常量池。
  • JVM为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的
  • 运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为真实地址
  • 运行时常量池,相对于Class文件常量池的另一重要特征是:具备动态性
  • 运行时常量池类似于传统编程语言中的符号表(symbol table),但是它所包含的数据却比符号表要更加丰富一些。
  • 当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则JVM会抛OutOfMemoryError异常。

图解字节码指令执行流程

  • 字节码执行过程展示:初始状态

image-20200708204750374

  • 首先将操作数500压入操作数栈中

image-20200708204953552

  • 获取 System.out 输出流的引用

image-20200729153532555

关于【符号引用 --> 直接引用】的理解

  1. 上面代码调用 System.out.println( ) 方法时,首先需要看 System 类有没有加载,再看看 PrintStream 类有没有加载
  2. 如果没有加载,则执行加载,执行时,将常量池中的符号引用(字面量)转换为直接引用(真正的地址值)

关于程序计数器的说明

程序计数器始终存储的都是当前字节码指令的索引地址,目的是为了方便记录方法调用后能够正常返回,或者是进行了CPU切换后,也能回到原来的代码继续执行。

方法区的演进细节

永久代演进过程

  • 首先明确:只有Hotspot才有永久代
  • BEA JRockit、IBMJ9等来说,是不存在永久代的概念的。原则上如何实现方法区属于虚拟机实现细节,不受《Java虚拟机规范》管束,并不要求统一
  • Hotspot中方法区的变化:
JDK 版本演变细节
JDK1.6及以前有永久代(permanent generation),静态变量存储在永久代上
JDK1.7有永久代,但已经逐步 “去永久代”,字符串常量池、静态变量从永久代中移除,保存在堆中
JDK1.8无永久代,类型信息,字段,方法,常量保存在本地内存的元空间,但字符串常量池、静态变量仍然在堆中。

JDK6

  • 方法区由永久代实现,使用 JVM 虚拟机内存

image-20201129121051748

JDK7

  • 方法区由永久代实现,使用 JVM 虚拟机内存

image-20201129121111687

JDK8及以后

  • 方法区由元空间实现,使用物理机本地内存

image-20201129121131455

永久代为什么要被元空间替代?

由于类的元数据分配在本地内存中,元空间的最大可分配空间就是系统可用内存空间,这项改动是很有必要的,原因有:

  • 为永久代设置空间大小是很难确定的。
    • 在某些场景下,如果动态加载类过多,容易产生Perm区的OOM。比如某个实际Web工程中,因为功能点比较多,在运行过程中,要不断动态地加载很多类,经常出现致命错误。Exception in thread 'dubbo client x.x connector' java.lang.OutOfMemoryError:PermGen space
    • 而元空间和永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制
  • 对永久代进行调优是很困难的。
    • 方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再用的类型,方法区的调优主要是为了降低Full GC
    • 有些人认为方法区(如HotSpot虚拟机中的元空间或者永久代)是没有垃圾收集行为的,其实不然。《Java虚拟机规范》对方法区的约束是非常宽松的,提到过可以不要求虚拟机在方法区中实现垃圾收集。事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在(如JDK11时期的ZGC收集器就不支持类卸载)。
    • 一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻。但是这部分区域的回收有时又确实是必要的。以前Sun公司的Bug列表中,曾出现过的若干个严重的Bug就是由于低版本的HotSpot虚拟机对此区域未完全回收而导致内存泄漏

字符串常量池

字符串常量池 StringTable 为什么要调整位置?

  • JDK7中将StringTable放到了堆空间中。因为永久代的回收效率很低,在Full GC的时候才会执行永久代的垃圾回收,而Full GC是老年代的空间不足、永久代不足时才会触发。
  • 这就导致StringTable回收效率不高,而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存

静态变量位置

静态变量存放在哪里?

静态变量在jdk6/7存在与永久代中,在jdk8存在于堆中

静态引用对应的对象实体始终都存在堆空间

/**
 * 结论:
 *  静态变量在jdk6/7存在与永久代中,在jdk8存在于堆中 //private static byte[] arr
 *  静态引用对应的对象实体始终都存在堆空间 //new byte[1024 * 1024 * 100];
 *
 * jdk7:
 * -Xms200m -Xmx200m -XX:PermSize=300m -XX:MaxPermSize=300m -XX:+PrintGCDetails
 * jdk 8:
 * -Xms200m -Xmx200m -XX:MetaspaceSize=300m -XX:MaxMetaspaceSize=300m -XX:+PrintGCDetails
 */
public class StaticFieldTest {
    private static byte[] arr = new byte[1024 * 1024 * 100]; //100MB

    public static void main(String[] args) {
        System.out.println(StaticFieldTest.arr);
    }
}
  • 通过 GC 日志可以看出:静态变量引用对应的对象实体始终都在堆空间中(arr 数组对象直接怼到老年区去了)

方法区的垃圾收集

  • 有些人认为方法区(如Hotspot虚拟机中的元空间或者永久代)是没有垃圾收集行为的,其实不然。
  • 《Java虚拟机规范》对方法区的约束是非常宽松的,提到过可以不要求虚拟机在方法区中实现垃圾收集。事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在(如JDK11时期的ZGC收集器就不支持类卸载)。
  • 一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻。但是这部分区域的回收有时又确实是必要的。以前sun公司的Bug列表中,曾出现过的若干个严重的Bug就是由于低版本的HotSpot虚拟机对此区域未完全回收而导致内存泄漏。
  • 方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型

方法区常量的回收

  • 先来说说方法区内常量池之中主要存放的两大类常量:字面量和符号引用
    • 字面量比较接近Java语言层次的常量概念,如文本字符串、被声明为final的常量值等
    • 而符号引用则属于编译原理方面的概念,包括下面三类常量:
      • 类和接口的全限定名
      • 字段的名称和描述符
      • 方法的名称和描述符
  • HotSpot虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收
  • 回收废弃常量与回收Java堆中的对象非常类似。(关于常量的回收比较简单,重点是类的回收)

方法区类的回收

  • 判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:
    • 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
    • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。
    • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
  • Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。关于是否要对类型进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制,还可以使用-verbose:class 以及 -XX:+TraceClass-Loading-XX:+TraceClassUnLoading查看类加载和卸载信息
  • 在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及 OSGi 这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。

运行时数据区总结

  • 线程私有结构:程序计数器、虚拟机栈、本地方法栈
  • 每个虚拟机栈由具体的栈帧组成,在栈帧的动态链接中,保存至对方法的引用
  • 方法区在 JDK7 之前,使用永久代实现,在 JDK8 之后,使用元空间实现
  • Minor GC 针对于新生区,Major GC 针对于老年区,Full GC 针对于整个堆空间和方法区

第09章_方法区与栈的关联结构

大厂面试题

百度

  1. 三面:说一下JVM内存模型吧,有哪些区?分别干什么的?

字节跳动

  1. 二面:Java的内存分区
  2. 二面:讲讲vm运行时数据库区
  3. 什么时候对象会进入老年代?

蚂蚁金服

  1. Java8的内存分代改进
  2. JVM内存分哪几个区,每个区的作用是什么?
  3. 一面:JVM内存分布/内存结构?栈和堆的区别?堆的结构?为什么两个survivor区?
  4. 二面:Eden和survior的比例分配

小米

  1. jvm内存分区,为什么要有新生代和老年代

京东

  1. JVM的内存结构,Eden和Survivor比例。
  2. JVM内存为什么要分成新生代,老年代,持久代。新生代中为什么要分为Eden和survivor。

天猫

  1. 一面:Jvm内存模型以及分区,需要详细到每个区放什么。
  2. 一面:JVM的内存模型,Java8做了什么改

拼多多

  1. JVM内存分哪几个区,每个区的作用是什么?

美团

  1. java内存分配
  2. jvm的永久代中会发生垃圾回收吗?
  3. 一面:jvm内存分区,为什么要有新生代和老年代?

GC

  • 垃圾收集器可以对年轻代回收,也可以对老年代回收,甚至是全栈和方法区的回收。
    • 其中,Java堆是垃圾收集器的工作重点
  • 从次数上讲:
    • 频繁收集Young区
    • 较少收集Old区
    • 基本不收集Perm区(元空间)

1.垃圾标记:对象存活判断

引用计数算法

对每个对象保存一个整型的引用计数器属性。用于记录对象被引用的情况。

  • 优点:实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性。
  • 缺点:
    • 它需要单独的字段存储计数器,这样的做法增加了存储空间的开销
    • 每次赋值都需要更新计数器,伴随着加法和减法操作,这增加了时间开销
    • 引用计数器有一个严重的问题,即无法处理循环引用的情况。这是一条致命缺陷,导致在Java的垃圾回收器中没有使用这类算法。
可达性分析算法
  • 相对于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点,更重要的是该算法可以有效地解决在引用计数算法中循环引用的问题,防止内存泄漏的发生。

  • 可达性分析算法是以根对象集合(GCRoots)为起始点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达。

  • 使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接着,搜索所走过的路径称为引用链(Reference Chain)。

  • 如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象己经死亡,可以标记为垃圾对象。

  • 在可达性分析算法中,只有能够被根对象集合直接或者间接连接的对象才是存活对象。

Untitled

这里需要注意的是,可达性分析算法中,每次标记的是直接或间接与 GC Roots 连接的对象,标记完成后,遍历整个内存空间,将没有被标记的对象删除

在Java语言中,GC Roots包括以下几类元素:

  • 虚拟机栈中引用的对象
    • 比如:各个线程被调用的方法中使用到的参数、局部变量等。
  • 本地方法栈内JNI(通常说的本地方法)引用的对象
  • 方法区中类静态属性引用的对象
    • 比如:Java类的引用类型静态变量
  • 方法区中常量引用的对象
    • 比如:字符串常量池(String Table)里的引用
  • 所有被同步锁synchronized持有的对象
  • Java虚拟机内部的引用。
    • 基本数据类型对应的Class对象,一些常驻的异常对象(如:NullPointerExceptionOutOfMemoryError),系统类加载器。
  • 反映java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
  • 除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合。比如:分代收集和局部回收(PartialGC)。
    • 如果只针对Java堆中的某一块区域进行垃圾回收(比如:典型的只针对新生代),必须考虑到内存区域是虚拟机自己的实现细节,更不是孤立封闭的,这个区域的对象完全有可能被其他区域的对象所引用,这时候就需要一并将关联的区域对象也加入GCRoots集合中去考虑,才能保证可达性分析的准确性。
    • 典型的只针对新生代:因为新生代除外,还有关联的老年代,所以需要将老年代也一并加入GC Roots集合中
  • 小技巧:
    • 由于Root采用栈方式存放变量和指针,所以如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那它就是一个Root。

注意:

  • 如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性 (某一刻的静止状态) 的快照中进行。这点不满足的话分析结果的准确性就无法保证。
  • 这点也是导致GC进行时必须“stop The World”的一个重要原因。
    • 即使是号称(几乎)不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的。
对象的finalization机制
  • Java语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑。

  • 当垃圾回收器发现没有引用指向一个对象,即:垃圾回收此对象之前,总会先调用这个对象的finalize( )方法。

  • finalize( ) 方法允许在子类中被重写,用于在对象被回收时进行资源释放。通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等。

  • 永远不要主动调用某个对象的finalize( )方法,应该交给垃圾回收机制调用。理由包括下面三点:

    • finalize( )执行时可能会导致对象复活。
    • finalize( )方法的执行时间是没有保障的,它完全由GC线程决定,极端情况下,若不发生GC,则finalize( )方法将没有执行机会。
    • 一个糟糕的finalize( )会严重影响GC的性能。
  • 由于finalize( )方法的存在,虚拟机中的对象一般处于三种可能的状态。

  • 如果从所有的根节点都无法访问到某个对象,说明对象己经不再使用了。一般来说,此对象需要被回收。但事实上,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段。

    一个无法触及的对象有可能在某一个条件下“复活”自己

    ,如果这样,那么对它的回收就是不合理的,为此,定义虚拟机中的对象可能的三种状态。如下:

    • 可触及的:从根节点开始,可以到达这个对象。
    • 可复活的:对象的所有引用都被释放,但是对象有可能在 finalize() 中复活。
    • 不可触及的:对象的 finalize() 被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为 finalize() 只会被调用一次。
  • 以上3种状态中,是由于 finalize() 方法的存在,进行的区分。只有在对象不可触及时才可以被回收。

具体过程

  • 判定一个对象 objA 是否可回收,至少要经历两次标记过程:
    • 如果对象 objA 到 GC Roots 没有引用链,则进行第一次标记。
    • 进行筛选,判断此对象是否有必要执行finalize()方法
      • 如果对象 objA 没有重写 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,则虚拟机视为“没有必要执行”,objA 被判定为不可触及的。
      • 如果对象 objA 重写了 finalize() 方法,且还未执行过,那么 objA 会被插入到 F-Queue 队列中,由一个虚拟机自动创建的、低优先级的 Finalizer 线程触发其 finalize() 方法执行。
      • finalize() 方法是对象逃脱死亡的最后机会,稍后 GC 会对 F-Queue 队列中的对象进行第二次标记。如果 objA 在 finalize() 方法中与引用链上的任何一个对象建立了联系,那么在第二次标记时,objA 会被移出“即将回收”集合。之后,对象如果再次出现没有引用存在的情况。在这个情况下, finalize() 方法不会被再次调用,对象会直接变成不可触及的状态,也就是说,一个对象的 finalize() 方法只会被调用一次。

2.垃圾清除

  • 当成功区分出内存中存活对象和死亡对象后,GC 接下来的任务就是执行垃圾回收,释放掉无用对象所占用的内存空间,以便有足够的可用内存空间为新对象分配内存。目前在 JVM 中比较常见的三种垃圾收集算法是:
    • 标记一清除算法(Mark-Sweep)
    • 复制算法(Copying)
    • 标记-压缩算法(Mark-Compact)
标记-清除算法(Mark-Sweep)

当堆中的有效内存空间(Available Memory)被耗尽的时候,就会停止整个程序(也被称为 Stop The World),然后进行两项工作,第一项则是标记,第二项则是清除。

  • 标记:Collector 从引用根节点开始遍历,标记所有被引用的对象。一般是在对象的 Header 中记录为可达对象。
    • 标记的是引用的对象,不是垃圾!!
  • 清除:Collector 对堆内存从头到尾进行线性的遍历,如果发现某个对象在其 Header 中没有标记为可达对象,则将其回收。

Untitled

  • 标记清除算法的效率不算高

  • 在进行 GC 的时候,需要停止整个应用程序,导致用户体验较差

  • 这种方式清理出来的空闲内存是不连续的,产生内存碎片,需要维护一个空闲列表

  • 这里所谓的清除并不是真的置空,而是把需要清除的对象地址保存在空闲的地址列表里。下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够,就存放覆盖原有的地址。

  • 关于空闲列表是在为对象分配内存的时候:

    • 如果内存规整
      • 采用指针碰撞的方式进行内存分配
    • 如果内存不规整
      • 虚拟机需要维护一个列表
      • 空闲列表分配
复制算法(Copying)

将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收

Untitled

优点

  • 没有标记和清除过程,实现简单,运行高效
  • 复制过去以后保证空间的连续性,不会出现“碎片”问题

缺点

  • 此算法的缺点也是很明显的,就是需要两倍的内存空间。

  • 对于 G1 这种拆分成为大量 region 的 GC,复制而不是移动,意味着 GC 需要维护 region 之间对象的引用关系,不管是内存占用或者时间开销也不小

  • 如果系统中的垃圾对象非常多,复制算法可能不会很理想

  • 复制算法需要复制的存活对象数量并不会太大,或者说非常低才行

  • 在新生代,对常规应用的垃圾回收,一次通常可以回收 70% - 99% 的内存空间。回收性价比很高。所以现在的商业虚拟机都是用这种收集算法回收新生代。

Untitled

标记-压缩(整理)算法(Mark - Compact)
  • 复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。这种情况在新生代经常发生,但是在老年代,更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活对象较多,复制的成本也将很高。因此,基于老年代垃圾回收的特性,需要使用其他的算法。

  • 标记 - 清除算法的确可以应用在老年代中,但是该算法不仅执行效率低下,而且在执行完内存回收后还会产生内存碎片,所以 JVM 的设计者需要在此基础之上进行改进。标记 - 压缩(Mark-Compact)算法由此诞生。

  • 第一阶段和标记清除算法一样,从根节点开始标记所有被引用对象

  • 第二阶段将所有的存活对象压缩到内存的一端,按顺序排放。

  • 之后,清理边界外所有的空间。

Untitled

标记清除和标记压缩的区别

  • 标记-压缩算法的最终效果等同于标记-清除算法执行完成后,再进行一次内存碎片整理,因此,也可以把它称为标记-清除-压缩(Mark-Sweep-Compact)算法。
  • 二者的本质差异在于标记-清除算法是一种非移动式的回收算法,标记-压缩是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策。
  • 可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时,JVM 只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销

标记压缩算法内部使用指针碰撞

  • 如果内存空间以规整和有序的方式分布,即已用和未用的内存都各自一边,彼此之间维系着一个记录下一次分配起始点的标记指针,当为新对象分配内存时,只需要通过修改指针的偏移量将新对象分配在第一个空闲内存位置上,这种分配方式就叫做指针碰撞(Bump the Pointer)

优点

  • 消除了标记-清除算法当中,内存区域分散的缺点,我们需要给新对象分配内存时,JVM 只需要持有一个内存的起始地址即可。
  • 消除了复制算法当中,内存减半的高额代价。

缺点

  • 从效率上来说,标记-整理算法要低于复制算法,甚至要低于标记-清除算法
  • 移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址
  • 移动过程中,需要全程暂停用户应用程序。即:STW
标记清除(Mark-Sweep)标记整理(Mark-Compact)复制(Copying)
速率中等最慢最快
空间开销少(但会堆积碎片)少(不堆积碎片)通常需要活对象的2倍空间(不堆积碎片)
移动对象
  • 效率上来说,复制算法是当之无愧的老大,但是却浪费了太多内存。
  • 而为了尽量兼顾上面提到的三个指标,标记-整理算法相对来说更平滑一些,但是效率上不尽如人意,它比复制算法多了一个标记的阶段,比标记-清除多了一个整理内存的阶段。
  • 综合我们可以看到,没有最好的算法,只有最合适的算法
分代收集算法
  • 分代收集算法,是基于这样一个事实:不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点使用不同的回收算法,以提高垃圾回收的效率。

  • 在 Java 程序运行的过程中,会产生大量的对象,其中有些对象是与业务信息相关,比如 Http 请求中的 Session 对象、线程、Socket 连接,这类对象跟业务直接挂钩,因此生命周期比较长。但是还有一些对象,主要是程序运行过程中生成的临时变量,这些对象生命周期会比较短,比如:String 对象,由于其不变类的特性,系统会产生大量的这些对象,有些对象甚至只用一次即可回收。

目前几乎所有的 GC 都采用分代收集算法执行垃圾回收的。

在 HotSpot 中,基于分代的概念,GC 所使用的内存回收算法必须结合年轻代和老年代各自的特点。

  • 年轻代(Young Gen)

年轻代特点:区域相对老年代较小,对象生命周期短、存活率低,回收频繁。

这种情况复制算法的回收整理,速度是最快的。复制算法的效率只和当前存活对象大小有关,因此很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过 HotSpot 中的两个 Survivor 的设计得到缓解。

  • 老年代(Tenured Gen)

老年代特点:区域较大,对象生命周期长、存活率高,回收不及年轻代频繁。

这种情况存在大量存活率高的对象,复制算法明显变得不合适。一般是由标记-清除或者是标记-清除与标记-整理的混合实现。

  • Mark 阶段的开销与存活对象的数量成正比。
  • Sweep 阶段的开销与所管理区域的大小成正相关。
  • Compact 阶段的开销与存活对象的数据成正比。

以 HotSpot 中的 CMS 回收器为例,CMS 是基于 Mark-Sweep 实现的,对于对象的回收效率很高。而对于碎片问题,CMS 采用基于 Mark-Compact 算法的 Serial Old 回收器作为补偿措施:当内存回收不佳(碎片导致的 Concurrent Mode Failure 时),将采用 Serial Old 执行 Full GC 以达到对老年代内存的整理。

分代的思想被现有的虚拟机广泛使用,几乎所有的垃圾回收器都区分新生代和老年代。

增量收集算法
  • 如果一次性将所有的垃圾进行处理,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序线程交替执行。每次,垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。依次反复,直到垃圾收集完成。
  • 总的来说,增量收集算法的基础仍是传统的标记-清除和复制算法。增量收集算法通过对线程间冲突的妥善处理允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作。

使用这种方式,由于在垃圾回收过程中,间断性地还执行了应用程序代码,所以能减少系统的停顿时间。但是,因为线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降

分区算法
  • 一般来说,在相同条件下,堆空间越大,一次 GC 时所需要的时间就越长,有关 GC 产生的停顿也越长。为了更好地控制 GC 产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理地回收若干个小区间,而不是整个堆空间,从而减少一次 GC 所产生的停顿。
  • 分代算法将按照对象的生命周期长短划分成两个部分,分区算法将整个堆空间划分成连续的不同小区间。
  • 每一个小区间都独立使用,独立回收。这种算法的好处是可以控制一次回收多少个小区间。

Untitled

3.垃圾回收的相关概念

System.gc()
  • 在默认情况下,通过 System.gc()Runtime.getRuntime().gc() 的调用,会显式触发 Full GC,同时对老年代和新生代进行回收,尝试释放被丢弃对象占用的内存。

  • 然而 System.gc() 调用附带一个免责声明,无法保证对垃圾收集器的调用。(不能确保立即生效)

  • JVM 实现者可以通过 System.gc() 调用来决定 JVM 的 GC 行为。而一般情况下,垃圾回收应该是自动进行的,无须手动触发,否则就太过于麻烦了。

  • 运行程序,不一定会触发垃圾回收,但是调用 System.runFinalization() 会强制调用失去引用对象的finalize( )方法

  • System.gc( )System.runFinalization( ) 是一起使用的

内存溢出与内存泄露

内存溢出(OOM)

  • 内存溢出相对于内存泄漏来说,尽管更容易被理解,但是同样的,内存溢出也是引发程序崩溃的罪魁祸首之一。
  • 由于 GC 一直在发展,所以一般情况下,除非应用程序占用的内存增长速度非常快,造成垃圾回收已经跟不上内存消耗的速度,否则不太容易出现 OOM 的情况。
  • 大多数情况下,GC 会进行各种年龄段的垃圾回收,实在不行了就放大招,来一次独占式的 Full GC 操作,这时候会回收大量的内存,供应用程序继续使用。
  • javadoc 中对 OutOfMemoryError 的解释是,没有空闲内存,并且垃圾收集器也无法提供更多内存。

首先说没有空闲内存的情况:说明 Java 虚拟机的堆内存不够。原因有二:

  • Java 虚拟机的堆内存设置不够

  • 代码中创建了大量大对象,并且长时间不能被垃圾收集器收集。(存在被引用)

  • 这里面隐含着一层意思是,在抛出OutOfMemoryError之前,通常垃圾收集器会被触发,尽其所能去清理出空间。
    • 例如:在引用机制分析中,涉及到 JVM 会去尝试回收软引用指向的对象等**。**
    • java.nio.BIts.reserveMemory() 方法中,我们能清楚的看到,System.gc() 会被调用,以清理空间。
  • 当然,也不是在任何情况下垃圾收集器都会被触发的。
    • 比如,我们去分配一个超大对象,类似一个超大数组超过堆的最大值,JVM 可以判断出垃圾收集并不能解决这个问题,所以直接抛出 OutOfMemoryError

引起内存溢出的原因有很多种:

1.内存中加载的数据量过于庞大,如一次从数据库取出过多数据;
2.集合类中有对对象的引用,使用完后未清空,使得JVM不能回收;
3.代码中存在死循环或循环产生过多重复的对象实体;
4.启动参数内存值设定的过小;

内存溢出的解决方案:

第一步,修改JVM启动参数,直接增加内存。(-Xms,-Xmx参数一定不要忘记加。)

第二步,检查错误日志,查看“OutOfMemory”错误前是否有其它异常或错误。

第三步,对代码进行走查和分析,找出可能发生内存溢出的位置。

内存泄露(Memory Leak)

  • 也称作“存储渗漏”。严格来说,只有对象不会再被程序用到了,但是 GC 又不能回收他们的情况,才叫内存泄漏**。**
  • 但实际情况很多时候一些不太好的实践(或疏忽)会导致对象的生命周期变得很长甚至导致 OOM,也可以叫做宽泛意义上的“内存泄漏”
  • 尽管内存泄漏并不会立刻引起程序崩溃,但是一旦发生内存泄漏,程序中的可用内存就会被逐步蚕食,直至耗尽所有内存,最终出现 OutOfMemory 异常,导致程序崩溃。
  • 注意,这里的存储空间并不是指物理内存,而是指虚拟内存大小,这个虚拟内存大小取决于磁盘交换区设定的大小。

Java中出现内存泄露的例子

  • 单例模式
    • 单例的生命周期和应用程序是一样长的,所以单例程序中,如果持有对外部对象的引用的话,那么这个外部对象是不能被回收的,则会导致内存泄漏的产生。
  • 一些提供 close 的资源未关闭导致内存泄漏
    • 数据库连接(dataSourse.getConnection()),网络连接(Socket)和 IO 连接必须手动 close,否则是不能被回收的。
Stop The World
  • Stop-The-World,简称 STW,指的是 GC 事件发生过程中,会产生应用程序(用户线程)的停顿。停顿产生时整个应用程序线程都会被暂停,没有任何响应,有点像卡死的感觉,这个停顿称为 STW。
    • 可达性分析算法中枚举根节点(GC Roots)会导致所有 Java 执行线程停顿。
      • 分析工作必须在一个能确保一致性的快照中进行
      • 一致性指整个分析期间整个执行系统看起来像被冻结在某个时间点上
      • 如果出现分析过程中对象引用关系还在不断变化,则分析结果的准确性无法保证
  • 被 STW 中断的应用程序线程会在完成 GC 之后恢复,频繁中断会让用户感觉像是网速不快造成电影卡带一样,所以我们需要减少 STW 的发生。
  • STW 事件和采用哪款 GC 无关,所有的 GC 都有这个事件。
  • 哪怕是 G1 也不能完全避免 Stop-The-World 情况发生,只能说垃圾回收器越来越优秀,回收效率越来越高,尽可能地缩短了暂停时间。
  • STW 是 JVM 在后台自动发起和自动完成的。在用户不可见的情况下,把用户正常的工作线程全部停掉。
  • 开发中不要用 System.gc(); 会导致 Stop-The-World 的发生。
垃圾回收的并行与并发

并发(Concurrent)

  • 在操作系统中,是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理器上运行。
  • 并发不是真正意义上的“同时进行”,只是 CPU 把一个时间段划分成几个时间片段(时间区间),然后在这几个时间区间之间来回切换,由于 CPU 处理的速度非常快,只要时间间隔处理得当,即可让用户感觉是多个应用程序同时在进行。

Untitled

并行(Parallel)

  • 当系统有一个以上 CPU 时,当一个 CPU 执行一个进程时,另一个 CPU 可以执行另一个进程,两个进程互不抢占 CPU 资源,可以同时进行,我们称之为并行(Parallel)。
  • 其实决定并行的因素不是 CPU 的数量,而是 CPU 的核心数量,比如一个 CPU 多个核也可以并行。
  • 适合科学计算,后台处理等弱交互场景

Untitled

  • 并发,指的是多个事情,在同一时间段内同时发生了。
  • 并行,指的是多个事情,在同一时间点上同时发生了。
  • 并发的多个任务之间是互相抢占资源的。
  • 并行的多个任务之间是不互相抢占资源的。
  • 只有在多 CPU 或者一个 CPU 多核的情况中,才会发生并行。
  • 否则,看似同时发生的事情,其实都是并发执行的。

垃圾回收中的并行与并发

并发和串行,在谈论垃圾收集器的上下文语境中,它们可以解释如下:

  • 并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。
    • 如 ParNew、Parallel Scavenge、Parallel Old;
  • 串行(Serial)
    • 相较于并行的概念,单线程执行。
    • 如果内存不够,则程序暂停,启动 JVM 垃圾回收器进行垃圾回收。回收完,再启动程序的线程。

Untitled

  • 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),垃圾回收线程在执行时不会停顿用户程序的运行。
    • 用户程序在继续运行,而垃圾收集程序线程运行于另一个 CPU 上
    • 如:CMS、G1

Untitled

安全点与安全区域
  • 程序执行时并非在所有地方都能停顿下来开始 GC,只有在特定的位置才能停顿下来开始 GC,这些位置称为 “安全点(SafePoint)”。

  • SafePoint 的选择很重要,如果太少可能导致 GC 等待的时间太长,如果太频繁可能导致运行时的性能问题。大部分指令的执行时间都非常短暂,通常会根据“是否具有让程序长时间执行的特征”为标准。比如:选择一些执行时间较长的指令作为 Safe Point,如方法调用、循环跳转和异常跳转等。

  • 如何在 GC 发生时,检查所有线程都跑到最近的安全点停顿下来呢?

    • 抢先式中断:(目前没有虚拟机采用了)
      • 首先中断所有线程。如果还有线程不在安全点,就恢复线程,让线程跑到安全点。
    • 主动式中断:
      • 设置一个中断标志,各个线程运行到 Safe Point 的时候主动轮询这个标志,如果中断标志为真,则将自己进行中断挂起。(有轮询的机制)
  • SafePoint 机制保证了程序执行时,在不太长的时间内就会遇到可进入 GC 的 SafePoint。但是,程序“不执行”的时候呢?例如线程处于 Sleep 状态或 Blocked 状态,这时候线程无法响应 JVM 的中断请求,“走”到安全点去中断挂起,JVM 也不太可能等待线程被唤醒。对于这种情况,就需要安全区域(Safe Region)来解决。

  • 安全区域是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始 GC 都是安全的。我们也可以把 Safe Region 看做是被扩展了的 SafePoint。

实际执行时:

  • 当线程运行到 Safe Region 的代码时,首先标识已经进入了 Safe Region,如果这段时间内发生 GC,JVM 会忽略标识为 Safe Region 状态的线程;
  • 当线程即将离开 Safe Region 时,会检查 JVM 是否已经完成 GC,如果完成了,则继续运行,否则线程必须等待直到收到可以安全离开 Safe Region 的信号为止;
再谈引用

我们希望能描述这样一类对象:当内存空间还足够时,则能保留在内存中;如果内存空间在进行垃圾收集后还是很紧张,则可以抛弃这些对象。

【既偏门又非常高频的面试题】强引用、软引用、弱引用、虚引用有什么区别?具体使用场景是什么?

  • 强引用(Strong Reference)
  • 软引用(Soft Reference)
  • 弱引用(Weak Reference)
  • 虚引用(Phantom Reference)

这 4 种引用强度依次逐渐减弱。除强引用外,其他 3 种引用均可以在 java.lang.ref 包中找到它们的身影。如下图,显示了这 3 种引用类型对应的类,开发人员可以在应用程序中直接使用它们。

Untitled

  • 强引用(StrongReference):最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似 "Object obj = new Object() " 这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
    • 强引用可以直接访问目标对象。
    • 强引用所指向的对象在任何时候都不会被系统回收,虚拟机宁愿抛出 OOM 异常,也不会回收强引用所指向对象。
    • 强引用可能导致内存泄漏。
  • 软引用(SoftReference):在系统将要发生内存溢出之前,将会把这些对象列入回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存流出异常。当内存足够时,不会回收软引用的可达对象;内存不够时,才会回收软引用的可达对象。
  • 弱引用(WeakReference):被弱引用关联的对象只能生存到下一次垃圾收集之前。当垃圾收集器工作时,无论内存空间是否足够,都会回收掉被弱引用关联的对象。发现即回收
  • 虚引用(PhantomReference):一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获得一个对象的实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

4.垃圾收集器

Java 不同版本新特性

  • 语法层面:Lambda 表达式、switch、自动拆箱装箱、enum
  • API 层面:Stream API、新的日期时间、Optional、String、集合框架
  • 底层优化:JVM 优化、GC 的变化、元空间、静态域、字符串常量池位置变化
垃圾收集器分类

线程数分(垃圾回收线程数),可以分为串行垃圾回收器和并行垃圾回收器。

Untitled

  • 串行回收指的是在同一时间段内只允许有一个 CPU 用于执行垃圾回收操作,此时工作线程被暂停,直至垃圾收集工作结束。
    • 在诸如单 CPU 处理器或者较小的应用内存等硬件平台不是特别优越的场合,串行回收器的性能表现可以超过并行回收器和并发回收器。所以,串行回收默认被应用在客户端的 Client 模式下的 JVM 中。
    • 在并发能力比较强的 CPU 上,并行回收器产生的停顿时间要短于串行回收器。
  • 和串行回收相反,并行收集可以运用多个 CPU 同时执行垃圾回收,因此提升了应用的吞吐量,不过并行回收仍然与串行回收一样,采用独占式,使用了“Stop-The-World”机制。

按照工作模式分,可以分为并发式垃圾回收器和独占式垃圾回收器。

Untitled

  • 并发式垃圾回收器与应用程序线程交替工作,以尽可能减少应用程序的停顿时间。
  • 独占式垃圾回收器(Stop The World)一旦运行,就停止应用程序中的所有用户线程,直到垃圾回收过程完全结束。

碎片处理方式分,可分为压缩式垃圾回收器非压缩式垃圾回收器

  • 压缩式垃圾回收器会在回收完成后,对存活对象进行压缩整理,消除回收后的碎片。
    • 再分配对象空间:使用指针碰撞
  • 非压缩式的垃圾回收器不进行这步操作。
    • 再分配对象空间:空闲列表

工作的内存区间分,又可分为年轻代垃圾回收器和老年代垃圾回收器。

评估 GC 的性能指标
  • 吞吐量:运行用户代码的时间占总运行时间的比例
    • (总运行时间 = 程序的运行时间 + 内存回收的时间)
  • 垃圾收集开销:吞吐量的补数,垃圾收集所用时间与总运行时间的比例。
  • 暂停时间:执行垃圾收集时,程序的工作线程 (用户线程) 被暂停的时间。
  • 收集频率:相对于应用程序的执行,收集操作发生的频率。
  • 内存占用:Java 堆区所占的内存大小。
  • 快速:一个对象从诞生到被回收所经历的时间。
  • 吞吐量、暂停时间、内存占用,这三者共同构成一个“不可能三角”。三者总体的表现会随着技术进步而越来越好。一款优秀的收集器通常最多同时满足其中的两项。
  • 这三项里,暂停时间的重要性日益凸显。因为随着硬件发展,内存占用多些越来越能容忍,硬件性能的提升也有助于降低收集器运行时对应用程序的影响,即提高了吞吐量。而内存的扩大,对延迟反而带来负面效果。
  • 简单来说,主要抓住两点:
    • 吞吐量
    • 暂停时间

评估 GC 的性能指标:吞吐量(Throughput)

  • 吞吐量就是 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值,即吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间)
    • 比如:虚拟机总共运行了100 分钟,其中垃圾收集花掉1分钟,那吞吐量就是 99%。
  • 这种情况下,应用程序能容忍较高的暂停时间,因此,高吞吐量的应用程序有更长的时间基准,快速响应 (暂停时间) 是不必考虑的
  • 吞吐量优先,意味着在单位时间内,STW 的时间最短:0.2 + 0.2 = 0.4

Untitled

评估 GC 的性能指标:暂停时间(pause time)

  • “暂停时间”是指一个时间段内应用程序线程暂停,让 GC 线程执行的状态。
    • 例如,GC 期间 100 毫秒的暂停时间意味着在这 100 毫秒期间内没有应用程序线程是活动的。
  • 暂停时间优先,意味着尽可能让单次 STW 的时间最短:0.1 + 0.1 + 0.1 + 0.1 + 0.1 = 0.5

Untitled

吞吐量 vs 暂停时间

  • 高吞吐量较好因为这会让应用程序的最终用户感觉只有应用程序线程在做“生产性”工作。直觉上,吞吐量越高程序运行越快。
  • 低暂停时间(低延迟)较好是因为从最终用户的角度来看不管是 GC 还是其他原因导致一个应用被挂起始终是不好的。这取决于应用程序的类型,有时候甚至短暂的 200 毫秒暂停都可能打断终端用户体验。因此,具有低的较大暂停时间是非常重要的,特别是对于一个交互式应用程序。
  • 不幸的是”高吞吐量”和”低暂停时间”是一对相互竞争的目标(矛盾)。
    • 因为如果选择以吞吐量优先,那么必然需要降低内存回收的执行频率,但是这样会导致 GC 需要更长的暂停时间来执行内存回收。
    • 相反的,如果选择以低延迟优先为原则,那么为了降低每次执行内存回收时的暂停时间,也只能频繁地执行内存回收,但这又引起了年轻代内存的缩减和导致程序吞吐量的下降
  • 在设计(或使用)GC 算法时,我们必须确定我们的目标:一个 GC 算法只可能针对两个目标之一(即只专注于较大吞吐量或最小暂停时间),或尝试找到一个二者的折衷。
  • 现在标准:在最大吞吐量优先的情况下,降低停顿时间
垃圾回收器发展史

有了虚拟机,就一定需要收集垃圾的机制,这就是 Garbage Collection,对应的产品我们称为 Garbage Collector。

  • 1999 年随 JDK 1.3.1 一起来的是串行方式的 Serial GC ,它是第一款 GC。ParNew 垃圾收集器是 Serial 收集器的多线程版本。
  • 2002 年 2 月 26 日,Parallel GC 和 Concurrent Mark Sweep GC 跟随 JDK 1.4.2 一起发布。
  • Parallel GC 在 JDK 6 之后成为 HotSpot 默认 GC。
  • 2012 年,在 JDK 1.7u4 版本中,G1 可用。
  • 2017 年,JDK 9 中 G1 变成默认的垃圾收集器,以替代 CMS。
  • 2018 年 3 月,JDK 10 中 G1 垃圾回收器的并行完整垃圾回收,实现并行性来改善最坏情况下的延迟。
  • 2018 年 9 月,JDK 11 发布。引入 Epsilon 垃圾回收器,又被称为 "No-Op(无操作)"回收器。同时,引入 ZGC:可伸缩的低延迟垃圾回收器(Experimental)
  • 2019 年 3 月,JDK 12 发布。增强 G1,自动返回未用堆内存给操作系统。同时,引入 Shenandoah GC:低停顿时间的 GC(Experimental)。
  • 2019 年 9 月,JDK 13 发布。增强 ZGC,自动返回未用堆内存给操作系统。
  • 2020 年 3 月,JDK 14 发布。删除 CMS 垃圾回收器。扩展 ZGC 在 MacOS 和 Windows 上的应用。
款经典的垃圾收集器
  • 串行回收器:Serial、Serial Old
  • 并行回收器:ParNew、Parallel Scavenge、Parallel Old
  • 并发回收器:CMS、G1

Untitled

Untitled

  • 新生代收集器:Serial、ParNew、Parallel Scavenge;
  • 老年代收集器:Serial Old、Parallel Old、CMS;
  • 整堆收集器:G1;

垃圾收集器的组合关系

Untitled

  • 两个收集器间有连线,表明它们可以搭配使用:Serial/Serial Old、Serial/CMS、ParNew/Serial Old、ParNew/CMS、Parallel Scavenge/Serial Old、Parallel Scavenge/Parallel Old、G1;
  • 其中 Serial Old 作为 CMS 出现"Concurrent Mode Failure"失败的后备预案。
  • (红色虚线)由于维护和兼容性测试的成本,在 JDK 8 时将 Serial + CMS、ParNew + Serial Old 这两个组合声明为废弃(JEP173),并在 JDK 9 中完全取消了这些组合的支持(JEP214),即:移除。
  • (绿色虚线)JDK 14 中:弃用 Parallel Scavenge 和 Serialold GC 组合(JEP366)。
  • (青色虚线)JDK 14 中:删除 CMS 垃圾回收器(JEP363)。

为什么 CMS GC 不可以和 Parallel Scavenge GC 搭配使用?

答:Parallel Scavenge GC 底层框架和其他垃圾回收器不同。

  • 为什么要有很多收集器,一个不够吗?因为 Java 的使用场景很多,移动端,服务器等。所以就需要针对不同的场景,提供不同的垃圾收集器,提高垃圾收集的性能。
  • 虽然我们会对各个收集器进行比较,但并非为了挑选一个最好的收集器出来。没有一种放之四海皆准、任何场景下都适用的完美收集器存在,更加没有万能的收集器。所以我们选择的只是对具体应用最合适的收集器。
Serial 回收器:串行回收
  • Serial 收集器是最基本、历史最悠久的垃圾收集器了。JDK 1.3 之前回收新生代唯一的选择。
  • Serial 收集器作为 HotSpot 中 Client 模式下的默认新生代垃圾收集器。
  • Serial 收集器采用复制算法、串行回收和"Stop-The-World"机制的方式执行内存回收**。**
  • 除了年轻代之外,Serial 收集器还提供用于执行老年代垃圾收集的 Serial Old 收集器。Serial Old 收集器同样也采用了串行回收和"Stop The World"机制,只不过内存回收算法使用的是标记-压缩算法**。**
    • Serial Old 是运行在 Client 模式下默认的老年代的垃圾回收器
    • Serial Old 在 Server 模式下主要有两个用途:
      • 与新生代的 Parallel Scavenge 配合使用
      • 作为老年代 CMS 收集器的后备垃圾收集方案

Untitled

  • 这个收集器是一个单线程的收集器,但它的“单线程”的意义并不仅仅说明它只会使用一个 CPU 或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束(Stop The World)。
  • 优势:简单而高效(与其他收集器的单线程比),对于限定单个 CPU 的环境来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。
    • 运行在 Client 模式下的虚拟机是个不错的选择。
  • 在用户的桌面应用场景中,可用内存一般不大(几十 MB 至一两百 MB),可以在较短时间内完成垃圾收集(几十 ms 至一百多 ms),只要不频繁发生,使用串行回收器是可以接受的。
  • 在 HotSpot 虚拟机中,使用-XX:+UseSerialGC参数可以指定年轻代和老年代都使用串行收集器。
    • 等价于新生代用 Serial GC,且老年代用 Serial Old GC。

总结

  • 这种垃圾收集器大家都了解,现在已经不用串行的了。而且在限定单核 CPU 才可以用。现在都不是单核的了。
  • 对于交互较强的应用而言,这种垃圾收集器是不能接受的。一般在 Java Web 应用程序中是不会采用串行垃圾收集器的。
ParNew 回收器:并行回收
  • 如果说 Serial GC 是年轻代中的单线程垃圾收集器,那么 ParNew 收集器则是 Serial 收集器的多线程版本。
    • Par 是 Parallel 的缩写,New:只能处理的是新生代
  • ParNew 收集器除了采用并行回收的方式执行内存回收外,两款垃圾收集器之间几乎没有任何区别。ParNew 收集器在年轻代中同样也是采用复制算法、"Stop-The-World"机制。
  • ParNew 是很多 JVM 运行在 Server 模式下新生代的默认垃圾收集器。

Untitled

  • 对于新生代,回收次数频繁,使用并行方式高效。
  • 对于老年代,回收次数少,使用串行方式节省资源。(CPU 并行需要切换线程,串行可以省去切换线程的资源)。
  • 由于 ParNew 收集器是基于并行回收,那么是否可以断定 ParNew 收集器的回收效率在任何场景下都会比 Serial 收集器更高效?
    • ParNew 收集器运行在多 CPU 的环境下,由于可以充分利用多 CPU、多核心等物理硬件资源优势,可以更快速地完成垃圾收集,提升程序的吞吐量。
    • 但是在单个 CPU 的环境下,ParNew 收集器不比 Serial 收集器更高效。虽然 Serial 收集器是基于串行回收,但是由于 CPU 不需要频繁得做任务切换,因此可以有效避免多线程交互过程中产生的一些额外开销。
  • 除 Serial Old GC 外,目前只有 ParNew GC 能与 CMS 收集器配合工作(JDK 8 中 Serial Old GC 移除对 ParNew GC 的支持,JDK 9 版本中已经明确提示 UserParNewGC was deprecated,将在后续版本中被移除,JDK 14 中移除 CMS GC)。
  • 在程序中,开发人员可以通过选项-XX:+UseParNewGC手动指定使用 ParNew 收集器执行内存回收任务。它表示年轻代使用并行收集器,不影响老年代。
  • -XX:ParallelGCThreads 限制线程数量,默认开启和 CPU 数据相同的线程数。
Parallel 回收器:吞吐量优先
  • HotSpot 的年轻代中除了拥有 ParNew 收集器是基于并行回收的以外,Parallel Scavenge 收集器同样也采用了复制算法、并行回收和"Stop The World"机制。
  • 那么 Parallel 收集器的出现是否多此一举?
    • 和 ParNew 收集器不同,Parallel Scavenge 收集器的目标则是达到一个可控制的吞吐量(Throughput),它也被称为吞吐量优先的垃圾收集器。
    • 自适应调节策略也是 Parallel Scavenge 与 ParNew 一个重要区别。
  • 高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。因此,常见在服务器环境中使用。例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序。
  • Parallel 收集器在 JDK 1.6 时提供了用于执行老年代垃圾收集的 Parallel Old 收集器,用来代替老年代的 Serial Old 收集器。
  • Parallel Old 收集器采用了标记-压缩算法,但同样也是基于并行回收和"Stop-The-World"机制。

Untitled

  • 在程序吞吐量优先的应用场景中,Parallel 收集器和 Parallel Old 收集器的组合,在 Server 模式下的内存回收性能很不错。
  • 在 Java 8 中,默认是此垃圾收集器。
CMS 回收器:低延迟
  • 在 JDK 1.5 时期,HotSpot 推出了一款在强交互应用中几乎可认为有划时代意义的垃圾收集器:CMS(Concurrent-Mark-Sweep)收集器,这款收集器是 HotSpot 虚拟机中第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程同时工作。
  • CMS 收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间。停顿时间越短(低延迟)就越适合与用户交互的程序,良好的响应速度能提升用户体验。
    • 目前很大一部分的 Java 应用集中在互联网站或者 B/S 系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS 收集器就非常符合这类应用的需求。
  • CMS 的垃圾收集算法采用标记-清除算法,并且也会"Stop-The-World"。
  • 不幸的是,CMS 作为老年代的收集器,却无法与 JDK 1.4.0 中已经存在的新生代收集器 Parallel Scavenge 配合工作,所以在 JDK 1.5 中使用 CMS 来收集老年代的时候,新生代只能选择 ParNew 或者 Serial 收集器中的一个。
  • 在 G1 出现之前,CMS 使用还是非常广泛的。一直到今天,仍然有很多系统使用 CMS GC。

Untitled

CMS 整个过程比之前的收集器要复杂,整个过程分为4个主要阶段,即初始标记阶段、并发标记阶段、重新标记阶段和并发清除阶段。(涉及 STW 的阶段主要是:初始标记和重新标记)

  • 初始标记(Initial-Mark)阶段:在这个阶段中,程序中所有的工作线程 (用户线程) 都将会因为“Stop-The-World”机制而出现短暂的暂停,这个阶段的主要任务仅仅只是标记出 GC Roots 能直接关联到的对象。一旦标记完成之后就会恢复之前被暂停的所有应用线程。由于直接关联对象比较小,所以这里的速度非常快。
  • 并发标记(Concurrent-Mark)阶段:从 GC Roots 的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。
  • 重新标记(Remark)阶段:由于在并发标记阶段中,程序的工作线程会和垃圾收集线程同时运行或者交叉运行,因此为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短。
  • 并发清除(Concurrent-Sweep)阶段:此阶段清理删除掉标记阶段判断的已经死亡的对象,释放内存空间。由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。
  • 尽管 CMS 收集器采用的是并发回收(非独占式),但是在其初始化标记和再次标记这两个阶段中仍然需要执行“Stop-the-World”机制暂停程序中的工作线程,不过暂停时间并不会太长,因此可以说明目前所有的垃圾收集器都做不到完全不需要“Stop-The-World”,只是尽可能地缩短暂停时间。
  • 由于最耗费时间的并发标记与并发清除阶段都不需要暂停工作,所以整体的回收是低停顿的。
  • 另外,由于在垃圾收集阶段用户线程没有中断,所以在 CMS 回收过程中,还应该确保应用程序用户线程有足够的内存可用。因此,CMS 收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,而是当堆内存使用率达到某一阈值时,便开始进行回收,以确保应用程序在 CMS 工作过程中依然有足够的空间支持应用程序运行。要是 CMS 运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure”失败,这时虚拟机将启动后备预案:临时启用 Serial Old 收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。
  • CMS 收集器的垃圾收集算法采用的是标记-清除算法,这意味着每次执行完内存回收后,由于被执行内存回收的无用对象所占用的内存空间极有可能是不连续的一些内存块,不可避免地将会产生一些内存碎片。那么 CMS 在为新对象分配内存空间时,将无法使用指针碰撞(Bump the Pointer)技术,而只能够选择空闲列表(Free List)执行内存分配。

CMS 为什么不使用标记整理(压缩)算法?

答案其实很简单,因为当并发清除的时候,用 Compact 整理内存的话,原来的用户线程使用的内存还怎么用呢?要保证用户线程能继续执行,前提的它运行的资源不受影响。Mark Compact 更适合“Stop The World”这种场景下使用。

优点

  • 并发收集
  • 低延迟

缺点

  • 会产生内存碎片,导致并发清除后,用户线程可用的空间不足。在无法分配大对象的情况下,不得不提前触发 Full GC。
  • CMS 收集器对 CPU 资源非常敏感。在并发阶段,它虽然不会导致用户停顿,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量会降低。
  • CMS 收集器无法处理浮动垃圾。可能出现“Concurrent Mode Failure"失败而导致另一次 Full GC 的产生。在并发标记阶段由于程序的工作线程和垃圾收集线程是同时运行或者交叉运行的,那么在并发标记阶段如果产生新的垃圾对象,CMS 将无法对这些垃圾对象进行标记,最终会导致这些新产生的垃圾对象没有被及时回收,从而只能在下一次执行 GC 时释放这些之前未被回收的内存空间。

浮动垃圾:在并发清除阶段产生的垃圾只能在下一次GC清除。

CMS 收集器可以设置的参数

  • -XX:+UseConcMarkSweepGC手动指定使用 CMS 收集器执行内存回收任务。

    • 开启该参数后会自动将 -XX:+UseParNewGC 打开。即:ParNew(Young 区用)+ CMS(Old 区用)+ Serial Old 的组合。

    • -XX:CMSInitiatingoccupanyFraction设置堆内存使用率的阈值,一旦达到该阈值,便开始进行回收。

    • JDK 5 及以前版本的默认值为 68,即当老年代的空间使用率达到 68% 时,会执行一次 CMS 回收。JDK 6 及以上版本默认值为 92%

    • 如果内存增长缓慢,则可以设置一个稍大的值,大的阀值可以有效降低 CMS 的触发频率,减少老年代回收的次数可以较为明显地改善应用程序性能。反之,如果应用程序内存使用率增长很快,则应该降低这个阈值,以避免频繁触发老年代串行收集器。因此通过该选项便可以有效降低 Full GC 的执行次数。

  • -XX:+UseCMSCompactAtFullCollection 用于指定在执行完 Full GC 后对内存空间进行压缩整理,以此避免内存碎片的产生。不过由于内存压缩整理过程无法并发执行,所带来的问题就是停顿时间变得更长了。

  • -XX:CMSFullGCsBeforecompaction 设置在执行多少次 Full GC 后对内存空间进行压缩整理。

  • -XX:ParallelcMSThreads设置 CMS 的线程数量。

    • CMS 默认启动的线程数是(ParallelGCThreads + 3)/ 4,ParallelGCThreads 是年轻代并行收集器的线程数。当 CPU 资源比较紧张时,受到 CMS 收集器线程的影响,应用程序的性能在垃圾回收阶段可能会非常糟糕。

HotSpot 有这么多的垃圾回收器,那么如果有人问,Serial GC、Parallel GC、Concurrent Mark Sweep GC 这三个 GC 有什么不同呢?

请记住以下口令:

  • 如果你想要最小化地使用内存和并行开销,请选 Serial GC;
  • 如果你想要最大化应用程序的吞吐量,请选 Parallel GC;
  • 如果你想要最小化 GC 的中断或停顿时间,请选 CMS GC。

CMS(concurrent mark sweep)在jdk1.5中已经开始使用了,2004年9月30日,JDK1.5发布。CMS设计的目标就是获取最低停顿时间(stop the world停顿时间),它是基于标记-清除算法实现的。常用的场景是互联网网站(对服务响应要求较高),它是一个老年代垃圾收集器,可以和Serial收集器,Parallel New收集器配合使用。当并行模式(concurrent mode failure)失败时CMS会退化成Serial Old.

CMS全称 Concurrent Mark Sweep,是一款并发的、使用标记-清除算法的垃圾回收器

1、不压缩老年代,而是使用空闲列表来管理回收空间。

2、大部分标记清理工作与应用程序并发执行。

存在问题:

1.对CPU资源敏感

其实,面向并发设计的程序都对CPU资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低。CMS默认启动的回收线程数是(CPU数量+3)/4,也就是当CPU在4个以上时,并发回收时垃圾收集线程不少于25%的CPU资源,随着CPU数量的增加而下降。

2.无法处理浮动垃圾

CMS并发清理时,用户程序的运行也会产生新的垃圾(一边打扫房间,一遍丢新的垃圾),但是这部分垃圾产生于标记过程之后,因此只好留在下次GC时清理,这种垃圾被称为浮动垃圾(Floating Garbage)。

3.Concurrent Mode Failure

由于CMS并发清理阶段,用户程序还在运行,也需要内存空间,因此CMS收集器不能像其他老年代收集器那样,等到老年代空间快满了再执行垃圾收集,而是要预留一部分内存给用户程序使用。CMS的做法是老年代空间占用率达到某个阈值时触发垃圾收集,有一个参数来控制触发百分比: -XX:CMSInitiatingOccupancyFraction=80 (这里配置的是80%)。

如果预留的老年代空间不够应用程序的使用,就会出现Concurrent Mode Failure,此时会触发一次FullGC,使用Serial Old收集器重新对老年代进行垃圾回收,会发生stop-the-world,耗时相当感人(实际工作中遇到的大部分FGC估计都是这种情况)。Concurrent Mode Failure一般会伴随ParNew promotion failed,晋升担保失败。所谓晋升担保,就是为了应对新生代GC后存活对象过多,Survivor区无法容纳的情况,需要老年代有足够的空间容纳这些对象,如果老年代没有足够的空间,就会产生担保失败。

为了避免Concurrent Mode Failure,可以采取的做法是:

1.调大老年代空间;
2.调低CMSInitiatingOccupancyFraction的值,但这样会造成更频繁的CMS GC;
3.代码层面优化,控制对象创建频率。

垃圾碎片问题

  • 原因:由于CMS采用的是标记-清除算法,所以不可避免会有内存碎片问题。
  • 解决:使用-XX:+CMSFullGCsBeforeCompaction=n,意思是在上次CMS并发GC执行过后,到底还要做多少Full GC才做压缩。默认是0,也就是说每次CMS GC顶不住了转入Full GC时都要压缩。

并发模式失败(concurrent mode failure)

  • 原因:CMS垃圾清理线程和应用线程是并发执行的,如果在清理过程中老年代空间不足不能容纳新对象。
  • 解决:使用-XX:+UseCMSInitiatingOccupancyOnly-XX:CMSInitiatingOccupancyFraction=60,指定CMS对内存的占用率到60%时开始GC。

重新标记阶段时间过长

  • 解决:使用-XX:+CMSScavengeBeforeRemark,在执行重新标记之前,先做一次Young GC,目的在于较少年轻代对老年代的无效引用,降低重新标记的开销。

主要四个阶段

  • 初始标记:只标记和GC Roots能直连的对象,速度快,会发生(stop the world)
  • 并发标记:和应用线程并发执行,遍历初始标记阶段标记过的对象,标记这些对象的可达对象。
    返回未用堆内存给操作系统。
  • 2020 年 3 月,JDK 14 发布。删除 CMS 垃圾回收器。扩展 ZGC 在 MacOS 和 Windows 上的应用。
款经典的垃圾收集器
  • 串行回收器:Serial、Serial Old
  • 并行回收器:ParNew、Parallel Scavenge、Parallel Old
  • 并发回收器:CMS、G1

Untitled

Untitled

  • 新生代收集器:Serial、ParNew、Parallel Scavenge;
  • 老年代收集器:Serial Old、Parallel Old、CMS;
  • 整堆收集器:G1;

垃圾收集器的组合关系

Untitled

  • 两个收集器间有连线,表明它们可以搭配使用:Serial/Serial Old、Serial/CMS、ParNew/Serial Old、ParNew/CMS、Parallel Scavenge/Serial Old、Parallel Scavenge/Parallel Old、G1;
  • 其中 Serial Old 作为 CMS 出现"Concurrent Mode Failure"失败的后备预案。
  • (红色虚线)由于维护和兼容性测试的成本,在 JDK 8 时将 Serial + CMS、ParNew + Serial Old 这两个组合声明为废弃(JEP173),并在 JDK 9 中完全取消了这些组合的支持(JEP214),即:移除。
  • (绿色虚线)JDK 14 中:弃用 Parallel Scavenge 和 Serialold GC 组合(JEP366)。
  • (青色虚线)JDK 14 中:删除 CMS 垃圾回收器(JEP363)。

为什么 CMS GC 不可以和 Parallel Scavenge GC 搭配使用?

答:Parallel Scavenge GC 底层框架和其他垃圾回收器不同。

  • 为什么要有很多收集器,一个不够吗?因为 Java 的使用场景很多,移动端,服务器等。所以就需要针对不同的场景,提供不同的垃圾收集器,提高垃圾收集的性能。
  • 虽然我们会对各个收集器进行比较,但并非为了挑选一个最好的收集器出来。没有一种放之四海皆准、任何场景下都适用的完美收集器存在,更加没有万能的收集器。所以我们选择的只是对具体应用最合适的收集器。
Serial 回收器:串行回收
  • Serial 收集器是最基本、历史最悠久的垃圾收集器了。JDK 1.3 之前回收新生代唯一的选择。
  • Serial 收集器作为 HotSpot 中 Client 模式下的默认新生代垃圾收集器。
  • Serial 收集器采用复制算法、串行回收和"Stop-The-World"机制的方式执行内存回收**。**
  • 除了年轻代之外,Serial 收集器还提供用于执行老年代垃圾收集的 Serial Old 收集器。Serial Old 收集器同样也采用了串行回收和"Stop The World"机制,只不过内存回收算法使用的是标记-压缩算法**。**
    • Serial Old 是运行在 Client 模式下默认的老年代的垃圾回收器
    • Serial Old 在 Server 模式下主要有两个用途:
      • 与新生代的 Parallel Scavenge 配合使用
      • 作为老年代 CMS 收集器的后备垃圾收集方案

Untitled

  • 这个收集器是一个单线程的收集器,但它的“单线程”的意义并不仅仅说明它只会使用一个 CPU 或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束(Stop The World)。
  • 优势:简单而高效(与其他收集器的单线程比),对于限定单个 CPU 的环境来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。
    • 运行在 Client 模式下的虚拟机是个不错的选择。
  • 在用户的桌面应用场景中,可用内存一般不大(几十 MB 至一两百 MB),可以在较短时间内完成垃圾收集(几十 ms 至一百多 ms),只要不频繁发生,使用串行回收器是可以接受的。
  • 在 HotSpot 虚拟机中,使用-XX:+UseSerialGC参数可以指定年轻代和老年代都使用串行收集器。
    • 等价于新生代用 Serial GC,且老年代用 Serial Old GC。

总结

  • 这种垃圾收集器大家都了解,现在已经不用串行的了。而且在限定单核 CPU 才可以用。现在都不是单核的了。
  • 对于交互较强的应用而言,这种垃圾收集器是不能接受的。一般在 Java Web 应用程序中是不会采用串行垃圾收集器的。
ParNew 回收器:并行回收
  • 如果说 Serial GC 是年轻代中的单线程垃圾收集器,那么 ParNew 收集器则是 Serial 收集器的多线程版本。
    • Par 是 Parallel 的缩写,New:只能处理的是新生代
  • ParNew 收集器除了采用并行回收的方式执行内存回收外,两款垃圾收集器之间几乎没有任何区别。ParNew 收集器在年轻代中同样也是采用复制算法、"Stop-The-World"机制。
  • ParNew 是很多 JVM 运行在 Server 模式下新生代的默认垃圾收集器。

Untitled

  • 对于新生代,回收次数频繁,使用并行方式高效。
  • 对于老年代,回收次数少,使用串行方式节省资源。(CPU 并行需要切换线程,串行可以省去切换线程的资源)。
  • 由于 ParNew 收集器是基于并行回收,那么是否可以断定 ParNew 收集器的回收效率在任何场景下都会比 Serial 收集器更高效?
    • ParNew 收集器运行在多 CPU 的环境下,由于可以充分利用多 CPU、多核心等物理硬件资源优势,可以更快速地完成垃圾收集,提升程序的吞吐量。
    • 但是在单个 CPU 的环境下,ParNew 收集器不比 Serial 收集器更高效。虽然 Serial 收集器是基于串行回收,但是由于 CPU 不需要频繁得做任务切换,因此可以有效避免多线程交互过程中产生的一些额外开销。
  • 除 Serial Old GC 外,目前只有 ParNew GC 能与 CMS 收集器配合工作(JDK 8 中 Serial Old GC 移除对 ParNew GC 的支持,JDK 9 版本中已经明确提示 UserParNewGC was deprecated,将在后续版本中被移除,JDK 14 中移除 CMS GC)。
  • 在程序中,开发人员可以通过选项-XX:+UseParNewGC手动指定使用 ParNew 收集器执行内存回收任务。它表示年轻代使用并行收集器,不影响老年代。
  • -XX:ParallelGCThreads 限制线程数量,默认开启和 CPU 数据相同的线程数。
Parallel 回收器:吞吐量优先
  • HotSpot 的年轻代中除了拥有 ParNew 收集器是基于并行回收的以外,Parallel Scavenge 收集器同样也采用了复制算法、并行回收和"Stop The World"机制。
  • 那么 Parallel 收集器的出现是否多此一举?
    • 和 ParNew 收集器不同,Parallel Scavenge 收集器的目标则是达到一个可控制的吞吐量(Throughput),它也被称为吞吐量优先的垃圾收集器。
    • 自适应调节策略也是 Parallel Scavenge 与 ParNew 一个重要区别。
  • 高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。因此,常见在服务器环境中使用。例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序。
  • Parallel 收集器在 JDK 1.6 时提供了用于执行老年代垃圾收集的 Parallel Old 收集器,用来代替老年代的 Serial Old 收集器。
  • Parallel Old 收集器采用了标记-压缩算法,但同样也是基于并行回收和"Stop-The-World"机制。

Untitled

  • 在程序吞吐量优先的应用场景中,Parallel 收集器和 Parallel Old 收集器的组合,在 Server 模式下的内存回收性能很不错。
  • 在 Java 8 中,默认是此垃圾收集器。
CMS 回收器:低延迟
  • 在 JDK 1.5 时期,HotSpot 推出了一款在强交互应用中几乎可认为有划时代意义的垃圾收集器:CMS(Concurrent-Mark-Sweep)收集器,这款收集器是 HotSpot 虚拟机中第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程同时工作。
  • CMS 收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间。停顿时间越短(低延迟)就越适合与用户交互的程序,良好的响应速度能提升用户体验。
    • 目前很大一部分的 Java 应用集中在互联网站或者 B/S 系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS 收集器就非常符合这类应用的需求。
  • CMS 的垃圾收集算法采用标记-清除算法,并且也会"Stop-The-World"。
  • 不幸的是,CMS 作为老年代的收集器,却无法与 JDK 1.4.0 中已经存在的新生代收集器 Parallel Scavenge 配合工作,所以在 JDK 1.5 中使用 CMS 来收集老年代的时候,新生代只能选择 ParNew 或者 Serial 收集器中的一个。
  • 在 G1 出现之前,CMS 使用还是非常广泛的。一直到今天,仍然有很多系统使用 CMS GC。

Untitled

CMS 整个过程比之前的收集器要复杂,整个过程分为4个主要阶段,即初始标记阶段、并发标记阶段、重新标记阶段和并发清除阶段。(涉及 STW 的阶段主要是:初始标记和重新标记)

  • 初始标记(Initial-Mark)阶段:在这个阶段中,程序中所有的工作线程 (用户线程) 都将会因为“Stop-The-World”机制而出现短暂的暂停,这个阶段的主要任务仅仅只是标记出 GC Roots 能直接关联到的对象。一旦标记完成之后就会恢复之前被暂停的所有应用线程。由于直接关联对象比较小,所以这里的速度非常快。
  • 并发标记(Concurrent-Mark)阶段:从 GC Roots 的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。
  • 重新标记(Remark)阶段:由于在并发标记阶段中,程序的工作线程会和垃圾收集线程同时运行或者交叉运行,因此为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短。
  • 并发清除(Concurrent-Sweep)阶段:此阶段清理删除掉标记阶段判断的已经死亡的对象,释放内存空间。由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。
  • 尽管 CMS 收集器采用的是并发回收(非独占式),但是在其初始化标记和再次标记这两个阶段中仍然需要执行“Stop-the-World”机制暂停程序中的工作线程,不过暂停时间并不会太长,因此可以说明目前所有的垃圾收集器都做不到完全不需要“Stop-The-World”,只是尽可能地缩短暂停时间。
  • 由于最耗费时间的并发标记与并发清除阶段都不需要暂停工作,所以整体的回收是低停顿的。
  • 另外,由于在垃圾收集阶段用户线程没有中断,所以在 CMS 回收过程中,还应该确保应用程序用户线程有足够的内存可用。因此,CMS 收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,而是当堆内存使用率达到某一阈值时,便开始进行回收,以确保应用程序在 CMS 工作过程中依然有足够的空间支持应用程序运行。要是 CMS 运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure”失败,这时虚拟机将启动后备预案:临时启用 Serial Old 收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。
  • CMS 收集器的垃圾收集算法采用的是标记-清除算法,这意味着每次执行完内存回收后,由于被执行内存回收的无用对象所占用的内存空间极有可能是不连续的一些内存块,不可避免地将会产生一些内存碎片。那么 CMS 在为新对象分配内存空间时,将无法使用指针碰撞(Bump the Pointer)技术,而只能够选择空闲列表(Free List)执行内存分配。

CMS 为什么不使用标记整理(压缩)算法?

答案其实很简单,因为当并发清除的时候,用 Compact 整理内存的话,原来的用户线程使用的内存还怎么用呢?要保证用户线程能继续执行,前提的它运行的资源不受影响。Mark Compact 更适合“Stop The World”这种场景下使用。

优点

  • 并发收集
  • 低延迟

缺点

  • 会产生内存碎片,导致并发清除后,用户线程可用的空间不足。在无法分配大对象的情况下,不得不提前触发 Full GC。
  • CMS 收集器对 CPU 资源非常敏感。在并发阶段,它虽然不会导致用户停顿,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量会降低。
  • CMS 收集器无法处理浮动垃圾。可能出现“Concurrent Mode Failure"失败而导致另一次 Full GC 的产生。在并发标记阶段由于程序的工作线程和垃圾收集线程是同时运行或者交叉运行的,那么在并发标记阶段如果产生新的垃圾对象,CMS 将无法对这些垃圾对象进行标记,最终会导致这些新产生的垃圾对象没有被及时回收,从而只能在下一次执行 GC 时释放这些之前未被回收的内存空间。

浮动垃圾:在并发清除阶段产生的垃圾只能在下一次GC清除。

CMS 收集器可以设置的参数

  • -XX:+UseConcMarkSweepGC手动指定使用 CMS 收集器执行内存回收任务。

    • 开启该参数后会自动将 -XX:+UseParNewGC 打开。即:ParNew(Young 区用)+ CMS(Old 区用)+ Serial Old 的组合。

    • -XX:CMSInitiatingoccupanyFraction设置堆内存使用率的阈值,一旦达到该阈值,便开始进行回收。

    • JDK 5 及以前版本的默认值为 68,即当老年代的空间使用率达到 68% 时,会执行一次 CMS 回收。JDK 6 及以上版本默认值为 92%

    • 如果内存增长缓慢,则可以设置一个稍大的值,大的阀值可以有效降低 CMS 的触发频率,减少老年代回收的次数可以较为明显地改善应用程序性能。反之,如果应用程序内存使用率增长很快,则应该降低这个阈值,以避免频繁触发老年代串行收集器。因此通过该选项便可以有效降低 Full GC 的执行次数。

  • -XX:+UseCMSCompactAtFullCollection 用于指定在执行完 Full GC 后对内存空间进行压缩整理,以此避免内存碎片的产生。不过由于内存压缩整理过程无法并发执行,所带来的问题就是停顿时间变得更长了。

  • -XX:CMSFullGCsBeforecompaction 设置在执行多少次 Full GC 后对内存空间进行压缩整理。

  • -XX:ParallelcMSThreads设置 CMS 的线程数量。

    • CMS 默认启动的线程数是(ParallelGCThreads + 3)/ 4,ParallelGCThreads 是年轻代并行收集器的线程数。当 CPU 资源比较紧张时,受到 CMS 收集器线程的影响,应用程序的性能在垃圾回收阶段可能会非常糟糕。

HotSpot 有这么多的垃圾回收器,那么如果有人问,Serial GC、Parallel GC、Concurrent Mark Sweep GC 这三个 GC 有什么不同呢?

请记住以下口令:

  • 如果你想要最小化地使用内存和并行开销,请选 Serial GC;
  • 如果你想要最大化应用程序的吞吐量,请选 Parallel GC;
  • 如果你想要最小化 GC 的中断或停顿时间,请选 CMS GC。

CMS(concurrent mark sweep)在jdk1.5中已经开始使用了,2004年9月30日,JDK1.5发布。CMS设计的目标就是获取最低停顿时间(stop the world停顿时间),它是基于标记-清除算法实现的。常用的场景是互联网网站(对服务响应要求较高),它是一个老年代垃圾收集器,可以和Serial收集器,Parallel New收集器配合使用。当并行模式(concurrent mode failure)失败时CMS会退化成Serial Old.

CMS全称 Concurrent Mark Sweep,是一款并发的、使用标记-清除算法的垃圾回收器

1、不压缩老年代,而是使用空闲列表来管理回收空间。

2、大部分标记清理工作与应用程序并发执行。

存在问题:

1.对CPU资源敏感

其实,面向并发设计的程序都对CPU资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低。CMS默认启动的回收线程数是(CPU数量+3)/4,也就是当CPU在4个以上时,并发回收时垃圾收集线程不少于25%的CPU资源,随着CPU数量的增加而下降。

2.无法处理浮动垃圾

CMS并发清理时,用户程序的运行也会产生新的垃圾(一边打扫房间,一遍丢新的垃圾),但是这部分垃圾产生于标记过程之后,因此只好留在下次GC时清理,这种垃圾被称为浮动垃圾(Floating Garbage)。

3.Concurrent Mode Failure

由于CMS并发清理阶段,用户程序还在运行,也需要内存空间,因此CMS收集器不能像其他老年代收集器那样,等到老年代空间快满了再执行垃圾收集,而是要预留一部分内存给用户程序使用。CMS的做法是老年代空间占用率达到某个阈值时触发垃圾收集,有一个参数来控制触发百分比: -XX:CMSInitiatingOccupancyFraction=80 (这里配置的是80%)。

如果预留的老年代空间不够应用程序的使用,就会出现Concurrent Mode Failure,此时会触发一次FullGC,使用Serial Old收集器重新对老年代进行垃圾回收,会发生stop-the-world,耗时相当感人(实际工作中遇到的大部分FGC估计都是这种情况)。Concurrent Mode Failure一般会伴随ParNew promotion failed,晋升担保失败。所谓晋升担保,就是为了应对新生代GC后存活对象过多,Survivor区无法容纳的情况,需要老年代有足够的空间容纳这些对象,如果老年代没有足够的空间,就会产生担保失败。

为了避免Concurrent Mode Failure,可以采取的做法是:

1.调大老年代空间;
2.调低CMSInitiatingOccupancyFraction的值,但这样会造成更频繁的CMS GC;
3.代码层面优化,控制对象创建频率。

垃圾碎片问题

  • 原因:由于CMS采用的是标记-清除算法,所以不可避免会有内存碎片问题。
  • 解决:使用-XX:+CMSFullGCsBeforeCompaction=n,意思是在上次CMS并发GC执行过后,到底还要做多少Full GC才做压缩。默认是0,也就是说每次CMS GC顶不住了转入Full GC时都要压缩。

并发模式失败(concurrent mode failure)

  • 原因:CMS垃圾清理线程和应用线程是并发执行的,如果在清理过程中老年代空间不足不能容纳新对象。
  • 解决:使用-XX:+UseCMSInitiatingOccupancyOnly-XX:CMSInitiatingOccupancyFraction=60,指定CMS对内存的占用率到60%时开始GC。

重新标记阶段时间过长

  • 解决:使用-XX:+CMSScavengeBeforeRemark,在执行重新标记之前,先做一次Young GC,目的在于较少年轻代对老年代的无效引用,降低重新标记的开销。

主要四个阶段

  • 初始标记:只标记和GC Roots能直连的对象,速度快,会发生(stop the world)
  • 并发标记:和应用线程并发执行,遍历初始标记阶段标记过的对象,标记这些对象的可达对象。
  • 重新标记:由于并发标记是和应用线程是并发执行的,所以有些标记过的对象发生了变化。这个过程比`初始标记
  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值