文章目录
- 一、Java基础
- 二、Java内存模型(JMM)
- 三、JVM
- 了解JVM吗?
- JVM内部结构?
- 1、类加载器
- 2、Java 内存区域(运行时数据区)
- 类加载过程?🌟
- 3、Java 对象的创建过程?🌟
- 4、内存分配:
- 5、JVM垃圾回收
- 四、集合
- 对线程安全的理解?🌟
静态语言与动态语言区别?🌟
- 静态语言(强类型语言):是编译时变量的数据类型就可以确定的语言,大多数静态语言要求在使用变量之前必须声明数据类型。Java、C、C++和C#等属于静态语言。
- 动态语言(弱类型语言):是运行时才确定数据类型的语言,变量在使用之前无需声明类型,通常变量的值是被赋值的那个值的类型。比如Php、Asp、JavaScript、Python、Perl等等。
一、Java基础
1、Java基础概念:
JVM JDK JRE?🌟
- Java 虚拟机(JVM)是运行 Java 字节码的虚拟机。JVM 有针对不同系统的特定实现(Windows,Linux,macOS),目的是使用相同的字节码,它们都会给出相同的结果。字节码和不同系统的 JVM 实现是 Java 语言“一次编译,随处可以运行”的关键所在。
- JVM 并不是只有一种!只要满足 JVM 规范,每个公司、组织或者个人都可以开发自己的专属 JVM。 也就是说我们平时接触到的 HotSpot VM 仅仅是是 JVM 规范的一种实现而已。
- 除了我们平时最常用的 HotSpot VM 外,还有 J9 VM、Zing VM、JRockit VM 等 JVM 。维基百科上就有常见 JVM 的对比:Comparison of Java virtual machines ,感兴趣的可以去看看。并且,你可以在 Java SE Specifications 上找到各个版本的 JDK 对应的 JVM 规范。
- JRE
- JRE是 Java 运行时环境,包括JVM和核心类库。提供给需要运行Java程序的用户使用的。但是,它不能用于创建新程序。
- JDK(Java Development Kit)
- JDK是Java开发工具包,是程序员编写Java程序所需用到的Java开发工具包。JDK包括JRE、编译器Javac、Java程序的调试工具和分析工具,Java编写需要的文档等。能够创建和编译程序。
- 补充:JDK是开发环境,JRE是运行时环境。编写Java程序时需要JDK,运行Java程序需要JRE,但JDK已经包含了JRE,所以只要安装了JDK就可以编辑和运行Java程序。当Java程序只需要运行,不需要编写时,只安装JRE即可。
什么是字节码?采用字节码的好处是什么?
- 字节码就是扩展名为
.class
的文件。(JVM可以理解的代码叫字节码)。 - 字节码不面向任何特定的处理器,只面向虚拟机。Java程序不需要重新编译就可以在多种不同操作系统的计算机上运行。
Java 程序从源代码到运行的过程如下图所示?
.class->机器码 这一步:在这一步 JVM 类加载器首先加载字节码文件,然后通过解释器逐行解释执行,这种方式的执行速度会相对比较慢。而且,有些方法和代码块是经常需要被调用的(也就是所谓的热点代码),所以为了避免解释型语言带来的执行效率低问题,引进了 JIT(just-in-time compilation) 编译器,把运行频率很高的字节码直接编译成机器码来提高性能。
为什么说 Java 语言“编译与解释并存”?
- 高级编程语言按照程序的执行方式分为两种:
1)编译型语言:会通过编译器将源代码一次性翻译成机器码再执行。一般情况下,编译型语言执行速度比较快,开发效率较低。常见的编译性语言有 C、C++、Go、Rust 等等。
(编译只做一次,运行时不需要再编译,因此执行速度比较快)
2)解释型语言:会通过解释器一句句解释成机器码再执行。解释型语言开发效率比较快,执行速度比较慢。常见的解释性语言有 Python、JavaScript、PHP 等等。
(解释型语言运行程序时 才解释,性能上不如编译型语言) - Java语言具有编译型语言和解释型语言的特征。因为Java语言是先 编译生成字节码,再用解释器把字节码解释为机器码。
Java 和 C++ 的区别?
Java 和 C++ 都是面向对象的语言,都支持封装、继承和多态。
- 区别:
- Java不提供指针来 直接访问内存,程序内存更加安全。
- Java的类是单继承,C++支持多继承。但是Java的接口可以多继承。
- Java有自动内存管理垃圾回收机制(GC),不需要程序员手动释放无用内存。
- C++支持方法重载和操作符重载,但Java只支持方法重载。(操作符重载增加了复杂性,这与 Java 最初的设计思想不符)
Java中行参改变会影响实参吗?
- 基本类型和特别的引用类型(封装类、String类)作为参数时,形参的修改不会影响实参。
- 数组之类的引用类型作为参数时,形参的修改会影响实参
Java只有值传递?
- 如果参数是基本类型,传递的是基本类型的字面量值,行参拷贝基本类型的值拿到方法中去用,行参的修改不影响实参。
- 如果参数是引用类型,传递的是实参的地址,行参拷贝实参地址值拿到方法中用。行参改变可能会影响实参:
- 比如String类型、自定义的对象…,行参拷贝实参的地址值,行参的修改不会影响实参。
- 比如说 数组类型:行参拷贝实参的地址值,均指向同一个数组对象,行参的修改会影响实参。
如果参数是基本类型的话,很简单,传递的就是基本类型的字面量值的拷贝,会创建副本。
如果参数是引用类型,传递的就是实参所引用的对象在堆中地址值的拷贝,同样也会创建副本。
2、Java基本语法
标识符和关键字的区别?
- 标识符就是一个名字。
- 关键字 是被赋予特殊含义的标识符。
- 例子:比如说开一家店,店的名字叫标识符,但不可以叫警察局。因为警察局被赋予了特殊意义,这里警察局就是关键字。
Java语言关键字?🌟
- 访问控制关键字:(用来控制一个成员能够被访问的范围的)
- 1)
private
:同一个类中。 - 2)
缺省
:同一个类中,同一个包中的类。 - 3)
protected
:同一类中、同一包中的类、其他包的子类。 - 4)
public
:同一类中、同一包中的类、其他包的子类、其他包下的无关类。
- 1)
final
关键字:是最终的意思,可以修饰类、方法、变量- 1)如果修饰类:表明该类是最终类,不能被继承。
- 2)修饰方法:表明是最终方法,不能被重写。
- 3)修饰变量:表明该变量第一次赋值后不能再被赋值。
- 修饰的变量是基本类型:那么变量存储的数据值不能发生改变。
- …引用类型:变量存储的地址值不能发生改变,但是地址指向的对象内容可以发生变化。
- 程序控制:
- 1)
break
:指跳出整个循环体,继续执行循环下面的语句。 - 2)
continue
:指跳出当前的这一次循环,继续下一次循环。 - 3)
return
:用于跳出所在方法,结束该方法的运行。
- 1)
Java数据类型?🌟
- 基本数据类型:
- 整数:
- 1)
byte
,默认值为0,8位 1字节; - 2)
short
,默认值0, 16位 2字节; - 3)
int
,默认值0, 32位 4字节; - 4)
long
,默认值0L,64位 8字节;
- 1)
- 浮点数:
- 5)
float
,默认值0.0f,32位 4字节; - 6)
double
,默认值0.0d,64位 8字节;
- 5)
- 字符:7)
char
,默认值’u0000’ 16位 2字节; - 布尔:8)
boolean
,默认值false,一位?
- 整数:
- 引用数据类型:
- 指向对象的变量是引用变量
自动类型转换:
- 转换前数据类型位数 低于转换后的数据类型位数。
- 转换从低级到高级:
包装类型和基本类型的区别?
- 包装类型默认值为
null
;基本类型默认值不为null。 - 包装类型可用于泛型;基本类型不能用于泛型。
- 包装类型属于对象类型,是存在Java堆中。基本数据类型的局部变量是存放在Java虚拟机栈中的局部变量表中,基本数据类型的成员变量(未被 static 修饰 )是存放在Java虚拟机的堆中。
- 相比于对象类型, 基本数据类型占用的空间非常小。
包装类型的缓存机制了解么?
- Java为了提高包装类的使用效率,对除了Float、Double之外的包装类都提供了常量池缓存机制。
- 对于
Byte、Short、Integer、Long
这四种包装类,Java会对数值范围在[-128,127]
的数据做缓存;Character
是对数值范围在[0,127]
的数据做缓存;Boolean
直接返回True
或False
。- 如果装箱的数据在这个范围之中,就会先去常量池查找是否已经生成了这个值对应的包装类对象,如果有装箱操作则直接返回该对象的引用,如果没有才会创建一个包装类的对象返回,并将这个对象放入常量池;
- 如果数据超出了这个范围,则直接创建新的对象并返回新对象的引用。
- 两种浮点数类型的包装类
Float,Double
并没有实现缓存机制。
所有整型包装类对象之间值的比较,全部使用 equals
方法比较?
自动装箱与自动拆箱?原理是什么?🌟
- 自动装箱:就是Java自动将基本数据类型用对应的引用类型包装起来。
- 装箱:是调用了包装类的
valueOf()
方法
- 装箱:是调用了包装类的
- 自动拆箱:就是 Java自动将包装类型转换为基本数据类型。
- 拆箱:是调用了
xxxValue()
方法。例如.intValue()
拆箱为int
类型
- 拆箱:是调用了
- 例子:
Integer i = 10;//装箱 ==》等价于Integer i = Integer.valueOf(10)
int n = i;//拆箱 ==> 等价于int n = i.intValue();
- 如果频繁拆装箱,也会严重影响系统性能,我们应该尽量避免不必要的拆装箱操作。
为什么浮点数运算的时候会有精度丢失的问题?如何解决?
- 计算机是二进制的,计算机在表示一个数字的时候宽度有限,无法准确地表示所有小数。而浮点数是由整数部分和小数部分组成的,也就意味着,计算机无法准确表示浮点数。
- 解决方案:使用
BigDecimal
。BigDecimal可以实现对浮点数的运算,不会造成精度丢失。
例如:0.1+0.2不等于0.3
因为计算机是二进制的。 运算 0.1+ 0.2 时要先把 0.1和 0.2 从十进制转成二进制。 0.1 和 0.2 转成的二进制是无穷的。计算机在表示一个数字的时候宽度有限,所以会出现精度丢失问题,最终导致 0.1+0.2 不等于0.3
超过long整型的数据应该如何表示?
基本数据类型都有一个表达范围,如果超过这个范围就会有数值溢出的风险。
使用BigInteger
,内部使用int[]数组来存储任意大小的整型数据。
变量
自增自减运算符?
例如:
- 当
b = ++a
时,先自增(自己增加 1),再赋值(赋值给 b); - 当
b = a++
时,先赋值(赋值给 b),再自增(自己增加 1)。也就是,++a 输出的是 a+1 的值,a++输出的是 a 值。 - 用一句口诀就是:“符号在前就先加/减,符号在后就后加/减”。
成员变量与局部变量的区别?
- 语法形式 :成员变量是类中定义的;局部变量是在代码块/方法中定义的变量或是方法的参数;成员变量可以被
public
,private
,static
等修饰符所修饰,而局部变量不能被访问控制修饰符及static
所修饰;但是,成员变量和局部变量都能被final
所修饰。 - 存储方式 :从变量在内存中的存储方式来看,如果成员变量是使用
static
修饰的,那么这个成员变量是属于类的,如果没有使用static
修饰,这个成员变量是属于实例的。而对象存在于堆内存,局部变量则存在于栈内存。 - 生存时间 :从变量在内存中的生存时间上看,成员变量是对象的一部分,它随着对象的创建而存在,而局部变量随着方法的调用而自动生成,随着方法的调用结束而消亡。
- 默认值 :从变量是否有默认值来看,成员变量如果没有被赋初始值,则会自动以类型的默认值而赋值(一种情况例外:被 final 修饰的成员变量也必须显式地赋值),而局部变量则不会自动赋值。
静态变量有什么作用?
- 静态变量可以被类的所有实例共享,无论创建多少对象,都共享这一份静态变量。
- 通常情况下,静态变量会被
final
关键字修饰成为常量。
字符型常量和字符串常量的区别?
- 字符型常量是用单引号’引起来;字符串常量是用双引号"引起来。
- 字符型常量相当于一个整型值(ASCII值),可以参与表达式运算;字符串常量代表一个地址值(该字符串在内存中存放位置)
- 字符型常量在内存中占2个字节;字符串常量占若干个字节。
(注意: char 在 Java 中占两个字节)
方法
静态方法为什么不能调用非静态成员?
- 静态方法是属于类的,在类加载时就会分配内存,可以通过类名直接访问。非静态成员是属于对象实例的,对象实例化后分配内存,通过实例对象访问。
- 非静态成员不存在时,静态方法就已经存在了,静态方法中调用还不存在的非静态成员是非法的。
静态方法和实例方法有何不同?
- 静态方法是属于类的,可以通过
类名.方法名
或对象名.方法名
来调用;实例方法只能通过对象名.方法名
来调用; - 静态方法只能访问静态成员(静态成员变量和静态方法),不能访问实例成员;实例方法没有这个限制。
构造方法?
类的构造方法的作用是什么?
- 完成对象的初始化工作。
如果一个类没有声明构造方法,该程序能正确执行吗?
如果一个类没有声明构造方法,也可以执行!因为一个类即使没有声明构造方法也会有默认的不带参数的构造方法。如果我们自己添加了类的构造方法(无论是否有参),Java 就不会再添加默认的无参数的构造方法了,我们一直在不知不觉地使用构造方法,这也是为什么我们在创建对象的时候后面要加一个括号(因为要调用无参的构造方法)。如果我们重载了有参的构造方法,记得都要把无参的构造方法也写出来(无论是否用到),因为这可以帮助我们在创建对象的时候少踩坑。
构造方法有哪些特点?是否可被 override?
- 构造方法特点如下:
- 名字与类名相同。
- 没有返回值,但不能用 void 声明构造函数。
- 生成类的对象时自动执行,无需调用。
- 构造方法不能被 override(重写),但是可以 overload(重载),所以你可以看到一个类中有多个构造函数的情况。
构造方法与普通方法区别?🌟
- 命名:构造方法名和类名相同;普通方法名不能与类名相同。
- 返回值:构造方法名前没有返回值类型的声明,也不能用return返回值;普通方法有返回类型可以为void也可以指定其他类型。
- 当没有指定构造方法时,系统默认提供无参构造方法,如果指定了构造方法,系统就不会再为我们提供默认的无参构造方法了。如果想使用无参构造方法必须自己定义
- 作用:
- 构造方法是用来创建对象时初始化对象,即为对象成员变量赋初始值;
- 普通方法是根据需要,自己编码完成项目所需功能。
- 调用:
- 构造方法在初始化对象时自动执行,不需要调用,当一个类中存在多个构造方法时,java编译系统会自动按照初始化时最后面括号的参数个数以及参数类型来自动一一对应,完成构造函数的调用;
- 普通方法需通过对象名来调用。
注解
- Annotation (注解)是JavaSE5.0中新增功能。可以理解为为类、方法和成员变量做标记,这种标记可以在编译、类加载、运行时被读取,并执行相应的处理。
注解的解析方法有哪几种?
注解的操作中经常需要进行解析,注解的解析就是判断是否存在注解,存在注解就解析出内容。
- 注解的解析就是判断是否存在注解,存在注解就解析出内容:
- 编译期 直接扫描 :编译器在编译 Java 代码的时候扫描对应的注解并处理。
- 比如某个方法使用
@Override
注解,编译器在编译的时候就会检测当前的方法是否重写了父类对应的方法。
- 比如某个方法使用
- 运行期通过反射处理 :像框架中自带的注解(比如 Spring 框架的 @Value 、@Component)都是通过反射来进行处理的。
- 编译期 直接扫描 :编译器在编译 Java 代码的时候扫描对应的注解并处理。
3、面向对象基础
面向对象、面向过程、面向切面、面向接口区别?🌟
- 面向过程:是以过程为中心,是把解决问题的过程拆成一个个方法,通过一个个方法的执行解决问题。
- 面向对象:是以对象为中心,会先抽象出对象,然后用对象执行方法的方式解决问题。
- 面向切面:在程序运行时动态地将某段代码切入到指定方法位置进行的编程方式。
- AOP与OOP相辅相成。面向对象注重业务单元逻辑的划分,AOP针对的是处理过程中的某个步骤或阶段,AOP将面向对象过程中,把业务中重复的部分截取出来放到单独的类中,这样就大大减少了面向对象过程中重复的运行负载,提高了可重用性。
- 面对接口:面对接口规范了对象的属性和方法,是面对对象的一部分。
创建一个对象用什么运算符?对象实体与对象引用有何不同?
new 运算符,new 创建对象实例(对象实例在堆内存中),对象引用指向对象实例(对象引用存放在栈内存中)。
一个对象引用可以指向 0 个或 1 个对象(一根绳子可以不系气球,也可以系一个气球);一个对象可以有 n 个引用指向它(可以用 n 条绳子系住一个气球)。
对象相等和引用相等的区别?
- 对象的相等:一般是比较 内存中存放的内容 是否相等。
- 引用的相等:一般是比较 指向的内存地址 是否相等。
面向对象三大特征?封装、继承、多态🌟
- 封装:封装就是把对象的属性隐藏在对象内部,不允许外部对象直接访问对象的内部信息。但是可以提供 (可以被外界访问的)方法 来访问。(通常将成员变量私有、提供方法进行暴露)
- 继承:
- 子类拥有父类所有的属性和方法(包括私有属性和私有方法),但是父类中的私有属性和方法,子类是无法访问的,只是拥有。
- 子类也可以对父类进行扩展,拥有自己属性和方法。
- 多态:一个对象具有多种状态。
- 3.1 对象类型和引用类型之间具有继承(类)/实现(接口)的关系;
引用类型变量发出的方法调用的到底是哪个类中的方法,必须在程序运行期间才能确定;
多态不能调用“只在子类存在但在父类不存在”的方法;
如果子类重写了父类的方法,真正执行的是子类覆盖的方法,如果子类没有覆盖父类的方法,执行的是父类的方法。 - 3.2 多态的具体体现:
- 1)方法的多态体现在 方法重载和方法重写上:
- 方法重载(overload):重载发生在同一个类中,重载就是一个类中 多个重名方法 根据不同的传参执行不同的逻辑处理。
- 方法重写(override):方法重写发生在子类中,方法重写是子类重写父类的某些方法,重写后,再调用这个方法时,就是执行子类中的过程了。
- 2)对象的多态就像
List list = new ArrayList()
,父类引用指向子类对象,编译时是父类类型,运行时时子类类型。
编译类型看 = 左边;运行类型看 = 右边。
- 1)方法的多态体现在 方法重载和方法重写上:
- 3.1 对象类型和引用类型之间具有继承(类)/实现(接口)的关系;
封装?
- 封装步骤:通常将成员变量私有、提供方法进行暴露。
- 封装作用:提高业务功能设计的安全性,提高程序逻辑性和开发效率。
Java怎么体现多态?/ 重载重写?🌟
- 方法的多态体现在方法重载和方法重写上:
- 方法重载(
overload
):重载发生在同一个类中,重载就是一个类中 多个重名方法 根据不同的传参执行不同的逻辑处理(就是一个类中有多个函数的函数名相同,但是它们的参数不同。它们是不同的函数,只是功能类似)(编译时多态) - 方法重写(
override
):方法重写发生在子类中,方法重写是子类重写父类的某些方法,重写后,再调用这个方法时,就是执行子类中的过程了。(运行时多态)
- 方法重载(
- 对象的多态就像
List list = new ArrayList()
,父类引用指向子类对象,编译时是父类类型,运行时时子类类型。
编译类型看 = 左边;运行类型看 = 右边。
多态解决了什么问题?
- 多态特性能提高代码的可扩展性和复用性。
- 为什么这么说呢?我们回过头去看讲解多态特性的时候,举的第二个代码实例(Iterator 的例子)。在那个例子中,我们利用多态的特性,仅用一个 print() 函数就可以实现遍历打印不同类型(Array、LinkedList)集合的数据。当再增加一种要遍历打印的类型的时候,比如 HashMap,我们只需让 HashMap 实现 Iterator 接口,重新实现自己的 hasNext()、next() 等方法就可以了,完全不需要改动 print() 函数的代码。所以说,多态提高了代码的可扩展性。如果我们不使用多态特性,我们就无法将不同的集合类型(Array、LinkedList)传递给相同的函数(print(Iterator iterator) 函数)。我们需要针对每种要遍历打印的集合,分别实现不同的 print() 函数,比如针对 Array,我们要实现 print(Array array) 函数,针对 LinkedList,我们要实现 print(LinkedList linkedList) 函数。而利用多态特性,我们只需要实现一个 print() 函数的打印逻辑,就能应对各种集合数据的打印操作,这显然提高了代码的复用性。除此之外,多态也是很多设计模式、设计原则、编程技巧的代码实现基础,比如策略模式、基于接口而非实现编程、依赖倒置原则、里式替换原则、利用多态去掉冗长的 if-else 语句等等。
接口、抽象类的共同点和区别、作用?设计什么的时候会用到这些?🌟
- 共同点:
- 1)都不可以实例化;
- 2)都可以包含抽象方法;(接口中默认隐式声明为
public abstract
) - 3)都可以有默认实现的方法(Java 8 可以用 default 关键字在接口中定义默认方法)。
- 区别:
- 1)一个类只能继承一个类,但可以实现多个接口;
- 2)抽象类中可以没有抽象方法;接口中只能存在抽象方法(接口可以看作是比抽象类更抽象的类);
- 3)接口中成员变量只能是
public static final
类型的(公共的静态常量),不能被修改 且 有初始值;抽象类的成员变量可以是各种类型。默认default
,可在子类中重新定义重新赋值。 - 4)实现了某个接口就拥有对应的行为;抽象类主要用于代码复用,强调的是所属关系。
- 5)接口中不能含有静态代码块以及静态方法,而抽象类可以有静态代码块和静态方法;
- 作用:
- 何时用抽象类、何时用接口?
- 1)如果想拥有一些方法,并且这些方法有默认实现,就使用抽象类。
- 2)一个类可以实现多个接口,如果注重扩展功能,就使用接口。
- 设计什么的时候会用到这些?
- 比如说门都有
open()
和close()
两个动作,有的门可以需要有报警alarm()功能,但是open()和close()是门固有的行为,而报警alarm()
是附加行为,并不是每个门都有的行为。所以把门定义为抽象类,里面定义open()和close()方法,把报警单独设为一个接口。报警门继承门类并且实现alarm接口。- 将这三个功能都放在接口里面,需要用到报警功能的类就需要实现这个接口中的open( )和close( ),也许这个类根本就不具备open( )和close( )这两个功能,比如火灾报警器。
- 自己:比如说开发时,在Service层先定义接口,在接口中定义要实现的方法,在实现类中实现对应接口。
- 这样可以
- 比如说门都有
深拷贝和浅拷贝区别?什么是引用拷贝?
拷贝一般分为两大类 引用拷贝 和 对象拷贝,我们通常讲的深拷贝和浅拷贝都属于对象拷贝。
- 区别:
- 浅拷贝:浅拷贝会创建一个新的对象(区别于引用拷贝的一点),如果属性是基本类型(int,double,long,boolean等),拷贝的就是基本类型的值;如果属性是引用类型,拷贝的就是内存地址,共享一块内存,如果在拷贝对象中改变了这个地址,就会影响源对象。
- 深拷贝 :深拷贝会创建一个新对象,会将源对象各个属性的值拷贝过来(是值不是引用)。拷贝对象和源对象不共享内存,修改拷贝对象不会影响源对象。
- 引用拷贝:是通过
=
地址赋值。- 引用拷贝就是拷贝引用地址,都指向同一个对象。这种方式不会生成新的对象,只会在原对象上增加了一个新的对象引用。
4、Java常见类
Object 类的常见方法?🌟
getClass()
方法:获取运行时类型,返回值为Class对象。hashCode()
:返回对象的哈希码,主要使用在哈希表中,比如 JDK 中的HashMap。equals()
:比较两个对象的内存地址是否相等。String类型对这个方法进行了重写,用于比较字符串的值是否相等。clone()
:创建并返回当前对象的一份拷贝。(浅拷贝)toString()
:返回一个String字符串,用于描述当前对象的信息,可以重写返回对自己有用的信息,默认返回的是当前对象的类名+hashCode的16进制数字。notify()
:多线程时用到的方法,唤醒该对象的等待集合中的某个线程。notifyAll()
:多线程时用到的方法,唤醒该对象的等待集合中的所有线程。wait()
:多线程时用到的方法,让当前线程释放持有的锁进入等待状态。当其他持锁线程调用此对象的notify()
方法或notifyAll()
方法,会按照一定规则唤醒等待集合中的等待线程。
Object 类是一个特殊的类,是所有类的父类。它主要提供了以下 11 个方法:
/**
* native 方法,用于返回当前运行时对象的 Class 对象,使用了 final 关键字修饰,故不允许子类重写。
*/
public final native Class<?> getClass()
/**
* native 方法,用于返回对象的哈希码,主要使用在哈希表中,比如 JDK 中的HashMap。
*/
public native int hashCode()
/**
* 用于比较 2 个对象的内存地址是否相等,String 类对该方法进行了重写以用于比较字符串的值是否相等。
*/
public boolean equals(Object obj)
/**
* naitive 方法,用于创建并返回当前对象的一份拷贝。
*/
protected native Object clone() throws CloneNotSupportedException
/**
* 返回类的名字实例的哈希码的 16 进制的字符串。建议 Object 所有的子类都重写这个方法。
*/
public String toString()
/**
* native 方法,并且不能重写。唤醒一个在此对象监视器上等待的线程(监视器相当于就是锁的概念)。如果有多个线程在等待只会任意唤醒一个。
*/
public final native void notify()
/**
* native 方法,并且不能重写。跟 notify 一样,唯一的区别就是会唤醒在此对象监视器上等待的所有线程,而不是一个线程。
*/
public final native void notifyAll()
/**
* native方法,并且不能重写。暂停线程的执行。注意:sleep 方法没有释放锁,而 wait 方法释放了锁 ,timeout 是等待时间。
*/
public final native void wait(long timeout) throws InterruptedException
/**
* 多了 nanos 参数,这个参数表示额外时间(以毫微秒为单位,范围是 0-999999)。 所以超时的时间还需要加上 nanos 毫秒。。
*/
public final void wait(long timeout, int nanos) throws InterruptedException
/**
* 跟之前的2个wait方法一样,只不过该方法一直等待,没有超时时间这个概念
*/
public final void wait() throws InterruptedException
/**
* 实例被垃圾回收器回收的时候触发的操作
*/
protected void finalize() throws Throwable { }
== 和 equals() 的区别?🌟
==
:- 对于基本数据类型,==比较的是值;
- 对于引用类型,==比较的是对象的内存地址。
equals()
:只能用来判断对象是否相等,不能用于判断基本数据类型的变量。equals()
方法定义在Object
类中,所有类都有equals()
方法。- equals() 方法存在两种使用情况:
- 类没有重写 equals()方法 :比较对象的内存地址是否相等。等价于通过“==”比较这两个对象,使用的默认是 Object类equals()方法。
- 类重写了 equals()方法 :一般我们都重写 equals()方法来比较两个对象中的属性是否相等;若它们的属性相等,则返回 true(即,认为这两个对象相等)。
hashCode() 作用?
- 用来获取对象的哈希码(int 整数),也称散列码。(确定对象在哈希表中的索引位置)
hashCode()
方法定义在Object
类中,所有类都有hashCode()
方法。- 另外需要注意的是: Object 的 hashCode() 方法是本地方法,也就是用 C 语言或 C++ 实现的,该方法通常用来将对象的内存地址转换为整数之后返回。
- 散列表存储的是键值对(key-value),它的特点是:能根据“键”快速的检索出对应的“值”。这其中就利用到了散列码!(可以快速找到所需要的对象)
为什么要有 hashCode()?
-
以
HashSet
如何检查重复为例来说明为什么要有hashCode
:- 把对象加入
HashSet
时- 1)HashSet会先计算对象的
hashCode
值; - 2)如果存在
hashCode
值相等的对象,就再调用equals()
方法检查对象是否真的相同。- 如果相同HashSet就不存;
- 如果不同才会存,这样就大大减少了
equals()
的次数,提高了执行速度。
- 1)HashSet会先计算对象的
- 把对象加入
-
其实hashCode()和equals()都是用于比较两个对象是否相等。
- 问:那为什么 JDK 还要同时提供这两个方法呢hashcode equals?
- 答:因为hashCode()可以减少我们的查找成本,先通过hashCode()方法判断是否已有hashCode值相等的对象,如果有再调用equals()方法判断对象是否真的相同,这样大大减少了equals()的次数,提高了执行速度。
- 问:那为什么不只提供 hashCode() 方法呢?
- 答:因为两个对象的hashCode 值相等并不代表两个对象就相等。
- 问:那为什么两个对象有相同的 hashCode 值,它们也不一定是相等的?
- 答:为 hashCode() 所使用的哈希算法也许刚好会让多个对象传回相同的哈希值。越糟糕的哈希算法越容易碰撞,但这也与数据值域分布的特性有关(所谓哈希碰撞也就是指的是不同的对象得到相同的 hashCode )。
- 总结:
- 如果两个对象的
hashCode 值
不相等,我们就可以直接认为这两个对象不相等。 - 如果两个对象的
hashCode 值
相等,那这两个对象不一定相等(哈希碰撞)。 - 如果两个对象的
hashCode 值
相等并且equals()
方法也返回 true,我们才认为这两个对象相等。
- 如果两个对象的
为什么重写 equals() 时必须重写 hashCode() 方法?
- 因为两个相等的对象的 hashCode 值必须是相等。也就是说如果 equals() 方法判断两个对象是相等的,那这两个对象的 hashCode 值也要相等。
- 如果重写 equals() 时没有重写 hashCode() 方法的话就可能会导致
equals() 方法
判断是相等的两个对象,hashCode 值却不相等。
Integer
Integer缓存池?🌟
Integer 缓存是 Java 5 中引入的一个有助于节省内存、提高性能的特性。
Integer
中有个静态内部类IntegerCache
,里面有个cache[]数组
,也就是Integer常量池,常量池的大小为一个字节(-128~127)
- 当创建
Integer对象
时,如果不使用new Integer(int i)
语句,并且大小在-128~127
之间,对象存放在Integer常量池中。 - 例如:
Integer a = 10
;其实底层调用的是Integer.valueOf()
方法。这也是自动装箱的代码实现。JAVA将基本类型自动转换为包装类的过程称为自动装箱(autoboxing)。
String
String、StringBuffer、StringBuilder 的区别?🌟
- 可变性:
String
是不可变的;- 每次对
String
类型改变,都会生成一个新的String
对象,然后指向新的String
对象。
- 每次对
StringBuffer、StringBuilder
是可变的,StringBuffer、StringBuilder都继承自AbstractStringBuilder
类,这个类提供了修改字符串的方法比如append(...)
方法。StringBuffer
、StringBuilder
是对自身进行改变,不生成新的对象。
- 线程安全性:
String
是不可变的,可以理解为常量,线程安全。StringBuffer
是线程安全的,因为对方法加了同步锁,所以线程安全。StringBuilder
没有对方法加锁同步,线程不安全。StringBuilder
的性能要大于StringBuffer
。
- 应用场景:
- 操作少量的数据: 适用 String
- 单线程操作字符串缓冲区下操作大量数据: 适用 StringBuilder
- 多线程操作字符串缓冲区下操作大量数据: 适用 StringBuffer
为什么String在方法区不在堆上?🌟
- 方法区的数据和堆里面的数据都是共享的,字符串是常量嘛,定位就是可以共享的,但是它不是对象,所以存储在了方法区,另外,静态属性也存储在方法区,因为静态属性是通过类来调用的,所以不是属于对象的,所以它也存储在方法区
String 为什么是不可变的?❓
- 因为String类的值是保存在
value[]
数组中的,并且被private final
修饰了。private
修饰,表明外部的类是访问不到value
的;final
修饰,表明value
的引用是不会被改变的,而value只会在String的构造函数中被初始化,而且并没有其他方法可以修改value数组中的值,保证了value的引用和值都不会发生变化;所以说String是不可变的。
public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
private final char value[];
//...
}
有什么办法能够改变String?
- 虽然value数组被final修饰了,但只是说明value的引用不能改变,但value指向的数组其实是可以被修改的。
- 可以通过反射获取String对象中的value属性, 进而改变通过获得的value引用改变数组的结构。
例子:
public static void main(String[] args) throws Exception {
String str = "Hello World";
System.out.println("修改前的str:" + str);
System.out.println("修改前的str的内存地址" + System.identityHashCode(str));
// 获取String类中的value字段
Field valueField = String.class.getDeclaredField("value");
// 改变value属性的访问权限
valueField.setAccessible(true);
// 获取str对象上value属性的值
char[] value = (char[]) valueField.get(str);
// 改变value所引用的数组中的字符
value[3] = '?';
System.out.println("修改后的str:" + str);
System.out.println("修改前的str的内存地址" + System.identityHashCode(str));
}
运行结果:
修改前的str:Hello World
修改前的str的内存地址1922154895
修改后的str:Hel?o World
修改前的str的内存地址1922154895
可以看到str的字符串序列已经被改变了,但是str的内存地址还是没有改变。
字符串拼接用“+” 还是 StringBuilder?
- 通过
“+”
的字符串拼接方式:实际上是通过StringBuilder
调用append()
方法实现的,拼接完成后通过toString()
得到一个String
对象。但是编译器不会创建单个StringBuilder
来复用,从而会导致创建过多的StringBuilder
对象。 - 直接使用
StringBuilder
对象进行字符串拼接的话,就不会存在这个问题了。
String的equals() 和 Object的equals() 有何区别?
String
中的equals()
方法是被重写过的,比较的是 String 字符串的值是否相等。Object
的equals()
方法是比较的对象的内存地址。
字符串常量池?🌟
字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。
String str = new String(“java”);问创建了几个对象?🌟
- 创建了两个对象,一个对象存储在堆中,一个对象存储在字符串常量池中。
注意:"java"会存储在堆和方法区里面,看下图的红色区域。实际上:理解的话就理解成创建了两个Java,实际运行的时候就只有一个。由于产生了两个对象,这样的创建字符串的方式就会浪费空间
String s1 = new String(“abc”);这句话创建了几个字符串对象?🌟
- 会创建 1 或 2 个字符串对象。
- new String 会先去常量池中判断有没有此字符串,如果有则只在堆上创建一个字符串并且指向常量池中的字符串;如果字符串常量池中不存在字符串对象“abc”的引用,那么会在堆中创建 2 个字符串对象
- 如果常量池中没有此字符串,则会创建 2 个对象,先在常量池中新建此字符串,然后把此引用返回给堆上的对象。
链接: new 字符串创建了几个对象?
intern 方法有什么作用?
- String.intern() 是一个 native(本地)方法,其作用是将指定的字符串对象的引用保存在字符串常量池中,可以简单分为两种情况:
- 如果字符串常量池中保存了对应的字符串对象的引用,就直接返回该引用。
- 如果字符串常量池中没有保存对应的字符串对象的引用,那就在常量池中创建一个指向该字符串对象的引用并返回。
5、异常
Java 异常类层次结构图概览 :
Exception 和 Error 有什么区别?🌟笔试
在 Java 中,所有的异常都有一个共同的祖先 java.lang 包中的 Throwable 类。Throwable 类有两个重要的子类:
Exception
:程序本身可以处理的异常,可以通过catch
来进行捕获。Exception
又可以分为:Checked Exception
(受检查异常,必须处理) 和Unchecked Exception
(不受检查异常,可以不处理)
Error
:属于程序无法处理的错误 ,不建议通过catch捕获 。例如 Java 虚拟机运行错误(Virtual MachineError)、虚拟机内存不够错误(OutOfMemoryError)、类定义错误(NoClassDefFoundError)等 。这些异常发生时,Java 虚拟机(JVM)一般会选择线程终止。
Checked Exception 和 Unchecked Exception 有什么区别?
Checked Exception
(受检查异常,必须处理) :Java 代码在编译过程中,如果受检查异常没有被catch
或者throws
关键字处理的话,就没办法通过编译。- 除了RuntimeException及其子类以外,其他的Exception类及其子类都属于受检查异常 。常见的受检查异常有:
IO 相关的异常、ClassNotFoundException 、SQLException...
。
- 除了RuntimeException及其子类以外,其他的Exception类及其子类都属于受检查异常 。常见的受检查异常有:
Unchecked Exception
(不受检查异常,可以不处理):Java 代码在编译过程中 ,不处理也可以正常通过编译。通常是可以编码避免的逻辑错误。,可以根据需要进行捕获。RuntimeException 及其子类
都统称为非受检查异常,常见的有(建议记下来,日常开发中会经常用到):- NullPointerException(空指针错误)
- IllegalArgumentException(参数错误比如方法入参类型错误)
- NumberFormatException(字符串转换为数字格式错误,IllegalArgumentException的子类)
- ArrayIndexOutOfBoundsException(数组越界错误)
- ClassCastException(类型转换错误)
- ArithmeticException(算术错误)
- SecurityException (安全错误比如权限不够)
- UnsupportedOperationException(不支持的操作错误比如重复创建同一用户)
- …
Throwable 类常用方法有哪些?
- String getMessage(): 返回异常发生时的简要描述
- String toString(): 返回异常发生时的详细信息
- String getLocalizedMessage(): 返回异常对象的本地化信息。使用 Throwable 的子类覆盖这个方法,可以生成本地化信息。如果子类没有覆盖该方法,则该方法返回的信息与 getMessage()返回的结果相同
- void printStackTrace(): 在控制台上打印 Throwable 对象封装的异常信息
try-catch-finally 如何使用?
- try块 : 用于捕获异常。其后可接零个或多个 catch 块,如果没有 catch 块,则必须跟一个 finally 块。
- catch块 : 用于处理 try 捕获到的异常。
- finally 块 : 无论是否捕获或处理异常,finally 块里的语句都会被执行,除非JVM退出。当在 try 块或 catch 块中遇到 return 语句时,finally 语句块将在方法返回之前被执行。
- Note:不要在 finally 语句块中使用 return! 当 try 语句和 finally 语句中都有 return 语句时,try 语句块中的 return 语句会被忽略。这是因为 try 语句中的 return 返回值会先被暂存在一个本地变量中,当执行到 finally 语句中的 return 之后,这个本地变量的值就变为了 finally 语句中的 return 返回值。
finally 中的代码一定会执行吗?
不一定。
- finally 之前虚拟机被终止运行的话,finally 中的代码就不会被执行。
- 程序所在的线程死亡。
- 关闭 CPU。
如何使用 try-with-resources 代替try-catch-finally?
finally虽然可以用于释放资源,但是释放资源的代码过于繁琐
利用try-catch-resource 自动释放资源、代码简洁。
- 在可以在try-with-resources块中声明多个资源,多个资源用分号分隔,任何 catch 或 finally 块在声明的资源关闭后运行。
5、泛型
什么是泛型?有什么作用?
- 通过泛型参数可以指定传入的对象类型,编译器可以对泛型参数检测。比如 ArrayList persons = new ArrayList() 这行代码就指明了该 ArrayList 对象只能传入 Persion 对象,如果传入其他类型的对象就会报错。
- 原生 List 返回类型是 Object ,需要手动转换类型才能使用,使用泛型后编译器自动转换。
泛型的使用方式有哪几种?
- 泛型一般有三种使用方式:泛型类、泛型接口、泛型方法。
项目中哪里用到了泛型?
- 自定义通用返回结果Result,通过参数T 可根据具体的返回类型 动态指定结果的数据类型。
- 定义 Excel 处理类 ExcelUtil 用于动态指定 Excel 导出的数据类型
- 构建集合工具类(参考 Collections 中的 sort, binarySearch 方法)。
- …
6、反射?
通过反射你可以获取任意一个类的所有属性和方法,还可以调用这些方法和属性。
链接: 反射
反射如何创建一个对象?
- 两种方式:
- 获取该类的Class对象;再调用
Class
对象的newInstance()
方法创建该Class对象的实例,此时该Class对象必须要有无参数的构造方法。 - 获取该类的Class对象;利用
getConstructor()
来获取指定的构造方法;再调用Constructor
的newInstance()
方法创建对象类的实例。如果这个构造方法被私有化起来,就需要把访问设置为true
(setAccessible(true)
);
- 方式一:使用
Class
对象的newInstance()
方法创建该Class对象的实例,此时该Class对象必须要有无参数的构造方法
public class User {
@Override
public String toString() {
return "User对象创建成功!";
}
//利用反射创建对象
public static void main(String[] args) {
Class<User> c = User.class;
User user = c.newInstance();
System.out.println(user);
}
}
- 方式二:获取该类的Class对象;利用
getConstructor()
来获取指定的构造方法;再调用Constructor
的newInstance()
方法创建对象类的实例。如果这个构造方法被私有化起来,就需要把访问设置为true
(setAccessible(true)
);
public class User {
@Override
public String toString() {
return "User对象创建成功!";
}
//反射
public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
Class<User> c = User.class;
Constructor<User> constructor = c.getDeclaredConstructor();
User user = constructor.newInstance();
System.out.println(user);
}
}
反射的优缺点?
- 优点:
- 1)在运行时得到一个类的全部成分然后操作。
- 2)可以破坏封装性。(很突出)
- 3)可以破坏泛型的约束性。(很突出)
- 4)更重要的用途是适合:做Java高级框架
- 5)基本上主流框架都会基于反射设计一些通用技术功能。
- 缺点:
- 1)会增加安全问题:反射是运行时技术,绕过了编译阶段。比如说 会无视泛型参数的安全检查。因为 泛型的安全检查在编译时期,编译器会对泛型参数进行检测。
- 2)性能问题:使用反射基本上是一种解释操作,用于字段和方法接入时要远慢于直接代码。因此反射机制主要应用在对灵活性和扩展性要求很高的系统框架上,普通程序不建议使用。
- 3)使用反射会模糊程序内内部逻辑:程序员希望在源代码中看到程序的逻辑,反射等绕过了源代码的技术,因而会带来维护问题。反射代码比相应的直接代码更复杂。
至于执行效率的话,还可以,因为它是一种强类型语言,执行效率不错。不过,建议将反射过后,保存进 cache中。
如果构造器是非public的,需要打开权限(暴力反射),然后再创建对象(newInstance);
如果成员变量是非public的,需要打开权限(暴力反射),然后再取值(get)、赋值(set);
如果某成员方法是非public的,需要打开权限(暴力反射),然后再触发执行(invoke):
setAccessible(boolean)
反射的应用场景?
Spring/Spring Boot、MyBatis 等等框架中都大量使用了反射机制。
为什么你使用 Spring 的时候 ,一个@Component
注解就声明了一个类为 Spring Bean 呢?为什么你通过一个 @Value
注解就读取到配置文件中的值呢?究竟是怎么起作用的呢?
这些都是因为你可以基于反射分析类,然后获取到类/属性/方法/方法的参数上的注解。你获取到注解之后,就可以做进一步的处理。
7、IO
- I/O(Input/Outpu) 即输入/输出 ;
File类 --> 定位文件以及操作文件本身
IO流 --> 读写文件数据
xmind!!!
IO 基础相关
- 字节输入流
InputStream(抽象类)
:以字节形式读入到内存- 实现类:
- 1)
FileInputStream
字节输入流:可指定文件路径;可以每次读取一个字节或字节数组,也可一次性读完全部字节。 - 2)
BufferedInputStream
字节缓冲输入流:可以把低级的字节输入流包装成一个高级的缓冲字节输入流管道,从而提高字节输入流读数据的性能。
它读取数据到内存时,不是一个一个字节去读取,而是先将读到的字节存放在缓冲区,从内部缓冲区中读取字节。这样大幅减少了IO次数,提高读取效率。(这个缓冲区实际就是一个字节数组) - 3)
ObjectInputStream
对象字节输入流:用于输入流中读取Java对象(反序列化,可以把二进制字节流转为Java对象)。
- 1)
- 实现类:
- 字节输出流
OutputStream(抽象类)
:以字节写出到磁盘文件或网络中去- 实现类:
- 1)
FileOutputStream
字节输出流:可指定文件路径,可以直接输出一个字节或字节数组。 - 2)
BufferedOutputStream
字节缓冲输出流:先将要写入的字节存入缓存区,并从内部缓冲区中单独写入字节。 - 3)
ObjectOutputStream
对象字节输出流:将对象写入到输出流(序列化,可以把对象转为二进制字节流)。
- 1)
- Note:
flush()
:刷新输出流;close()
:关闭输出流。
- 实现类:
序列化和反序列化的类都必须实现Serializable
接口,对象中如果有属性不想被序列化,使用transient
修饰。
字符流适合读文本内容
-
Reader
字符输入流- 1)
FileReader
: - 2)字符缓冲输入流
BufferedReader
:可以把低级的字符输入流包装成一个高级的缓冲字符输入流管道,从而提高字符输入流读数据的性能。(装饰器(Decorator)模式)
- 1)
-
Writer
字符输出流- 1)
FileWriter
- 2)字符缓冲输出流
BufferedWriter
:可以把低级的字符输出流包装成一个高级的缓冲字符输出流管道,从而提高字符输出流写数据的性能。(装饰器(Decorator)模式)
- 1)
-
转换流(解决文件编码与代码编码不一致问题):用到了适配器(Adapter Pattern)模式用于解决由于接口不匹配而造成的类不能交互的问题
- 字符输入转换流
InputStreamReader
:InputStreamReader(InputStream is , String charset)
:把原始字节流按照指定编码转换成字符输入流,这样字符流中的字符就不乱码了 - 字符输出转换流
OutputStreamWriter
:OutputStreamWriter(OutputStream os , String charset)
把原始的字节输出流按照指定编码转换为字符输出流
- 字符输入转换流
InputStreamreader
是字节流转换为字符流的桥梁,子类FileReader是基于该基础上的封装,可以直接操作字符文件。
Java IO 流了解吗?
IO 即 Input/Output,输入和输出。数据输入到计算机内存的过程即输入,反之输出到外部存储(比如数据库,文件,远程主机)的过程即输出。数据传输过程类似于水流,因此称为 IO 流。IO 流在 Java 中分为输入流和输出流,而根据数据的处理方式又分为字节流和字符流。
Java IO 流的 40 多个类都是从如下 4 个抽象类基类中派生出来的。
InputStream / Reader
: 所有的输入流的基类,前者是字节输入流,后者是字符输入流。
OutputStream / Writer
: 所有输出流的基类,前者是字节输出流,后者是字符输出流。
I/O 流为什么要分为字节流和字符流呢?
- 个人认为主要有两点原因:
- 字符流是由 Java 虚拟机将字节转换得到的,这个过程还算是比较耗时;
- 如果我们不知道编码类型的话,使用字节流的过程中很容易出现乱码问题。
序列化、反序列化以及作用?🌟
如果我们需要持久化 Java 对象比如将 Java 对象保存在文件中,或者在网络传输 Java 对象,这些场景都需要用到序列化。
-
- 序列化: 把对象转换成二进制字节流的过程,就叫做对象的序列化。
- 反序列化:把在序列化过程中所生成的二进制字节流转换成对象的过程,就叫做对象的反序列化。
- 作用:
- 序列化作用:把对象转换成二进制字节流,以便于在网络上传输或者保存在文件、缓存数据库、内存中。
- 反序列化作用:把在序列化过程中所生成的二进制字节流转换成对象。
如何实现序列化?
- 对象须实现
Serializable
接口,JVM 就会把Object
对象按默认格式序列化。
序列化和反序列化用到的流?
- 序列化用到的:
- 1)对象字节输出流:
ObjectOutputStream(OutputStream out)
--> 把低级字节输出流包装成高级的对象字节输出流。 - 2)序列化方法(API):
writeObject(ObjectOutputStream out)
--> 对传入的对象进行序列化。
- 1)对象字节输出流:
- 反序列化用到的:
- 1)对象字节输入流:
ObjectInputStream(InputStream in)
--> 把低级字节输入流包装成高级的字节输入流。 - 2)反序列化方法(API):
readObject(ObjectInputStream in)
--> 把序列化生成的字节流反序列化为对象返回。
- 1)对象字节输入流:
如果有些字段不想进行序列化怎么办?
- 使用
transient
关键字修饰。- 作用是:防止实例中那些用此关键字修饰的的变量序列化;当对象被反序列化时,被
transient
修饰的变量值不会被持久化和恢复。
- 作用是:防止实例中那些用此关键字修饰的的变量序列化;当对象被反序列化时,被
- 关于 transient 还有几点注意:
- transient 只能修饰变量,不能修饰类和方法。
- transient 修饰的变量,在反序列化后变量值将会被置成类型的默认值。例如,如果是修饰 int 类型,那么反序列后结果就是 0。
- static 变量因为不属于任何对象(Object),所以无论有没有 transient 关键字修饰,均不会被序列化。
实际开发中有哪些用到序列化和反序列化的场景?
- 对象在进行网络传输(比如远程方法调用 RPC 的时候)之前需要先被序列化,接收到序列化的对象之后需要再进行反序列化;
- 将对象存储到文件中的时候需要进行序列化,将对象从文件中读取出来需要进行反序列化。
- 将对象存储到缓存数据库(如 Redis)时需要用到序列化,将对象从缓存数据库中读取出来需要反序列化。
IO设计模式总结
装饰器(Decorator)模式
- 装饰器(Decorator)模式可以在不改变原有对象情况下拓展功能。
- 是继承关系的一种替代方案,可以在不创建更多子类的情况下拓展功能;
- 它是通过创建一个包装对象,装饰真实的对象,装饰模式以客户端透明的方式动态的给对象附加上更多的责任。
- 装饰器类需要跟原始类继承相同的抽象类或者实现相同的接口。(各种字符流间装饰,各种字节流间装饰)
- 例如:将
FileInputStream
字节流包装为BufferedInputStream
过程就是装饰的过程。
BufferedInputStream bis = new BufferedInputStream(new FileInputStream(fileName));
适配器模式
- 适配器(Adapter Pattern)模式用于解决由于接口不匹配而造成的类不能交互的问题。
- IO流中的字节流和字符流的接口不同,它们之间可以协调工作就是基于适配器模式来做的。
(通过适配器,我们可以将字节流对象适配成一个字符流对象,这样我们可以直接通过字节流对象来读取或者写入字符数据。) InputStreamReader
和OutputStreamWriter
就是两个适配器(Adapter), 同时,它们两个也是字节流和字符流之间的桥梁。InputStreamReader
使用StreamDecoder (流解码器)
对字节进行解码,实现字节流 到 --> 字符流的转换。OutputStreamWriter
使用StreamEncoder(流编码器)
对字符进行编码,实现字符流 到 --> 字节流的转换。
- IO流中的字节流和字符流的接口不同,它们之间可以协调工作就是基于适配器模式来做的。
- 例子:
// InputStreamReader 是适配器,FileInputStream 是被适配的类
InputStreamReader isr = new InputStreamReader(new FileInputStream(fileName), "UTF-8");
// BufferedReader 增强 InputStreamReader 的功能(装饰器模式)
BufferedReader bufferedReader = new BufferedReader(isr);
装饰器和适配器区别?
- 装饰器模式侧重于增强原对象的功能;装饰类和原始类需要继承相同的类或实现相同的接口;装饰器模式支持对原始类嵌套多个装饰器。
- 适配器模式侧重于解决 由于接口不匹配导致类不能交互的问题,适配器是适配者不需要继承相同的类或实现相同的接口;
工厂模式
- 工厂模式用于创建对象,是创建型模式,NIO 中大量用到了工厂模式。
- 比如
Files 类
的newInputStream(...) 方法
用于创建InputStream
对象(静态工厂)、 Paths 类的 get 方法创建 Path 对象(静态工厂)、ZipFileSystem 类(sun.nio包下的类,属于 java.nio 相关的一些内部实现)的 getPath 的方法创建 Path 对象(简单工厂)。
观察者模式
- NIO 中的文件目录监听服务使用到了观察者模式。
- NIO 中的文件目录监听服务基于 WatchService 接口和 Watchable 接口。WatchService 属于观察者,Watchable 属于被观察者。
Watchable 接口定义了一个用于将对象注册到 WatchService(监控服务) 并绑定监听事件的方法 register 。
IO模型
计算机结构
先从计算机结构的角度来解读一下 I/O。
根据冯.诺依曼结构,计算机结构分为 5 大部分:运算器、控制器、存储器、输入设备、输出设备。
输入设备(比如键盘)和输出设备(比如显示器)都属于外部设备。网卡、硬盘这种既可以属于输入设备,也可以属于输出设备。
输入设备向计算机输入数据;
输出设备接收计算机输出的数据。
- 从计算机结构的视角看, I/O 描述了计算机系统与外部设备之间通信的过程。
- 从应用程序的角度来解读一下 I/O:
- 1)根据大学里学到的操作系统相关的知识:为了保证操作系统的稳定性和安全性,一个进程的地址空间划分为 用户空间(User space) 和 内核空间(Kernel space ) 。
平常运行的应用程序都是运行在用户空间,只有内核空间才能进行系统态级别的资源有关的操作,比如文件管理、进程通信、内存管理等等。也就是说,我们想要进行 IO 操作,一定是要依赖内核空间的能力。
并且,用户空间的程序不能直接访问内核空间。 - 2)想要执行 IO 操作时,由于没有执行这些操作的权限,只能发起系统调用请求操作系统帮忙完成。从应用程序的视角来看的话,我们的应用程序对操作系统的内核发起 IO 调用(系统调用),操作系统负责的内核执行具体的 IO 操作。也就是说,我们的应用程序实际上只是发起了 IO 操作的调用而已,具体 IO 的执行是由操作系统的内核来完成的。当应用程序发起 I/O 调用后,会经历两个步骤:
- 内核等待 I/O 设备准备好数据
- 内核将数据从内核空间拷贝到用户空间。
- 1)根据大学里学到的操作系统相关的知识:为了保证操作系统的稳定性和安全性,一个进程的地址空间划分为 用户空间(User space) 和 内核空间(Kernel space ) 。
IO模型?
- 常见的IO模型有5种:同步阻塞 I/O、同步非阻塞 I/O、I/O 多路复用、信号驱动 I/O 和异步 I/O。
- 同步阻塞 IO 模型中,应用程序发起 read 调用后,会一直阻塞,直到内核把数据拷贝到用户空间。
- 同步非阻塞 I/O模型中,应用程序会反复发起 read 调用,等待数据从内核空间拷贝到用户空间的这段时间里,线程依然是阻塞的,直到在内核把数据拷贝到用户空间。
- 相比于同步阻塞 IO 模型,同步非阻塞 IO 模型确实有了很大改进。通过轮询操作,避免了一直阻塞。
- 存在问题:应用程序不断进行 I/O 系统调用轮询数据是否已经准备好的过程是十分消耗 CPU 资源的。
- I/O 多路复用模型:线程首先发起 select 调用,询问内核数据是否准备就绪 => 等内核把数据准备好了=>用户线程再发起 read 调用。read 调用的过程(数据从内核空间 拷贝到 -> 用户空间)还是阻塞的。
- 通过减少无效的系统调用,减少了对 CPU 资源的消耗。
- Java 中的 NIO ,有一个非常重要的选择器 ( Selector ) 的概念,也可以被称为 多路复用器。通过它,只需要一个线程便可以管理多个客户端连接。当客户端数据到了之后,才会为其服务。
- 信号驱动 I/O:
- 异步IO(AIO (Asynchronous I/O)):基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。
- 目前来说 AIO 的应用还不是很广泛。Netty 之前也尝试使用过 AIO,不过又放弃了。这是因为,Netty 使用了 AIO 之后,在 Linux 系统上的性能并没有多少提升。
- 简单总结一下 Java 中的 BIO、NIO、AIO。
BIO (Blocking I/O) 同步阻塞IO模型:
BIO 属于同步阻塞 IO 模型 。
- 同步阻塞 IO 模型中,应用程序发起 read 调用后,会一直阻塞,直到内核把数据拷贝到用户空间。
- 存在问题:在客户端连接数量不高的情况下,是没问题的。但是,当面对十万甚至百万级连接的时候,传统的 BIO 模型是无能为力的。因此,我们需要一种更高效的 I/O 处理模型来应对更高的并发量。
NIO (Non-blocking/New I/O) :
- Java 中的 NIO 可以看作是 I/O 多路复用模型。也有很多人认为,Java 中的 NIO 属于同步非阻塞 IO 模型。
- NIO 中的 N 可以理解为 Non-blocking,不单纯是 New。它是支持面向缓冲的,基于通道的 I/O 操作方法。
- 对于高负载、高并发的(网络)应用,应使用 NIO 。
- Java 中的 NIO ,有一个非常重要的选择器 ( Selector ) 的概念,也可以被称为 多路复用器。通过它,只需要一个线程便可以管理多个客户端连接。当客户端数据到了之后,才会为其服务。
AIO (Asynchronous I/O) 异步IO模型:
- 异步IO(AIO (Asynchronous I/O)):基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。
- 目前来说 AIO 的应用还不是很广泛。Netty 之前也尝试使用过 AIO,不过又放弃了。这是因为,Netty 使用了 AIO 之后,在 Linux 系统上的性能并没有多少提升。
二、Java内存模型(JMM)
Java内存模型(Java Memory Model,简称JMM)
Java内存模型是一种抽象的概念,并不是真实存在的,它描述的是一组规范或者规定。JVM运行程序的实体是线程,每一个线程都有自己私有的工作内存。Java内存模型中规定了所有变量都存储在主内存中,主内存是一块共享内存区域,所有线程都可以访问。但是线程对变量的读取赋值等操作必须在自己的工作内存中进行,在操作之前先把变量从主内存中复制到自己的工作内存中,然后对变量进行操作,操作完成后再把变量写回主内存。线程不能直接操作主内存中的变量,线程的工作内存中存放的是主内存中变量的副本。
并发编程三大特性
- 原子性、可见性、有序性
1)原子性?
- 一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
例子:银行账户转账问题
比如从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。试想一下,如果这2个操作不具备原子性,会造成什么样的后果。假如从账户A减去1000元之后,操作突然中止。然后又从B取出了500元,取出500元之后,再执行 往账户B加上1000元 的操作。这样就会导致账户A虽然减去了1000元,但是账户B没有收到这个转过来的1000元。
所以这2个操作必须要具备原子性才能保证不出现一些意外的问题。
如何保证原子性?
在Java内存模型中,只保证了基本读取和赋值的原子性操作。如果想保证多个操作的原子性,需要使用synchronized关键字或者Lock相关的工具类。如果想要使int、long等类型的自增操作具有原子性,可以用java.util.concurrent.atomic包下的工具类,如:AtomicInteger、AtomicLong等。另外需要注意的是,volatile关键字不具有保证原子性的语义。
- 使用
synchronized
关键字或者Lock
相关的工具类; - 如果想要使int、long等类型的自增操作具有原子性,可以用java.util.concurrent.atomic包下的工具类,如:AtomicInteger、AtomicLong等。另外需要注意的是,volatile关键字不具有保证原子性的语义。
2)可见性?
- 可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
如何保证可见性?
- 使用
volatile
关键字:- 当一个共享变量被
volatile
关键字修饰时,当前线程修改工作内存中的变量后,会立刻将其修改刷新到主内存中;其他线程需要读取时,会去内存中读取新值。 - 而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。
- 当一个共享变量被
- 使用
synchronized
关键字:synchronized
关键字能够保证同一时刻只有一个线程获得锁,然后执行同步代码,并且在释放锁之前,会把对变量的修改刷新到主内存中,因此可以保证可见性。
- 使用
Lock
相关的工具类:Lock
相关的工具类的lock
方法能够保证同一时刻只有一个线程获得锁,然后执行同步代码块,并且确保执行Lock
相关的工具类的unlock
方法在之前,会把变量的修改刷新到主内存中。
3)有序性?🌟
- 有序性指的是:程序执行的顺序按照代码的先后顺序执行。
- 编译优化可能会导致
在Java中,为了提高程序的运行效率,编译器和处理器常常会对指令重排,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。这种情况被称之为指令重排(Instruction Reordering)。
单线程环境里面确保最终执行结果和代码顺序的结果一致;
处理器在进行重排序时,必须要考虑指令之间的数据依赖性;
多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。
如何保证有序性?
- 使用
volatile
关键字:通过禁止指令重排,从而避免了程序乱序执行的情况。- 把变量声明为volatile,在对这个变量读写操作时会通过插入内存屏障的方式,来禁止指令重排序。
- 使用
synchronized
关键字: - 使用
Lock
相关的工具类:
以一个常见的面试题为例讲解一下 volatile 关键字禁止指令重排序的效果:
“单例模式了解吗?来给我手写一下!给我解释一下双重检验锁方式实现单例模式的原理?”
- 双重校验锁实现对象单例(线程安全) :
//饿汉单例
public class Singleton {
// 1、构造器私有
private Singleton() { }
// 2、定义一个静态变量存储一个对象。静态变量用`volatile`修饰
//对象加上了volatile关键字是为了保证变量的可见性,防止指令重排序; 第二个线程拿到的可能是未初始化的对象,所以要加volatile防止指令重排序
private volatile static Singleton instance;
// 3、对外提供⼀个公共的⽅法获取实例
public static Singleton getInstance() {
// 第⼀重检查对象是否已经实例过,没有实例化过才进入加锁代码
if (instance == null) {
// 使⽤ synchronized 加锁
synchronized (Singleton.class) {
// 第⼆重检查是否为 null
if (instance == null) {
// new 关键字创建对象不是原⼦操作
instance = new Singleton();
}
}
}
return instance;
}
}
- 为什么使用
volatile
关键字?禁止指令重排- 创建对象其实是分三步执行的:
- 1)在堆内存开辟内存空间;
- 2)调用构造方法,初始化对象;
- 3)将
instance
单例对象指向分配的内存地址。
- 为了提高程序的运行效率,编译器和处理器常常会对指令重排,所以经过指令重排后创建对象的顺序可能从
1-> 2-> 3
变成1->3->2
,如果有线程执行顺序是1->3->2
,对象指向堆内存但还没有初始化时,其他线程可能这个时候进入到第一重判断,获得了没有初始化的对象。这样的话就会出现异常,这个就是著名的DCL 失效问题。 - 变量上添加
volatile
关键字以后,会通过在创建对象指令的前后添加内存屏障来禁⽌指令重排序,就可以避免这个问题,⽽且对volatile
修饰的变量的修改对其他任何线程都是可⻅的。
- 创建对象其实是分三步执行的:
2、 volatile关键字
1)volatile如何保证变量的可见性?
如上
2)volatile如何禁止指令重排序?
如上
3)volatile可以保证原子性吗?
不能保证原子性
链接: volatile 可以保证原子性么
3、 synchronized 关键字
(1)说一说自己对于 synchronized 关键字的了解?
synchronized
关键字是一个同步机制,主要是用来解决多线程同步问题,可以保证被它修饰的代码在任意时刻只有一个线程执行。synchronized
关键字可以用来修饰实例方法、静态方法和代码块。
(2)如何使用 synchronized 关键字?
synchronized
关键字可以用来修饰实例方法、静态方法和代码块。
- 修饰实例方法:给当前对象实例加锁,作用于调用该方法的当前对象。
- 修饰静态方法 :给当前类加锁,会作用于类的所有对象。
- 因为静态成员不属于任何一个实例对象,归整个类所有,不依赖于类的特定实例,被类的所有实例共享。
- 修饰代码块:给括号里指定的对象或类加锁。
synchronized(object)
表示进入同步代码库前要获得 给定对象的锁。synchronized(类.class)
表示进入同步代码前要获得 给定 Class 的锁
1。修饰实例方法:给当前对象实例加锁,作用于调用该方法的当前对象。
synchronized void method() {
//业务代码
}
2。修饰静态方法 :给当前类加锁,会作用于类的所有对象。
- 因为静态成员不属于任何一个实例对象,归整个类所有,不依赖于类的特定实例,被类的所有实例共享。
synchronized static void method() {
//业务代码
}
静态 synchronized 方法和非静态 synchronized 方法之间的调用互斥么?不互斥!如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。
3。修饰代码块:给括号里指定的对象或类加锁。
synchronized(object)
表示进入同步代码库前要获得 给定对象的锁。synchronized(类.class)
表示进入同步代码前要获得 给定 Class 的锁
synchronized(this) {
//业务代码
}
Note:尽量不要使用 synchronized(String a) 因为 JVM 中,字符串常量池具有缓存功能。
(3)构造方法可以使用 synchronized 关键字修饰么?
- Java语法规定 构造方法不能被synchronized关键字修饰,而且构造方法每次都是构造出新的对象,不存在多个线程同时读写同一对象中的属性的问题,所以不需要同步 。
(4)继承和synchronized
- 子类的同名方法可以覆盖父类的对应方法,只不过synchronized修饰符不会被继承,也就是说子类覆盖后对应方法就不同步了,但是可以调用的。这个时候调用父类的对应方法还是可以同步的.
(5)🌟synchronized关键字底层是怎么帮我们实现同步的?/ 讲一下 synchronized 关键字的底层原理?🌟
- 需要分两种情况去思考
- synchronized关键字修饰代码块:
要想知道底层原理,可以javap命令反编译class文件,看看他的字节码- (概述)代码块同步的实现是使用
monitorenter
和monitorexit
指令来完成的,其中monitorenter
指令指向同步代码块的开始位置,monitorexit
指令则指明同步代码块的结束位置。 - (详细)当执行
monitorenter
指令时,线程会尝试获取对象的锁:- 如果锁的计数器为
0
就表示锁可以被获取,获取后将锁计数器加1
;拥有对象锁的线程才能monitorexit
指令来释放锁,执行monitorexit
指令时,锁的计数器也会减1
,当计数器等于0
时,当前线程就释放锁。其他线程可以尝试获取锁。 - 如果获取锁失败,当前线程就要阻塞等待,直到锁被另外一个线程释放为止。
- 如果锁的计数器为
- (概述)代码块同步的实现是使用
synchronized
关键字修饰方法:- 通过
ACC_SYNCHRONIZED
标识,指明这个方法是一个同步方法。从而执行相应的同步调用。- 如果是实例方法,JVM 会尝试获取实例对象的锁;
- 如果是静态方法,JVM 会尝试获取当前 class 的锁。
- 通过
(6)DK1.6 之后的 synchronized 关键字底层做了哪些优化?
为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁,以及锁的存储结构和升级过程。
链接: Java6及以上版本对synchronized的优化
区别?
synchronized 和 volatile 区别?
synchronized
关键字和volatile
关键字是两个互补的存在:
- volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。
- volatile 关键字是线程同步的轻量级实现,volatile 关键字只能用于变量而 synchronized 关键字可以修饰方法以及代码块 。
- volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。
synchronized 和 Lock 区别?🌟
- 存在层面:
Syncronized
:是Java 中的一个关键字,存在于 JVM 层面;Lock
是 Java 中的一个接口。Lock需要手动加锁和手动解锁,一般通过lock.lock()
方法来进行加锁, 通过lock.unlock()
方法进行解锁。
- 锁的释放:
Syncronized
:1)获取锁的线程执行完同步代码后,会自动释放锁;2)线程发生异常时,会自动释放锁,因此不会死锁。Lock
:必须在finally
中释放锁,不然容易造成线程死锁。
- 锁的获取:
Syncronized
:假设线程A获得锁,B线程会等待;如果A发生了阻塞,线程B会一直等待。Lock
:会分情况而定,Lock中有尝试获取锁的方法,如果尝试获取到锁,则不用一直等待。
- 锁的状态:
synchronized
:无法判断锁的状态。Lock
:可以判断锁的状态。(Lock可以通过trylock来知道有没有获取锁,而synchronized不能)
- 锁的类型:
synchronized
:是可重入锁,不可中断,非公平锁。Lock
:是可重入锁,可中断,公平锁。- 等待可中断 :
Lock
提供了一种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()
来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。 - 可实现公平锁 :
Lock
可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。公平锁就是先等待的线程先获得锁。ReentrantLock默认情况是非公平的,可以通过 ReentrantLock类的ReentrantLock(boolean fair)构造方法来制定是否是公平的。 - 可实现选择性通知(锁可以绑定多个条件): synchronized关键字与wait()和notify()/notifyAll()方法相结合可以实现等待/通知机制。ReentrantLock类当然也可以实现,但是需要借助于Condition接口与newCondition()方法。
- 等待可中断 :
- 锁的性能:
synchronized
:适用于少量同步的情况下,性能开销比较大。Lock
:适用于大量同步阶段:Lock 锁可以提高多个线程进行读的效率(使用 readWriteLock)- 在竞争不是很激烈的情况下,
Synchronized
的性能要优于ReetrantLock,但是在资源竞争很激烈的情况下,Synchronized的性能会下降几十倍,但是ReetrantLock的性能能维持常态;
ReetrantLock 提供了多样化的同步,比如有时间限制的同步,可以被Interrupt的同步(synchronized的同步是不能Interrupt的)等。
- 在竞争不是很激烈的情况下,
synchronized 和 ReentrantLock 区别?
- 两者都是可重入锁;
可重入锁:指自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,在对象锁没有被释放之前,再次想要获得这个对象锁还能够获得;如果不是可重入锁的话就会造成死锁。因为同一个线程每次获得锁,锁的计数器都增1,要等锁的计数器为0时才能释放锁。 synchronized
依赖于 JVM 而ReentrantLock
依赖于 API;- synchronized 是依赖于 JVM 实现的,前面我们也讲到了 虚拟机团队在 JDK1.6 为 synchronized 关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。
ReentrantLock
是 JDK 层面实现的(也就是 API 层面,需要lock()
和unlock()
方法配合try/finally
语句块来完成),所以我们可以通过查看它的源代码,来看它是如何实现的。
ReentrantLock
比synchronized
增加了一些高级功能;- 等待可中断 : ReentrantLock提供了一种能够中断等待锁的线程的机制,通过 lock.lockInterruptibly() 来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。
- 可实现公平锁 : ReentrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。ReentrantLock默认情况是非公平的,可以通过 ReentrantLock类的ReentrantLock(boolean fair)构造方法来制定是否是公平的。(
true
时为公平锁,false
时为非公平锁)- 公平:锁的获取顺序就应该符合请求上的绝对时间顺序(先请求的先获得锁)。
- 可实现选择性通知(锁可以绑定多个条件): synchronized关键字与wait()和notify()/notifyAll()方法相结合可以实现等待/通知机制。ReentrantLock类当然也可以实现,但是需要借助于Condition接口与newCondition()方法。
可重入锁?
- 可重入锁:指自己可以再次获取自己的内部锁。
- 比如一个线程获得了某个对象的锁,在对象锁没有被释放之前,再次想要获得这个对象锁还能够获得;如果不是可重入锁的话就会造成死锁。因为同一个线程每次获得锁,锁的计数器都增1,要等锁的计数器为0时才能释放锁。
synchronized
和ReentrantLock
都是可重入锁。
17. ThreadLocal
谈谈对ThreadLocal的理解?
链接: link
考点:作用、实现机制
关键字:线程变量、资源副本、线程隔离
ThreadLocal
是线程变量,可以把不安全的变量(需要并发访问的资源) 封装到ThreadLocal
,每个线程拥有自己的资源副本,具有线程隔离的效果,不需要对该变量进行同步了。(只有在线程内才能获取到对应的值,线程外不能访问)ThreadLocal
提供了线程安全的共享机制,在编写多线程代码时,可以把不安全的变量封装进ThreadLocal。ThreadLocal
的实现基于ThreadLocalMap
,用于存放键值对,ThreadLocal
类中提供了set
和get
方法。set
方法可以将值绑定到当前线程;get
方法可以获取当前线程绑定的数据。
- (内存泄漏)在线程池中使用ThreadLocal会导致内存泄漏,因为线程池中的线程没有被销毁,设置的key value也就是Entry对象不会回收,从而出现内存泄漏问题。因此在使用完ThreadLocal对象后,需调用
remove()
方法清除Entry对象。
ThreadLocal,即线程变量,它将需要并发访问的资源复制多份,让每个线程拥有一份资源。由于每个线程都拥有自己的资源副本,从而也就没有必要对该变量进行同步了。
ThreadLocal提供了线程安全的共享机制,在编写多线程代码时,可以把不安全的变量封装进ThreadLocal。 在实现上,Thread类中声明了threadLocals变量,用于存放当前线程独占的资源。ThreadLocal类中定义了该变量的类型(ThreadLocalMap),这是一个类似于Map的结构,用于存放键值对。ThreadLocal类中还提供了set和get方法,set方法会初始化ThreadLocalMap并将其绑定到Thread.threadLocals,从而将传入的值绑定到当前线程。在数据存储上,传入的值将作为键值对的value,而key则是ThreadLocal对象本身(this)。get方法没有任何参数,它会以当前ThreadLocal对象(this)为key,从Thread.threadLocals中获取与当前线程绑定的数据。
加分回答 注意,ThreadLocal不能替代同步机制,两者面向的问题领域不同。同步机制是为了同步多个线程对相同资源的并发访问,是多个线程之间进行通信的有效方式。而ThreadLocal是为了隔离多个线程的数据共享,从根本上避免多个线程之间对共享资源(变量)的竞争,也就不需要对多个线程进行同步了。 一般情况下,如果多个线程之间需要共享资源,以达到线程之间的通信功能,就使用同步机制。如果仅仅需要隔离多个线程之间的共享冲突,则可以使用ThreadLocal。
ThreadLocal 有什么用?
- 通常情况下,我们创建的变量可以被任何一个线程访问并修改的,ThreadLocal可以实现每个线程有自己的专属本地变量。
- ThreadLocal是线程变量,可以把不安全的变量(需要并发访问的资源) 封装到ThreadLocal,每个线程拥有自己的资源副本,具有线程隔离的效果,不需要对该变量进行同步了。(只有线程内可以获取对应的值,线程外不能访问。)
- ThreadLocal的实现基于
ThreadLocalMap
,用于存放键值对,通过set
和get
方法绑定数据和获取数据:set
方法可以将数据绑定到当前线程;get
方法可以获取当前线程绑定的数据。
Note:内存泄漏问题:
在线程池中使用ThreadLocal会导致内存泄漏,因为线程池中的线程不会被销毁,设置的key value也就是Entry对象不会回收,从而出现内存泄漏的问题。所以在使用完ThreadLocal对象后,需要调用remove()方法清除Entry对象。
+内存泄漏,详细如下面!( ThreadLocal 内存泄露问题是怎么导致的?)
ThreadLocal原理?
ThreadLocal
虽然是线程变量,但实际上不存放任何信息,可以把它看作线程(Thread
)操作ThreadLocalMap
存放变量的桥梁。(它主要提供了初始化、set、get和remove方法。)ThreadLocal
实例总是通过Thread.currentThread()
获取到当前操作线程,然后去操作线程中ThreadLocalMap
类型的成员变量。
Thread
类中有2个ThreadLocalMap
类型的变量:threadLocals
和inheritableThreadLocals
;默认情况下这两个变量都是null,只有当前线程调用ThreadLocal
类的set
和get
方法时,才创建它们,实际上调用这两个方法时,调用的是ThreadLocalMap
类对应的set
和get
方法。ThreadLocalMap
类似于hashMap- +内存泄漏,详细如下面!( ThreadLocal 内存泄露问题是怎么导致的?)
ThreadLocal
不支持继承性,子线程获取不到父线程的值。- 可以使用
inheritableThreadLocals
来解决不能继承问题。它的底层实现原理是:- 当父线程创建子线程时,把父线程的变量复制到子线程中。
- 可以使用
ThreadLocal 内存泄露问题是怎么导致的?
ThreadLocalMap
中的key
是我们定义的ThreadLocal变量,是弱引用,value
是我们设置的值,是强引用。- 弱引用就是:在垃圾回收时,如果发现就立马回收掉;
- 强引用不会被回收。
- 这样就会出现key为null的Entry对象,value永远不会被回收,就可能会导致内存泄漏的风险。
- 所以在使用完ThreadLocal对象后,调用
remove()
方法清除Entry对象。
18、内存泄漏?🌟🌟
考点:分析器、详细日志、引用对象、泄漏警告、基准测试、代码审查
- 内存泄漏:是指不再使用的对象仍然被引用,导致垃圾收集器无法回收它们的内存。由于不再使用的对象仍然无法清理,甚至这种情况可能会越积越多,最终导致
OutOfMemoryError
。 - 可按照思路来分析和解决内存泄漏问题:
- 2.1 启用分析器。Java分析器是通过应用程序监视和诊断内存泄漏的工具,可以分析应用程序内部发生的事情。例如:如何分配内存。使用分析器可以比较不同的方法并找到可以最佳利用资源的方式。
- 2.2 启用详细垃圾收集日志。通过启用详细垃圾收集日志,可以跟踪GC的详细进度。要启用该功能,我们需要将以下内容添加到JVM的配置中
-verbose:gc
。通过这个参数,我们可以看到GC内部发生的细节。 - 2.3 使用引用对象,我们可以借助java.lang.ref包内置的Java引用对象来规避问题,使用java.lang.ref包,而不是直接引用对象,即使用对象的特殊引用,使得它们可以轻松地被垃圾收集。
- 2.4 Eclipse内存泄漏警告 对于JDK1.5以及更高的版本中,Eclipse会在遇到明显的内存泄漏情况时显示警告和错误。因此,在Eclipse中开发时,我们可以定期地访问“问题”选项卡,并更加警惕内存泄漏警告。
- 2.5 基准测试 我们可以通过执行基准测试来衡量和分析Java代码的性能。通过这种方式,我们可以比较执行相同任务的替代方法的性能。这可以帮助我们选择更好的方法,并可以帮助我们节约内存。
- 2.6 代码审查 最后,我们总是采用经典的老方式来进行简单的代码演练。在某些情况下,即使这种看似微不足道的方法也有助于消除一些常见的内存泄漏问题。
- 加分回答
通俗地说,我们可以将内存泄漏视为一种疾病,它通过阻塞重要的内存资源来降低应用程序的性能。和所有其他疾病一样,如果不治愈,随着时间的推移,它可能导致致命的应用程序崩溃。 内存泄漏很难解决,找到它们需要对Java语言有很深的理解并掌握复杂的命令。在处理内存泄漏时,没有一刀切的解决方案,因为泄漏可能通过各种不同的事件发生。 但是,如果我们采用最佳实践并定期执行严格的代码演练和分析,那么我们就可以将应用程序中内存泄漏的风险降到最低。
- 并发编程、ThreadLocal可能会导致内存泄漏。
内存泄漏情况?
那么对于这种情况下,由于代码的实现不同就会出现很多种内存泄漏问题(让JVM误以为此对象还在引用中,无法回收,造成内存泄漏)。
- 长生命周期对象持有短生命的引用,即使短声明周期对象不再使用,但因为长生命周期对象持有它的引用而导致不能被回收。比如,缓存系统,我们加载了一个对象放在缓存中,然后一直不使用这个缓存,由于缓存的对象一直被缓存引用得不到释放,就造成了内存泄漏;
(如HashMap
、LinkedList等等
。如果这些容器为静态的,那么它们的生命周期与程序一致,则容器中的对象在程序结束之前将不能被释放,从而造成内存泄漏) - 各种连接未调用关闭方法,可能会造成内存泄漏。比如,数据库连接,如果不再使用时,没有调用close()方法来释放与数据库的连接,就会造成对象无法被回收,从而导致内存泄漏;
- 其实原因依然是长生命周期对象持有短生命周期对象的引用
- 内部类持有外部类,可能会造成内存泄漏。如果一个外部类的实例对象的方法返回了一个内部类的实例对象,这个内部类对象被长期引用了,即使那个外部类实例对象不再被使用,但由于内部类持有外部类的实例对象,这个外部类对象将不会被垃圾回收,这也会造成内存泄露。
- 改变哈希值,当一个对象被存储进 HashSet 集合中以后,就不能修改这个对象中的那些参与计算哈希值的字段了,否则对象修改后的哈希值与最初存储进 HashSet 集合中时的哈希值就不同了,在这种情况下,即使在 contains 方法使用该对象的当前引用作为的参数去 HashSet 集合中检索对象,也将返回找不到对象的结果,这也会导致无法从 HashSet 集合中单独删除当前对象,造成内存泄露。
19、什么是内存溢出?
指程序申请内存时,没有足够的内存供申请者使用。
内存泄漏的堆积最终会导致内存溢出。
三、JVM
了解JVM吗?
- JVM(Java 虚拟机):是运行 Java 字节码的虚拟机。JVM 有针对不同系统的特定实现(Windows,Linux,macOS),目的是使用相同的字节码,它们都会给出相同的结果。字节码和不同系统的 JVM 实现是 Java 语言“一次编译,随处可以运行”的关键所在。
- JVM 并不是只有一种!只要满足 JVM 规范,每个公司、组织或者个人都可以开发自己的专属 JVM。 也就是说我们平时接触到的 HotSpot VM 仅仅是是 JVM 规范的一种实现而已。
- 除了我们平时最常用的 HotSpot VM 外,还有 J9 VM、Zing VM、JRockit VM 等 JVM 。维基百科上就有常见 JVM 的对比:Comparison of Java virtual machines ,感兴趣的可以去看看。并且,你可以在 Java SE Specifications 上找到各个版本的 JDK 对应的 JVM 规范。
- JRE、JDK:
- JRE
- JRE是 Java 运行时环境,包括JVM和核心类库。提供给需要运行Java程序的用户使用的。但是,它不能用于创建新程序。
- JDK(Java Development Kit)
- JDK是Java开发工具包,是程序员编写Java程序所需用到的Java开发工具包。JDK包括JRE、编译器Javac、Java程序的调试工具和分析工具,Java编写需要的文档等。能够创建和编译程序。
- 补充:JDK是开发环境,JRE是运行时环境。编写Java程序时需要JDK,运行Java程序需要JRE,但JDK已经包含了JRE,所以只要安装了JDK就可以编辑和运行Java程序。当Java程序只需要运行,不需要编写时,只安装JRE即可。
对于 Java 程序员来说,在虚拟机自动内存管理机制下,不再需要像 C/C++程序开发程序员这样为每一个 new 操作去写对应的 delete/free 操作,不容易出现内存泄漏和内存溢出问题。正是因为 Java 程序员把内存控制权利交给 Java 虚拟机,一旦出现内存泄漏和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那么排查错误将会是一个非常艰巨的任务。
- JRE
JVM内部结构?
JVM的内部结构分为三部分:
- 类装载器(ClassLoader)子系统:通过类加载器将字节码加载到JVM内存;
- 运行时数据区(内存区域):程序运行时的内存区域,其又分堆、方法区、程序计数器、虚拟机栈和本地方法栈;
- 执行引擎:执行字节码。
- 分为解释执行和编译执行。
- 解释执行:运行时通过解释器将字节码文件转换为最终的机器码
- 编译执行:将程序直接编译为机器码,无需解释直接运行机器码,运行效率比解释高。
- 目前JVM采用解释+即时编译(JIT (Just In Time Compiler))混合模式,即时编译是程序在运行过程中,JIT编译器会针对频繁被调用的热点代码做出优化,将其直接编译为机器码,存方法区中,之后每次执行只执行编译后的机器码,一次提升java运行性能。
- 分为解释执行和编译执行。
1、类加载器
类加载过程?🌟
- 类加载的过程主要分为三个部分:加载、链接、初始化
- 加载:通过类加载器把
class字节码文件
加载到内存中,然后在堆中创建一个java.lang.Class
对象。 - 链接:连接阶段就是把类的二进制数据合并到
JRE
中;又可以细分为三个小部分:验证、准备、解析。- 1)验证:主要是为了保证加载进来的字节流符合虚拟机规范,不会造成安全错误。
- 2)准备:主要是为类变量(注意,不是实例变量)分配内存,并且赋初值。
- 3)解析:将常量池内的符号引用替换为直接引用的过程。
- 初始化:对类变量初始化,是执行类构造器的过程。在Java类中对静态Field指定初始值有两种方式:
- 声明时即指定初始值,如
static int a = 5
; - 使用静态代码块为静态Field指定初始值,如:
static{ b = 5; }
- 声明时即指定初始值,如
- 加载:通过类加载器把
2、Java 内存区域(运行时数据区)
这里基本都是针对HotSpot虚拟机的
介绍下 Java 内存区域(运行时数据区)?🌟
- Java虚拟机在执行Java程序时,会把它管理的内存划分成若干个不同的数据区域。
- 1)线程共享的是:堆、方法区。
- 2)线程私有的是:程序计数器、虚拟机栈、本地方法栈。
- +详细介绍
2. 堆:
- 堆是Java虚拟机管理的内存中最大的一块,是线程共享的一块内存区域。
- 用于存储对象实例,几乎所有对象实例和数组都在这里分配内存。
- 但不是所有对象都在堆上分配内存,从jdk1.7开始,如果某些方法中的对象引用没有被返回 或者 没有被外面使用,那么对象可以直接在栈上分配内存。
- Java堆是垃圾回收器管理的主要区域,因为也被称为GC堆(Garbage Collected Heap),从垃圾回收角度,由于现在收集器基本都采用分代垃圾收集算法,所以Java堆可以细分为新生代、老年代和永久代。进一步划分的目的是更好地回收内存,或者更快地分配内存。
- 如果Java堆中没有内存存放新创建的对象,就会抛出
OutOfMemoryError
异常
- 如果Java堆中没有内存存放新创建的对象,就会抛出
下图所示的 Eden 区、两个 Survivor 区 S0 和 S1 都属于新生代,中间一层属于老年代,最下面一层属于永久代。
3. 方法区:
不同的虚拟机实现上,方法区的实现是不同的。
- 方法区是JVM运行时数据区域的一块逻辑区域,是线程共享的一块内存区域。
- 存放:被虚拟机加载的类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
- 方法区和永久代以及元空间是什么关系呢?方法区和永久代以及元空间的关系很像 Java 中接口和类的关系,类实现了接口。永久代和元空间是 HotSpot 虚拟机对虚拟机规范中方法区的两种实现方式。并且,永久代是 JDK 1.8 之前的方法区实现,JDK 1.8 及以后方法区的实现变成了元空间。
- 为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) 呢?
- 1、整个永久代有一个 JVM 本身设置的固定大小上限,无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。
- 2、元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了。
- 3、在 JDK8,合并 HotSpot 和 JRockit 的代码时, JRockit 从来没有一个叫永久代的东西, 合并之后就没有必要额外的设置这么一个永久代的地方了。
- 为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) 呢?
- 方法区常用参数有哪些?
4. 程序计数器:
- 程序计数器是一块较小的内存区域,是线程私有的一块内存区域。每个线程都有自己的程序计数器,各个线程之间计数器互不影响。
- 程序计数器用于记录当前线程执行的位置,字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
- 它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
- Note:程序计数器是唯一一个不会出现
OutOfMemoryError
的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
程序计数器为什么是私有的?
程序计数器主要有下面两个作用:
- 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
- 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
需要注意的是,如果执行的是 native 方法,那么程序计数器记录的是 undefined 地址,只有执行的是 Java 代码时程序计数器记录的才是下一条指令的地址。 - 所以,程序计数器私有主要是为了线程切换后能恢复到正确的执行位置。
5. 虚拟机栈:
- 虚拟机栈是线程私有的一块内存区域。
- 虚拟机栈描述的是Java方法执行的线程内存模型:每次方法调用,Java虚拟机都会同步创建一个栈桢(Stack Frame)被压入栈,方法调用结束 栈桢 出栈。(除Native方法外的Java方法调用都是通过栈实现的。)
- 栈由一个个栈桢组成;
- 每个栈桢都拥有:局部变量表、操作数栈、动态链接和方法返回地址。
- 它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
Java 方法有两种返回方式:
一种是 return 语句正常返回;一种是抛出异常。不管哪种返回方式,都会导致栈帧被弹出。也就是说, 栈帧随着方法调用而创建,随着方法结束而销毁。无论方法正常完成还是异常完成都算作方法结束。
- 局部变量表:主要存放了编译期可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。
- 操作数栈:主要作为方法调用的中转站使用,用于存放方法执行过程中产生的中间计算结果。另外,计算过程中产生的临时变量也会放在操作数栈中。
- 动态链接:主要服务一个方法需要调用其他方法的场景。在 Java 源文件被编译成字节码文件时,所有的变量和方法引用都作为符号引用(Symbilic Reference)保存在 Class 文件的常量池里。当一个方法要调用其他方法,需要将常量池中指向方法的符号引用转化为其在内存地址中的直接引用。动态链接的作用就是为了将符号引用转换为调用方法的直接引用。
6. 本地方法栈:
- 是线程私有的一块内存区域;
- 本地方法栈与虚拟机栈发挥的作用类似,只是虚拟机栈是为虚拟机执行的Java方法(也就是字节码)服务,本地方法栈是为虚拟机使用到的Native方法服务。在hotSpot虚拟机中和Java虚拟机栈合二为一。
- 本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。
- 它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
- 也会出现 StackOverFlowError 和 OutOfMemoryError 两种错误。
虚拟机栈和本地方法栈为什么是私有的?
- 虚拟机栈是用于描述Java方法的内存模型。
- 每个Java方法在执行时,会创建一个栈桢;栈桢中的局部变量表保存了一个方法所有局部变量。(栈桢的结构分为局部变量表、操作数栈、动态链接、方法出口)
- 方法调用时,创建栈桢,并压入虚拟机栈;方法执行完毕,栈桢出栈并销毁。
- 本地方法栈与虚拟机栈发挥的作用类似,只是虚拟机栈是为虚拟机执行的Java方法(也就是字节码)服务,本地方法栈是为虚拟机使用到的Native方法服务。在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
- 所以,为了保证线程中的局部变量不被其他线程访问到,虚拟机栈和本地方法栈是私有的。
常量池:
运行时常量池?
- 运行时常量池是方法区的一部分。
- 常量池表主要用来:存放编译期生成的各种字面量(Literal)和符号引用(Symbolic Reference)。
- 字面量包括:整数、浮点数和字符串字面量。(字面量源代码中的固定值的表示法,即通过字面我们就能知道其值的含义。)
- 符号引用包括:类符号引用、字段符号引用、方法符号引用和接口方法符号引用。
- 常量池表会在类加载后存放到方法区的运行时常量池中。
- 既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出
OutOfMemoryError
错误。
字符串常量池?
- 字符串常量池是:JVM为了提升性能和减少内存消耗针对字符串(String类)专门开辟的一块区域,主要是为了避免字符串的重复创建。
- jdk1.7之前,字符串常量池存放在永久代 (永久代是方法区的一种实现)
- JDK1.7 字符串常量池和静态变量从永久代移动了 Java 堆中。
JDK 1.7 为什么要将字符串常量池移动到堆中?
主要是因为永久代(方法区实现)的 GC 回收效率太低,只有在整堆收集 (Full GC)的时候才会被执行 GC。
Java 程序中通常会有大量的被创建的字符串等待回收,将字符串常量池放到堆中,能够更高效及时地回收字符串内存。
类加载过程?🌟
- 类加载的过程主要分为三个部分:加载、链接、初始化
- 加载:把
class字节码文件
从通过类加载器读入内存中,然后在堆中创建一个java.lang.Class
对象。 - 链接:连接阶段就是把类的二进制数据合并到
JRE
中;又可以细分为三个小部分:验证、准备、解析。- 1)验证:主要是为了保证加载进来的字节流符合虚拟机规范,不会造成安全错误。
- 2)准备:主要是为类变量(注意,不是实例变量)分配内存,并且赋初值。
- 3)解析:将常量池内的符号引用替换为直接引用的过程。
- 初始化:对类变量初始化,是执行类构造器的过程。在Java类中对静态Field指定初始值有两种方式:
- 声明时即指定初始值,如
static int a = 5
; - 使用静态代码块为静态Field指定初始值,如:
static{ b = 5; }
- 声明时即指定初始值,如
- 加载:把
3、Java 对象的创建过程?🌟
- 类加载检查:
- 虚拟机遇到
new
指令时,- 首先检查这个指令的参数 能否在常量池中定位到这个类的符号引用,
- 并检查这个符号引用的类是否已经被加载、解析和初始化过。如果没有,就先去执行类的加载过程。
- 虚拟机遇到
- 分配内存
- 类加载检查通过后,虚拟机会为新生对象分配内存,对象所需内存大小在类加载完成后便已、确定。为对象分配内存也就是在堆中分配一块确定大小的内存。
- 分配方式有两种:指针碰撞 和 空闲列表
- 1)指针碰撞:
- 使用场合:堆内存规整(即没有内存碎片)的情况。
- 原理:用过的内存整合在一边,没用过的内存放在另一边,‘中间有一个分界指针,只要向没用过的内存方向 将指针移动 对象内存大小位置即可。
- 使用该分配方式的 GC 收集器:Serial, ParNew
- 2)空闲列表:
- 使用场合:堆内存不规整的情况。
- 原理:虚拟机会维护一个列表,这个列表中记录哪些内存块可用,在分配时,找一块足够大的内存来划分给对象实例,最后更新列表记录。
- 使用该分配方式的 GC 收集器:CMS
- 1)指针碰撞:
- 初始化零值
- 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步是为了保证对象的实例字段在Java代码中不赋初始值也能使用,程序能访问到这些字段的数据类型所对应的零值。
- 设置对象头
- 初始化零值完成后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何能找到类的元数据信息、对象的哈希码、GC分代年龄等信息。这些信息存放在对象头中。另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
- (Hotspot 虚拟机的对象头包括两部分信息)对象头包含两部分信息:第一部分用于存储对象自身的运行时数据,比如哈希码、GC分代年龄、锁状态标志、对象持有的锁等;第二部分是类型指针,指向对应的类元数据,虚拟机通过这个指针确定此对象是哪个类的实例。
- 执行init方法
- 上面工作都完成后,从虚拟机的视角来看,一个新的对象已经产生了。但从Java程序的视角来看,对象的创建才刚开始,init方法还没有执行,所有字段都还为零。
- 所以一般来说,执行
new
命令之会接着执行init
方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。
4、内存分配:
类加载检查通过后,虚拟机会为新生对象分配内存,对象所需内存大小在类加载完成后便已、确定。为对象分配内存也就是在堆中分配一块确定大小的内存。
内存分配的两种方式 ?🌟
- 类加载检查通过后,虚拟机会为新生对象分配内存,对象所需内存大小在类加载完成后便可确定。为对象分配内存也就是在堆中分配一块确定大小的内存。
- 分配方式有两种:指针碰撞 和 空闲列表
- 1)指针碰撞:
- 使用场合:堆内存规整(即没有内存碎片)的情况。
- 原理:用过的内存整合在一边,没用过的内存放在另一边,中间有一个分界指针,只要向没用过的内存方向 将指针移动 对象内存大小位置即可。
- 使用该分配方式的 GC 收集器:Serial, ParNew
- 2)空闲列表:
- 使用场合:堆内存不规整的情况。
- 原理:虚拟机会维护一个列表,这个列表中记录哪些内存块可用,在分配时,找一块足够大的内存来划分给对象实例,最后更新列表记录。
- 使用该分配方式的 GC 收集器:CMS
- 1)指针碰撞:
- 选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。而 Java 堆内存是否规整,取决于 GC 收集器的算法是"标记-清除",还是"标记-整理"(也称作"标记-压缩"),值得注意的是,复制算法内存也是规整的。
内存分配并发问题
在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:
CAS + 失败重试
:CAS是一种乐观锁的实现方式,每次不加锁,假设没有冲突地去完成某项操作,如果因为冲突导致失败就重试,直到操作成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。TLAB
:TLAB是为线程中的对象分配的一块内存,这块内存是从Java堆的Eden区域划分出来的。- JVM在给线程中的对象分配内存时,首先在TLAB分配,当对象大于TLAB中的剩余内存 或 TLAB内存已经用完了时,再采用CAS进行分配。
对象的内存布局?
在Hotspot虚拟机中,对象的内存布局可可以分为三个区域:对象头、实例数据和对齐填充。
- 对象头包括两部分数据:一部分用于存储对象运行时数据,例如哈希码、GC分代年龄、对象持有的锁等;一部分是类型指针,指向类元数据,确定这个对象是哪个类的实例。
- 实例数据 是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。
- 对齐填充 不是必然存在的,也没有什么特别含义,仅仅起占位作用。对象的大小必须是8字节的整数倍,对象头部分正好是8字节的数据,因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。(因为Hotspot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍。)
对象的访问定位的两种方式?(句柄和直接指针两种方式)🌟
建立对象就是为了使用对象,我们的 Java 程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式由虚拟机实现而定,目前主流的访问方式有:使用句柄、直接指针。
- 句柄
如果使用句柄,Java堆中会划分出一块内存作为句柄池,栈的reference中存储的是对象的句柄地址,句柄中包括对象实例数据地址和对象类型数据地址。
Object obj = new Object();
Object obj表示一个本地引用,存储在java栈的本地便变量表中,表示一个reference类型的数据。
new Object()作为实例对象存放在java堆中,同时java堆中还存储了Object类的信息(对象类型、实现接口、方法等)的具体地址信息,这些地址信息所执行的数据类型存储在方法区中。 - 直接指针
如果使用直接指针访问,reference中存储的直接就是对象地址。
- 这两种对象访问各有优势,
- 使用句柄的最大好处是,reference中存储较为稳定的句柄地址,对象移动时(垃圾收集时移动对象是非常普遍的行为),只需改变句柄中对象实例数据指针,不需要修改reference。
- 使用直接指针的好处是,访问速度快,减少了一次指针定位的时间开销。
(由于java是面向对象的语言,在开发中java对象的访问非常的频繁,因此这类开销积少成多也是非常可观的,反之则提升访问速度。)
5、JVM垃圾回收
GC是什么?为什么要GC?🌟
- GC 是垃圾收集的意思(Gabage Collection),内存处理是编程人员容易出现问题的地方,忘记或者错误的内存回收会导致程序或系统的不稳定甚至崩溃,Java 提供的 GC 功能可以自动监测对象是否超过作用域从而达到自动回收内存的目的,Java 语言没有提供释放已分配内存的显示操作方法。
当需要排查各种内存溢出问题、当垃圾收集成为系统达到更高并发的瓶颈时,我们就需要对这些“自动化”的技术实施必要的监控和调节。
如果没有特殊说明,都是针对的是 HotSpot 虚拟机。
怎么回收的?🌟
- 自己写的
- 先判断对象是否死亡,有两种方法。详见下面。
- 再进行垃圾收集,垃圾收集算法有:…详见下面。
如何判断对象是否死亡? / 是否是时候收集?/ 怎么判断能不能回收?(两种方法)🌟
- 垃圾收集的目的在于清除不再使用的对象。
堆中几乎放着所有的对象实例,对堆垃圾回收前的第一步就是要判断哪些对象已经死亡(即不能再被任何途径使用的对象)。
- 引用计数法
- 引用计数器存储了对象的所有引用数。
- 每当有一个地方引用它,计数器就加 1;
- 当引用失效,计数器就减 1;
- 计数器为 0 时,就可以进行垃圾收集。
- 这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。(原因及例子详见: 死亡对象判断方法)
- 引用计数器存储了对象的所有引用数。
- 可达性分析算法
- 这个算法的基本思想是:以GC Roots为起点向下搜索,确定可达的对象(节点走过的路径称为引用链)。如果一个对象到GC Roots不可达的话就证明这个对象是不可用的,需要被回收。
下图中的 Object 6 ~ Object 10 之间虽有引用关系,但它们到 GC Roots 不可达,因此为需要被回收的对象。
- 这个算法的基本思想是:以GC Roots为起点向下搜索,确定可达的对象(节点走过的路径称为引用链)。如果一个对象到GC Roots不可达的话就证明这个对象是不可用的,需要被回收。
哪些对象可以作为 GC Roots 呢?
虚拟机栈(栈帧中的本地变量表)中引用的对象
本地方法栈(Native 方法)中引用的对象
方法区中类静态属性引用的对象
方法区中常量引用的对象
所有被同步锁持有的对象
垃圾回收算法、特点?🌟
- 标记 - 清除算法
- 分为标记、清除阶段。首先标记不需要回收的对象,在标记完成后再统一回收没有被标记的对象。这个算法是最基础的算法,后续的算法都是根据这个算法的不足进行改进得到的。
- 这个算法会带来两个问题:
- 1)效率问题
- 2)空间问题(标记清除后会产生大量不连续的碎片)
- 标记 - 复制算法
- 为了解决效率问题,“标记-复制”收集算法出现了。每次的内存回收都是对内存区间的一半进行回收。它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。
- 标记 - 整理算法
- 根据老年代的特点提出的一种标记算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是将所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。
- 分代收集算法
- 当前虚拟机的垃圾收集都采用分代收集算法,这种算法没有什么新的思想,只是根据对象存活周期的不同将内存分为几块。一般将 java 堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。
- 比如在新生代中,每次收集都会有大量对象死去,所以可以选择标记-复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择标记-清除或标记-整理算法进行垃圾收集。
对象可以被回收,就代表一定会被回收吗?
即使在可达性分析法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑阶段”,要真正宣告一个对象死亡,至少要经历两次标记过程;可达性分析法中不可达的对象被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize 方法。当对象没有覆盖 finalize 方法,或 finalize 方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要执行。
被判定为需要执行的对象将会被放在一个队列中进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被真的回收。
Note:Object 类中的 finalize 方法一直被认为是一个糟糕的设计,成为了 Java 语言的负担,影响了 Java 语言的安全和 GC 的性能。JDK9 版本及后续版本中各个类中的 finalize 方法会被逐渐弃用移除。忘掉它的存在吧!
Java中有哪些引用类型?强引用、软引用、弱引用、虚引用🌟(虚引用与软引用和弱引用的区别、使用软引用能带来的好处?)🌟
无论是通过引用计数法判断对象引用数量,还是通过可达性分析法判断对象的引用链是否可达,判定对象的存活都与“引用”有关。
JDK1.2 以后,Java 对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用四种(引用强度逐渐减弱)
- 强引用(StrongReference):发生
gc
的时候不会被回收。- 我们使用的大部分引用实际上都是强引用,这是使用最普遍的引用。
- 如果一个对象具有强引用,那就类似于必不可少的生活用品,垃圾回收器绝不会回收它。当内存空间不足,Java 虚拟机宁愿抛出
OutOfMemoryError
错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足问题。
- 软引用(SoftReference):内存不够用的情况下才会被回收。
- 只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,JAVA 虚拟机就会把这个软引用加入到与之关联的引用队列中。
- 弱引用(WeakReference):在GC时一定会被回收。
- 与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只有弱引用的对象,不管内存空间是否足够都会回收它的内存。
- 不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中。
- 虚引用(PhantomReference):主要用来跟踪对象是否被回收。
- 如果一个对象只具有虚引用,那么它和没有任何引用一样,在任何时候都可能被垃圾回收。
- 虚引用与软引用的区别:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只有弱引用的对象,不管内存空间是否足够都会回收它的内存。
- 虚引用与软引用和弱引用区别: 虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。
特别注意,在程序设计中一般很少使用弱引用与虚引用,使用软引用的情况较多,这是因为软引用可以加速 JVM 对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生。
如何判断一个常量是废弃常量?🌟
假如在字符串常量池中存在字符串 “abc”,如果当前没有任何 String 对象引用该字符串常量的话,就说明常量 “abc” 就是废弃常量,如果这时发生内存回收的话而且有必要的话,“abc” 就会被系统清理出常量池了。
如何判断一个类是无用的类?🌟
方法区主要回收的是无用的类,那么如何判断一个类是无用的类的呢?
- 同时满足下面 3 个条件才能算是 “无用的类” :
- 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
- 加载该类的
ClassLoader
已经被回收。 - 该类对应的
java.lang.Class
对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
- 虚拟机可以对满足上述 3 个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样不使用了就会必然被回收。
常见的垃圾回收器有哪些?CMS、G1收集器?🌟
如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。
- 没有最好的垃圾收集器,需要根据不同的应用场景选择适合的垃圾收集器。
- Serial(串行)收集器:
- 是最基本的垃圾收集器,是单线程垃圾收集器,只使用一条垃圾收集线程去完成垃圾收集工作,并且在它进行垃圾收集工作时,必须暂停其他工作线程,直到它垃圾收集结束。
- 新生代采用 标记-复制算法;老年代采用 标记-整理算法。
- 优点:是简单高效,没有线程交互的开销;缺点:垃圾收集时,必须暂停其他工作线程。
- 推荐在客户端模式下选用,由于没有线程交互的开销,可以获得最高的单线程收集效率。
- Serial(串行)收集器:
- ParNew收集器:
- ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和 Serial 收集器完全一样。多个垃圾收集线程并行工作,也需要暂停用户线程。
- 新生代采用 标记-复制算法;老年代采用 标记-整理算法。
- 它是许多运行在 Server 模式下的虚拟机的首要选择,除了 Serial 收集器外,只有它能与 CMS 收集器(真正意义上的并发收集器,后面会介绍到)配合工作。
- 并行和并发概念补充:
- 并行(Parallel) :指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
- 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行,可能会交替执行),用户程序在继续运行,而垃圾收集器运行在另一个 CPU 上。
- Parallel Scavenge 收集器
- 多线程收集器。Parallel Scavenge 收集器关注点是吞吐量(高效率的利用 CPU)。所谓吞吐量就是 CPU 中用于运行用户代码的时间与 CPU 总消耗时间的比值。 Parallel Scavenge 收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如果对于收集器运作不太了解,手工优化存在困难的时候,使用 Parallel Scavenge 收集器配合自适应调节策略,把内存管理优化交给虚拟机去完成也是一个不错的选择。
- 新生代采用标记-复制算法;老年代采用标记-整理算法
- Serial Old收集器
- Serial 收集器的老年代版本,也是一个单线程收集器。
- 它主要有两大用途:一种用途是在 JDK1.5 以及以前的版本中与 Parallel Scavenge 收集器搭配使用,另一种用途是作为 CMS 收集器的后备方案。
- Parallel Old收集器:
- Parallel Scavenge 收集器的老年代版本。
- 在注重吞吐量以及 CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器。
- CMS(Concurrent Mark Sweep)收集器:
- 是一种以获取最短回收停顿时间 为目标的收集器。
- 是作用于老年代的垃圾回收器,使用的是 标记-清除 算法实现的;收集结束时会产生空间碎片。
- 收集范围:是整个新生代或者老年代。
- 它的运作过程分为4个步骤 / 工作流程:
(1)初始标记:暂停用户线程,并标记直接与root相连的对象,速度很快。
(2)并发标记:标记可达对象,可以与用户线程一起工作。不能保证标记出所有存活对象。
(3)重新标记:为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间长,但远比并发标记时间短。
(4)并发清除:GC线程回收垃圾对象,可以与用户线程一起工作。
整个过程中耗时最长的并发标记和并发清除都可以与用户线程一起工作;所以总体上说,CMS收集器的内存回收过程与用户线程一起并发执行; -
- 优点:耗时最长的并发标记和并发清除阶段GC线程和用户线程是并发执行的,停顿 时间较短,适合对延迟有要求的任务。
- 缺点: 使用的是 标记-清除 算法实现的,收集结束时会产生空间碎片;GC线程和用户线程并发执行,两者会抢占CPU,并且产生浮动垃圾。
- G1收集器
- G1收集器,把堆内存分割成多个大小相等的独立区域然后并发的对其进行垃圾回收;
- 每一个Region可以作为Eden,Survivor区,老年代。还有Humongous区域,专门用来存储大对象。只要大小超过了一个Region的一半的对象即为大对象。
- G1收集器(jdk1.7):G1收集器兼顾低延迟和高吞吐在服务端运行,HotSpot团队期望取代CMS收集器。也就是在满足停顿时间的情况下获取最大的吞度量。有两种收集模式:Young GC和Mixed GC。
- G1收集器可以管理新生代和老年代,整体上采用的是标记-整理算法,局部来看,两个区域间采用标记-复制算法,不会产生内存空间碎片。
- 它的运作过程分为4个步骤:
- (1)初始标记:暂停用户线程,标记直接与Root相连的对象。
- (2)并发标记:标记可达对象,可以与用户线程一起工作。不能保证标记出所有存活对象。
- (3)重新标记:为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录;停顿时间比初始标记稍长,但远比并发标记短;
- (4)筛选回收:
- 首先排序各个Region的回收价值和成本;
- 然后根据用户期望的GC停顿时间来制定回收计划;
- 最后按计划回收一些价值高的Region中垃圾对象;
- 回收时采用"复制"算法,从一个或多个Region复制存活对象到堆上的另一个空的Region,并且在此过程中压缩和释放内存;可以并发进行,降低停顿时间,并增加吞吐量;
-
- 优点:使用region,不会出现新生代或者老年代分配空间过大而造成浪费;老年代使用标记-整理算法,不会产生内存碎片;每次只选择垃圾对象多的region,而不是整个堆。
- 缺点:
分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念。
- G1收集器,把堆内存分割成多个大小相等的独立区域然后并发的对其进行垃圾回收;
CMS和G1区别?
- (最后一个阶段)
- G1和CMS都分为4个阶段,前三个阶段基本相同都为初始标记、并发标记、重新标记,最后一个阶段不同:
- CMS的清除阶段 GC线程和用户线程并发执行,两者会抢占CPU,并且产生浮动垃圾。
- G1不是并发的。
- G1和CMS都分为4个阶段,前三个阶段基本相同都为初始标记、并发标记、重新标记,最后一个阶段不同:
- (是否产生内存碎片)
- G1可以管理新生代和老年代,而CMS只能作用于老年代,
- 并且CMS在老年代使用的是标记-清除算法,会产生内存碎片;
- G1整体上采用的是标记-整理算法,局部来看,两个区域间采用标记-复制算法,不会产生内存空间碎片。
- (内存)
- G1将内存划分为大小相等的Region,可以选择垃圾对象多的Region而不是整个堆从而减少STW,同时使用Region可以更精确控制收集,我们可以手动明确一个垃圾回收的最大时间。
HotSpot为什么要分为新生代和老年代?🌟
因为有的对象寿命长,有的对象寿命短。应该将寿命长的对象放在一个区,寿命短的对象放在一个区。不同的区采用不同的垃圾收集算法。寿命短的区清理频次高一点,寿命长的区清理频次低一点,提高效率。
Minor GC 和Full GC有什么不同?🌟
针对HotSpot VM的实现,它里面的GC其实准确分类只有两大种:
- 部分收集:
- 新生代收集(
Minor GC/Young GC
):只对新生代进行垃圾收集; - 老年代收集(
Major GC / Old GC
):只对老年代进行垃圾收集。需要注意的是 Major GC 在有的语境中也用于指代整堆收集; - 混合收集(
Mixed GC
):对整个新生代和部分老年代进行垃圾收集。
- 新生代收集(
- 整堆收集(
Full GC
):收集整个 Java 堆和方法区。
JVM中永久代会不会发生垃圾回收?
- 永久代会发生垃圾回收。
- 如果永久代的空间不足,会导致堆的
Full GC
(完全垃圾回收);(这就是为什么正确的永久代大小对避免Full GC是非常重要的原因)。- 条件:1)该类的实例都被回收。 2)加载该类的classLoader类加载器已经被回收 3)该类不能通过反射访问到其方法,而且该类的java.lang.Class没有被引用 当满足这3个条件时,是可以回收,但回不回收还得看jvm。
jdk1.8
中永久代被元空间取代,它的回收不是由Java来控制了,元空间使用的是操作系统的内存空间,所以容量比较充裕,不会发生元空间的空间不足问题。
为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) 呢?
- 永久代有一个 JVM 本身设置的固定大小上限,无法进行调整,而元空间使用的是内存空间,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。
- 当元空间溢出时会得到如下错误: java.lang.OutOfMemoryError: MetaSpace
- 你可以使用
-XX:MaxMetaspaceSize
标志设置最大元空间大小,默认值为unlimited
,这意味着它只受系统内存的限制。-XX:MetaspaceSize
调整标志定义元空间的初始大小如果未指定此标志,则 Metaspace 将根据运行时的应用程序需求动态地重新调整大小。
- 元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了。
- 在 JDK8,合并 HotSpot 和 JRockit 的代码时, JRockit 从来没有一个叫永久代的东西, 合并之后就没有必要额外的设置这么一个永久代的地方了。
1. 堆空间的基本结构
前言:
Java自动内存管理主要是针对 对象内存的回收和对象内存的分配。同时,Java内存管理最核心的功能是 堆内存中 对象内存分配与回收。
Java堆是垃圾收集器管理的主要区域,因此也被称为GC堆(Garbage Collected Heap)
从垃圾回收的角度来说,现在垃圾收集器基本都采用分代垃圾收集算法,所以Java堆被划分成了几个不同的区域,这样我们就可以根据各个区域的特点选择合适的垃圾收集算法。
在JDK7版本及JDK7版本之前,堆内存被通常分为下面三部分:
- 新生代内存
- 老生代
- 永久代
下图所示的Eden区、两个Survivor区S0和S1都属于新生代,中间一层属于老年代,最下面一层属于永久代。
JDK8版本之后 永久代已被元空间(Metaspace)取代,元空间使用的是内存。
空间分配担保
空间分配担保是为了确保在 Minor GC 之前老年代本身还有容纳新生代所有对象的剩余空间。
2. 内存分配和回收原则
- 对象优先在Eden区分配
- 大多数情况下,对象会首先在新生代中Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次
Minor GC
。- 执行 Minor GC 后,后面分配的对象如果能够存在 Eden 区的话,还是会在 Eden 区分配内存;
- GC 期间虚拟机又发现 对象 无法存入 Survivor 空间,所以只好通过 分配担保机制 把新生代的对象提前转移到老年代中去,老年代上的空间足够存放 对象,所以不会出现 Full GC。
- 大多数情况下,对象会首先在新生代中Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次
- 大对象直接进入老年代
- 大对象就是需要大量连续内存空间的对象。(比如:字符串、数组)
- 大对象直接进入老年代 主要是为了避免:为大对象分配内存时,由于分配担保机制带来的复制而降低效率。
- 长期存活的对象将进入老年代
既然虚拟机采用分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代、哪些对象应放在老年代中。为了做到这一点,虚拟机给每个对象一个对象年龄(Age)计数器。 - 大部分情况,对象会首先在新生代的Eden区中分配。如果对象在Eden区出生并经过Minor GC后仍能够存活,并且能够被Survivor容纳的话,将被移动到Survicor空间(S0或S1)中,并把对象年龄设置为1。(Eden区 -> Survivor区后,对象的初始年龄变为1)
- 对象在Survicor区中,每熬过一次Minor GC,年龄就增加1,当年龄增加到一定程度就会进入老年代。对象晋升到老年代的年龄阈值,可以通过参数
-XX:MaxTenuringThreshold
来设置。
Minor GC: 指新生代GC,即发生在新生代(包括Eden区和Survivor区)的垃圾回收操作,当新生代无法为新生对象分配内存空间的时候,会触发Minor GC。
四、集合
Java 集合, 也叫作容器,主要是由两大接口派生而来:一个是 Collection接口,主要用于存放单一元素;另一个是 Map 接口,主要用于存放键值对。
List, Set, Queue, Map 四者的区别?🌟
- List(对付顺序的好帮手): 存储的元素是有序的、可重复的。
- Set(注重独一无二的性质): 存储的元素是无序的、不可重复的。
- Queue(实现排队功能的叫号机): 按特定的排队规则来确定先后顺序,存储的元素是有序的、可重复的
- Map(用 key 来搜索的专家): 使用键值对(key-value)存储,key 是无序的、不可重复的,value 是无序的、可重复的,每个键最多映射到一个值。
如何选择集合?
- 如果需要根据键值获取到元素值,就选用
Map 接口
下的集合:- 不需要排序时就选择
HashMap
; - 需要排序时选择
TreeMap
; - 要保证线程安全就选用
ConcurrentHashMap
。
- 不需要排序时就选择
- 只需要存放元素值时,就选择实现
Collection 接口
的集合:- 不需要保证元素唯一,选择实现
List 接口
的,比如ArrayList 或 LinkedList,然后再根据实现这些接口的集合的特点来选用。 - 需要保证元素唯一时选择实现
Set 接口
的集合比如 TreeSet 或 HashSet。
- 不需要保证元素唯一,选择实现
为什么要使用集合?
- 数组长度不可变,集合长度可变。当不知道要存放的数据个数时,可以用集合。
- 数组存放的类型是基本类型或者引用类型,集合存放的类型可以不是一种引用类型;
- 集合提高了数据存储的灵活性,Java 集合不仅可以用来存储不同类型不同数量的对象,还可以保存具有映射关系的数据。
1. Collection
Collection接口,主要用于存放单一元素。
List:
ArrayList
:是 List 的主要实现类,底层使用Object[ ]
存储,适用于频繁的查找工作,线程不安全 ;- 第一次创建集合并添加第一个元素的时候,在底层创建一个默认长度为10的数组。
- 查询元素快,增删相对慢
Vector
:是 List 的古老实现类,底层使用Object[ ]
存储,线程安全的;LinkedList
: 双链表(JDK1.6 之前为循环链表,JDK1.7 取消了循环)- 查询元素慢,增删首尾元素快O(1)。
说一说ArrayList的扩容机制?🌟❗️
- ArrayList底层是基于数组实现的
- 执行添加操作时,会分配默认的初始容量10;
- 当再往数组中添加元素,但是发现数组满了的情况下,就需要扩容。
- 会创建一个新数组,大小为原来的1.5倍,再将原数组内容复制到新数组中。
ArrayList是线程安全的吗?线程不安全的表现?
ArrayList
不是线程安全的- 比如说 要执行添加操作,
- 在单线程环境下执行没有问题。
- 但是在多线程环境下执行,可能会发生线程B添加的值覆盖线程A添加的值。线程B覆盖了线程A的操作。
ArrayList 与 LinkedList 区别?🌟
- 是否保证线程安全:都是不同步的,不能保证线程安全;
- 底层数据结构:
ArrayList
底层使用的是Object[]
数组,LinkedList
底层使用的是双向链表。 - 插入和删除是否受元素位置影响:
ArrayList
采用数组存储,所以插入和删除元素的时间复杂度受元素位置影响。例如:- 执行
add(E e)
方法时,ArrayList
会默认将指定元素追加到末尾,此时时间复杂度为O(1); - 若是在指定位置
i
插入或删除元素add(int index, E element)
,时间复杂度就为 O(n-i)。因为在进行上述操作的时候集合中第 i 和第 i 个元素之后的(n-i)个元素都要执行向后位/向前移一位的操作。
- 执行
LinkedList
采用链表存储,- 1)所以 如果是在头尾插入或者删除元素不受元素位置的影响(add(E e)、addFirst(E e)、addLast(E e)、removeFirst() 、 removeLast()),时间复杂度为 O(1);
- 2)如果是要在指定位置
i
插入和删除元素的话(add(int index, E element),remove(Object o)), 时间复杂度为 O(n) ,因为需要先移动到指定位置再插入。
- 是否支持快速随机访问:
ArrayList
支持随机访问;LinkedList
不支持高效的随机元素访问;快速随机访问就是通过元素的序号快速获取元素对象(对应于get(int index)方法)。 - 内存空间占用:
ArrayList
的空 间浪费主要体现在在 list 列表结尾会预留一定的容量空间;LinkedList
的空间花费则体现在它的每一个元素都需要消耗比 ArrayList 更多的空间(因为要存放直接后继和直接前驱以及数据)。
双向链表:包含两个指针,一个 prev 指向前一个节点,一个 next 指向后一个节点。
Set:
- 无序:指存储的数据在底层数组中并非按照数组索引的顺序添加 ,而是根据数据的哈希值决定的。
- 无索引:没有带索引的方法,所以不能使用普通for循环遍历,也不能通过索引来获取元素。
HashSet:
- 无序、不重复、无索引。
- 底层原理:底层基于
HashMap
实现的,底层采用HashMap
来保存元素。- JDK8之前:底层使用 数组+链表 组成
- JDK8之后:底层使用 数组+链表+红黑树 组成
- 哈希表的详细流程
创建一个默认长度16,默认加载因为0.75的数组,数组名table
根据元素的哈希值跟数组的长度计算出应存入的位置
判断当前位置是否为null,如果是null直接存入,如果位置不为null,表示有元素, 则调用equals方法比较属性值,如果一样,则不存,如果不一样,则存入数组。
当数组存满到16*0.75=12时,就自动扩容,每次扩容原先的两倍
HashSet如何检查重复?HashSet原理解析?
- 把对象加入
HashSet
时- 1)HashSet会先计算对象的
hashCode
值; - 2)如果存在
hashCode
值相等的对象,就再调用equals()
方法检查对象是否真的相同。 - 3)如果相同HashSet就不存;如果不同才会存,这样就大大减少了
equals()
的次数,提高了执行速度。- JDK 7新元素占老元素位置,指向老元素。
- JDK 8中新元素挂在老元素下面。当链表长度超过
8
时,自动转为红黑树。(JDK8中,引入红黑树 进一步提高了操作数据的性能。)
为什么 JDK 还要同时提供这两个方法呢hashcode equals?**
- 答:因为hashCode()可以减少我们的查找成本,先通过hashCode()方法判断是否已有hashCode值相等的对象,如果有再调用equals()方法判断对象是否真的相同,这样大大减少了equals()的次数,提高了执行速度。
- 1)HashSet会先计算对象的
- 问:那为什么不只提供 hashCode() 方法呢?
- 答:因为两个对象的hashCode 值相等并不代表两个对象就相等。
- 问:那为什么两个对象有相同的 hashCode 值,它们也不一定是相等的?
- 答:为 hashCode() 所使用的哈希算法也许刚好会让多个对象传回相同的哈希值。越糟糕的哈希算法越容易碰撞,但这也与数据值域分布的特性有关(所谓哈希碰撞也就是指的是不同的对象得到相同的 hashCode )。
- 总结:
- 如果两个对象的hashCode 值相等,那这两个对象不一定相等(哈希碰撞)。
- 如果两个对象的hashCode 值相等并且equals()方法也返回 true,我们才认为这两个对象相等。
- 如果两个对象的hashCode 值不相等,我们就可以直接认为这两个对象不相等。
为什么重写 equals() 时必须重写 hashCode() 方法?
- 因为两个相等的对象的
hashCode 值
必须是相等。也就是说如果equals 方法
判断两个对象是相等的,那这两个对象的hashCode
值也要相等。 - 如果重写
equals()方法
时没有重写hashCode()
方法的话就可能会导致equals 方法
判断是相等的两个对象,hashCode 值
却不相等。
LinkedHashSet
- 原理:通过
LinkedHashMap
来实现的。(底层数据结构是依然哈希表,只是每个元素又额外的多了一个双链表的机制记录存储的顺序。) - 有序、不重复、无索引。
这里的有序指的是保证存储和取出的元素顺序一致
TreeSet
- 可排序、不重复、无索引。(按照元素的大小默认升序(有小到大)排序)
- TreeSet集合底层是基于红黑树的数据结构实现排序的,增删改查性能都较好。
注意:TreeSet集合是一定要排序的,可以将元素按照指定的规则进行排序。 - TreeSet默认规则
- 对于数值类型:Integer , Double,官方默认按照大小进行升序排序。
- 对于字符串类型:默认按照首字符的编号升序排序。
- 对于自定义类型如Student对象,TreeSet无法直接排序。
自定义排序规则?
- TreeSet集合存储对象的的时候有2种方式可以自定义排序规则:
- 方式一:让类(如学生类)实现
Comparable接口
重写compareTo()方法
来定制比较规则。 - 方式二:TreeSet集合有参数构造器,可以设置
Comparator接口
对应的比较器对象,来定制比较规则。(默认使用集合自带的比较器排序) - 两种方式中,关于返回值的规则:
- 1)如果认为 左边元素
>
右边元素 返回正整数。 - 2)如果认为 左边元素
<
右边元素返回负整数。 - 3)如果认为 左边元素
等于
右边元素返回0,此时Treeset集合只会保留一个元素,认为两者重复。
- 1)如果认为 左边元素
- 方式一:让类(如学生类)实现
Comparable 和 Comparator 的区别?
comparable 接口
实际上是出自java.lang包,它有一个compareTo(Object obj)方法
用来排序;comparator接口
实际上是出自 java.util 包,它有一个compare(Object obj1, Object obj2)方法
用来排序。
链接: comparable 和 Comparator 的区别 定制排序
Queue:
PriorityQueue
: Object[] 数组来实现二叉堆ArrayQueue
: Object[] 数组 + 双指针
2、Map
Map的集合?
Map 接口,主要用于存放键值对。
HashMap
:- JDK1.8 之前 HashMap 由数组+链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。
- JDK1.8之后 HashMap由数组+链表+红黑树组成。在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。
LinkedHashMap
: LinkedHashMap 继承自HashMap
,所以它的底层由数组和链表或红黑树组成。另外,LinkedHashMap 在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。详细可以查看:《LinkedHashMap 源码详细分析(JDK1.8)》Hashtable
: 数组+链表组成的,数组是 Hashtable 的主体,链表则是主要为了解决哈希冲突而存在的.TreeMap
: 红黑树(自平衡的排序二叉树)
HashMap:
HashMap 底层?🌟
- JDK1.8 之前 HashMap 由数组+链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。
- JDK1.8之后 HashMap由数组+链表+红黑树组成。在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。
put
操作:- 1)对key计算得到
hash值
,然后通过hash算法(数组长度-1) & hash
计算下标(要存放的位置),并判断当前元素存放位置是否已有元素: - 2)如果没有元素,就把它封装成
Node对象
,放在这个位置。 - 3)如果此位置已存放元素,就判断该元素与要存放元素的
hash值
和key
是否相同:- 如果相同就覆盖替换
value
(保证key的唯一性); - 如果不同:遍历链表,判断hash值是否相等 并用
equals()
方法来判断 key 是否相等:- 一旦有相同的,就覆盖当前节点的
value
。 - 如果都不同,到达了链表结尾,就在尾部插入新节点。如果链表长度大于阈值(默认为8),会将链表转为红黑树,以减少搜索时间。
(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)
- 一旦有相同的,就覆盖当前节点的
- 如果相同就覆盖替换
- 1)对key计算得到
拉链法
- 将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。
为什么转换为红黑树?
- 红黑树查找的时间复杂度为
O(logn)
;链表的时间复杂度是O(n)
;红黑树的时间复杂度是优于链表的。- 红黑树进行插入和删除操作时,会维持树的平衡,保证树的高度在
[logN,logN+1]
,查找的性能较好。
- 红黑树进行插入和删除操作时,会维持树的平衡,保证树的高度在
为什么不直接转为红黑树?
- 为何链表长度为8才转变为红黑树呢?
- 因为树节点(TreeNodes)所占的空间是普通节点Node的两倍。综合考虑,认为只能在节点太多的时候,红黑树占空间大这一劣势不太明显的时候,才会舍弃链表,使用红黑树。
红黑树保持平衡的步骤?
- 调整的方法有两种:变色和旋转,旋转又分为左旋转(逆时针)和右旋转(顺时针)。
- 新插入节点是红色,只有在父节点也为红色节点的时候是需要调整。父亲节点为红色分两种情况讨论:
- 叔叔节点为红色:就把父亲叔叔节点变成黑色,祖父节点变成红色,把祖父节点当成新插入的节点,继续进行调整。
- 叔叔不为红色:
- 插入节点与父亲节点在同一边:比如都是左节点,将父节点进行右旋,并将父节点与祖父节点交换颜色。
- 插入节点与父亲节点不在同一边:先进行左旋或者右旋,使插入节点与父节点在同一边,然后就和在同一边的调整方案相同。
HashMap为什么线程不安全?如何解决?🌟
HashMap
的底层是基于数组、链表、红黑树的组成的,在多线程环境下,可能会出现数据覆盖情况。- 假设两个线程A、B都在进行
put
操作,并且hash函数
计算出的插入下标是相同的,当线程A执行完判断是否存在哈希碰撞后由于时间片耗尽导致被挂起,而线程B得到时间片后在该下标处插入了元素,完成了正常的插入,然后线程A获得时间片,由于之前已经进行了hash碰撞的判断,所有此时不会再进行判断,而是直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,从而线程不安全。
- 假设两个线程A、B都在进行
- 解决办法有3种:
- 使用
HashTable
:HashTable是线程安全的,但是性能不好,不推荐使用。 - 使用
Collections类
的syschronizedMap()方法
将HashMap包装成线程安全的HashMap。 - 使用
ConcurentHashMap
:(是这三种方法中最高效的方法)ConcurrentHashMap是线程安全且高效的HashMap。
- 使用
HashMap的 put 操作?
- 对key计算得到
hash值
,然后通过hash算法(数组长度-1) & hash
计算下标(要存放的位置),并判断当前元素存放位置是否已有元素: - 如果没有元素,就把它封装成
Node对象
,放在这个位置。 - 如果此位置已存放元素,就判断该元素与要存放元素的
hash值
和key
是否相同:- 如果相同就覆盖替换
value
(保证key的唯一性); - 如果不同:遍历链表,判断hash值是否相等 并用
equals()
方法来判断 key 是否相等:- 一旦有相同的,就覆盖当前节点的
value
。 - 如果都不同,到达了链表结尾,就在尾部插入新节点。如果链表长度大于阈值(默认为8),会将链表转为红黑树,以减少搜索时间。
(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)
- 一旦有相同的,就覆盖当前节点的
- 如果相同就覆盖替换
put
方法源码:
static final int TREEIFY_THRESHOLD = 8;
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab;
Node<K,V> p;
int n, i;
//如果当前map中无数据,执行resize方法。并且返回n
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//如果要插入的键值对要存放的这个位置刚好没有元素,那么把他封装成Node对象,放在这个位置上即可
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
//否则的话,说明这上面有元素
else {
Node<K,V> e; K k;
//如果这个元素的key与要插入的一样,那么就替换一下。
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//1.如果当前节点是TreeNode类型的数据,执行putTreeVal方法
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//还是遍历这条链子上的数据,跟jdk7没什么区别
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//2.完成了操作后多做了一件事情,判断,并且可能执行treeifyBin方法
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null) //true || --
e.value = value;
//3.
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//判断阈值,决定是否扩容
if (++size > threshold)
resize();
//4.
afterNodeInsertion(evict);
return null;
}
HashMap的 get(k) 实现原理?
- 先通过key得到
hash值
,然后通过hash算法(数组长度-1) & hash
计算下标(要存放的位置); - 如果这个位置没有元素,就返回null;
- 如果有,则判断hash值和key是否相同,如果相同就返回这个节点;否则就判断此时数据结构是链表还是红黑树
- 1)链表结构进行顺序遍历查找操作,每次判断hash值是否相等 并用
equals()
方法来判断 key 是否相等,满足条件则直接返回该结点。链表遍历完都没有找到则返回空。 - 2)红黑树结构执行相应的
getTreeNode()
查找操作。
- 1)链表结构进行顺序遍历查找操作,每次判断hash值是否相等 并用
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
HashMap遍历方式?
HashMap扩容?
HashMap
的初始容量为16
;负载因子默认是:0.75。当现有容量 > 总容量 * 负载因子时,HashMap 扩容规则为当前容量2倍- (
Hashtable
初始容量为11
;负载因子默认都是:0.75,Hashtable 扩容规则为当前容量 2倍 + 1)
- (
有112个元素,你给hashmap的初始值是多少?为什么?
256
- HashMap中要放入112个元素时,我们需要先通过
expectedSize / 0.75F + 1.0F = 150
计算出设置值,这个值经过JDK处理后,会被设置为离他最近的2的幂次方:256。 - 不直接设置112,而是通过
expectedSize / 0.75F + 1.0F = 150
计算设置值原因:当现有容量 > 总容量 * 负载因子
时,HashMap会扩容为当前容量的2倍。先除以0.75再加1就是为了减少扩容的机率。 - 为什么JDK会将值处理为2的幂次方原因:HashMap的长度是2的次幂的话,可以让数据更散列更均匀的分布,更充分的利用数组的空间;在扩容迁移的时候不需要再重新通过哈希定位新的位置了。扩容后,元素新的位置,要么在原脚标位,要么在原脚标位+扩容长度这么一个位置。
HashMap长度为什么是2的幂次方?
- HashMap的长度是2的次幂的话,可以让数据更散列更均匀的分布,更充分的利用数组的空间;
- 在扩容迁移的时候不需要再重新通过哈希定位新的位置了。扩容后,元素新的位置,要么在原脚标位,要么在原脚标位+扩容长度这么一个位置。
HashMap 多线程操作导致死循环问题?
主要原因在于并发下的 Rehash 会造成元素之间会形成一个循环链表。不过,jdk 1.8 后解决了这个问题,但是还是不建议在多线程下使用 HashMap,因为多线程下使用 HashMap 还是会存在其他问题比如数据丢失。并发环境下推荐使用 ConcurrentHashMap 。
3、Lambda表达式
- Lambda表达式简化Comparator接口的匿名形式:
- 省略规则
- 参数类型可以省略不写。
- 如果只有一个参数,参数类型可以省略,同时()也可以省略。
- 如果Lambda表达式的方法体代码只有一行代码。可以省略大括号不写,同时要省略分号!
- 如果Lambda表达式的方法体代码只有一行代码。可以省略大括号不写。此时,如果这行代码是return语句,必须省略return不写,同时也必须省略";"不写
4、线程安全集合、线程不安全集合🌟
HashMap为什么线程不安全?如何解决?🌟
HashMap
的底层是基于数组、链表、红黑树的组成的,在多线程环境下,可能会出现数据覆盖情况。- 假设两个线程A、B都在进行
put
操作,并且hash函数
计算出的插入下标是相同的,当线程A执行完判断是否存在哈希碰撞后由于时间片耗尽导致被挂起,而线程B得到时间片后在该下标处插入了元素,完成了正常的插入,然后线程A获得时间片,由于之前已经进行了hash碰撞的判断,所有此时不会再进行判断,而是直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,从而线程不安全。
- 假设两个线程A、B都在进行
- 解决办法有3种:
- 使用
HashTable
:HashTable是线程安全的,但是性能不好,不推荐使用。 - 使用
Collections类
的syschronizedMap()方法
将HashMap包装成线程安全的HashMap。 - 使用
ConcurentHashMap
:(是这三种方法中最高效的方法)ConcurrentHashMap是线程安全且高效的HashMap。
- 使用
线程安全的集合有哪些?
java.util包
下的集合类中,大部分都是非线程安全的,但也有线程安全的集合类,比如Vector和HashTable。虽然线程安全,但是性能很差,已经被弃用了。- (对于java.util包下的非线程安全的集合)可以使用
Collections
工具类的syschronizedXxx()
方法,包装成线程安全的集合类。
从JDK 1.5开始,并发包下新增了大量高效的并发的容器,这些容器按照实现机制可以分为三类。
第一类是以降低锁粒度来提高并发性能的容器,它们的类名以Concurrent开头
,如ConcurrentHashMap
。
第二类是采用写时复制技术实现的并发容器,它们的类名以CopyOnWrite
开头,如CopyOnWriteArrayList
。
第三类是采用Lock实现的阻塞队列,内部创建两个Condition分别用于生产者和消费者的等待,这些类都实现了BlockingQueue接口,如ArrayBlockingQueue。
加分回答
Collections还提供了如下三类方法来返回一个不可变的集合,这三类方法的参数是原有的集合对象,返回值是该集合的“只读”版本。通过Collections提供的三类方法,可以生成“只读”的Collection或Map。 emptyXxx():返回一个空的不可变的集合对象 singletonXxx():返回一个只包含指定对象的不可变的集合对象 unmodifiableXxx():返回指定集合对象的不可变视图
线程不安全的集合有哪些?
- ArraryList
- LinkedList
- HashSet
- HashMap
- TreeSet
- TreeMap
5、区别:
HashMap 和 HashTable区别?🌟🌟
- 线程是否安全:
HashMap
是非线程安全的;Hashtable
是线程安全的,因为Hashtable
内部的方法基本都经过synchronized 修饰。(但如果要保证线程安全的话就使用ConcurrentHashMap
吧!Hashtable 基本被淘汰,不要在代码中使用它);- 在多线程环境下,
HashMap
可能会出现数据覆盖情况。- 假设两个线程A、B都在进行
put
操作,并且hash函数
计算出的插入下标是相同的,当线程A执行完判断是否存在哈希碰撞后由于时间片耗尽导致被挂起,而线程B得到时间片后在该下标处插入了元素,完成了正常的插入,然后线程A获得时间片,由于之前已经进行了hash碰撞的判断,所有此时不会再进行判断,而是直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,从而线程不安全。
- 假设两个线程A、B都在进行
- 在多线程环境下,
- 效率:
HashMap
要比Hashtable
效率高一点。因为Hashtable
是线程安全的,每个方法都要阻塞其他线程,所以 Hashtable 性能较差。 - 对 null 的支持:
HashMap
的key和value都可以为null;但Hashtable
不支持null键和null值,会抛出 NullPointerException异常。 - 初始容量大小和每次扩充容量大小的不同:
HashMap
的初始容量为16
;Hashtable
初始容量为11
;它们的负载因子默认都是:0.75。- 当现有容量 > 总容量 * 负载因子时,HashMap 扩容规则为当前容量2倍,Hashtable 扩容规则为当前容量 2倍 + 1。
- 底层数据结构:JDK1.8 以后的
HashMap
在解决哈希冲突时有了较大的变化,当链表长度 > 阈值(默认为 8)时,将链表转化为红黑树(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树),以减少搜索时间。Hashtable
没有这样的机制。
Hashtable 和 ConcurrentHashMap 的区别?🌟
- ConcurrentHashMap 和 Hashtable 的区别主要体现在实现线程安全的方式上不同。
- 底层数据结构: JDK1.7 的
ConcurrentHashMap
底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟 HashMap1.8 的结构一样,数组+链表/红黑二叉树。Hashtable
是采用 数组+链表 实现,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的; - 实现线程安全的方式(重要):
ConcurrentHashMap
:- 在 JDK1.7 的时候,
ConcurrentHashMap
底层采用 分段的数组+链表 实现,对整个桶数组进行了分割分段(Segment,分段锁),每一把锁只锁容器其中一部分数据(下面有示意图),多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。 - JDK1.8时,
ConcurrentHashMap
底层采用 数组+链表/红黑二叉树 实现,并发控制使用synchronized
和CAS
来操作。
- 在 JDK1.7 的时候,
Hashtable
(同一把锁):使用synchronized
来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。
HashTable:
JDK1.7 的 ConcurrentHashMap :ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成。
Segment 数组中的每个元素包含一个 HashEntry 数组,每个 HashEntry 数组属于链表结构。
JDK1.8 的 ConcurrentHashMap :Node 数组 + 链表 / 红黑树。不过,Node 只能用于链表的情况,红黑树的情况需要使用 TreeNode。当冲突链表达到一定长度时,链表会转换成红黑树。
HashMap和ConcurrentHashMap区别?🌟🌟
- 底层数据结构:
HashMap
:JDK1.8 之前HashMap
底层采用数组+链表实现。(链表则是主要为了解决哈希冲突而存在的);JDK1.8之后 底层是采用数组、链表、红黑树实现。ConcurrentHashMap
:JDK1.8之前 的ConcurrentHashMap
底层采用 分段的数组+链表 实现,JDK1.8 之后采用的数据结构跟HashMap
1.8 的结构一样,数组、链表、红黑树。
- 线程安全:
HashMap
:非线程安全。- 在多线程环境下,
HashMap
可能会出现数据覆盖情况。- 假设两个线程A、B都在进行
put
操作,并且hash函数
计算出的插入下标是相同的,当线程A执行完判断是否存在哈希碰撞后由于时间片耗尽导致被挂起,而线程B得到时间片后在该下标处插入了元素,完成了正常的插入,然后线程A获得时间片,由于之前已经进行了hash碰撞的判断,所有此时不会再进行判断,而是直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,从而线程不安全。
- 假设两个线程A、B都在进行
- 在多线程环境下,
ConcurrentHashMap
:线程安全。- 在 JDK1.7 的时候,
ConcurrentHashMap
对整个数组进行了分割分段(Segment,分段锁),每一把锁只锁容器其中一部分数据(下面有示意图),多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。 - JDK1.8时,
ConcurrentHashMap
并发控制使用synchronized
和CAS
来操作。
- 在 JDK1.7 的时候,
HashMap 和 HashSet 区别?
HashSet
底层就是基于HashMap
实现的。(HashSet 的源码非常非常少,因为除了 clone()、writeObject()、readObject()是 HashSet 自己不得不实现之外,其他方法都是直接调用 HashMap 中的方法。
ArrayList 和 Array 区别?什么时候用Array而不是ArrayList?
- 大小是否固定:
Array
大小是固定的;所以,事先知道数组大小时用Array
。ArrayList
大小是动态变化的,如果空间不够,会进行扩容,会创建一个新数组,大小为原来的1.5倍,再将原数组内容复制到新数组中。
- 存储元素的类型:
Array
可以存储基本类型和引用类型;ArrayList
只能存储引用类型。ArrayList
会对基本类型进行自动装箱,将基本类型用引用类型包装起来。
- 适合场景:
Array
适合 数据个数 和 类型 确定的场景;ArrayList
适合 个数 不确定,且需增删的场景。
ArrayList 和 Vector 区别?
ArrayList
:线程不安全;Vector
:线程安全的 (方法上加了synchronized
关键字);ArrayList
在底层数组不够用时,是扩容为原来的1.5倍,Vector
是扩容为原来的2倍。
ArrayList 与 LinkedList 区别?🌟🌟
- 是否保证线程安全:都是不同步的,都是非线程安全的;
- 底层数据结构:
ArrayList
底层使用的是Object[]
数组,LinkedList
底层使用的是双向链表。 - 插入和删除是否受元素位置影响:
ArrayList
采用数组存储,所以插入和删除元素的时间复杂度受元素位置影响。例如:- 执行
add(E e)
方法时,ArrayList
会默认将指定元素追加到末尾,此时时间复杂度为O(1); - 若是在指定位置
i
插入或删除元素add(int index, E element)
,时间复杂度就为 O(n-i)。因为在进行上述操作的时候集合中第 i 和第 i 个元素之后的(n-i)个元素都要执行向后位/向前移一位的操作。
- 执行
LinkedList
采用链表存储,- 1)所以 如果是在头尾插入或者删除元素不受元素位置的影响(add(E e)、addFirst(E e)、addLast(E e)、removeFirst() 、 removeLast()),时间复杂度为 O(1);
- 2)如果是要在指定位置
i
插入和删除元素的话(add(int index, E element),remove(Object o)), 时间复杂度为 O(n) ,因为需要先移动到指定位置再插入。
- 是否支持快速随机访问:
ArrayList
支持随机访问;LinkedList
不支持高效的随机元素访问;快速随机访问就是通过元素的序号快速获取元素对象(对应于get(int index)方法)。 - 内存空间占用:
ArrayList
的空间浪费主要体现在在 list 列表结尾会预留一定的容量空间;LinkedList
的空间花费则体现在它的每一个元素都需要消耗比 ArrayList 更多的空间(因为要存放直接前驱、直接后继和数据)。
双向链表:包含两个指针,一个 prev 指向前一个节点,一个 next 指向后一个节点。
比较 HashSet、LinkedHashSet 和 TreeSet 三者的异同?
- 同:都是
Set 接口
的实现类,都能保证元素唯一,并且都不是线程安全的。 - 区别:
- 1)底层数据结构不同。
HashSet
的底层数据结构是哈希表(基于 HashMap 实现)。LinkedHashSet
的底层数据结构是链表和哈希表,元素的插入和取出顺序满足 FIFO。TreeSet
底层数据结构是红黑树,元素是有序的,排序的方式有自然排序和定制排序。
- 2)元素顺序:
HashSet
:不维持元素顺序。LinkedHashSet
:元素顺序遵循插入顺序;TreeSet
:元素顺序遵循排序顺序。
- 3)是否允许 null 元素:
HashSet
和LinkedHashSet
都允许 null 元素;TreeSet
不允许 null 元素,而且当向 TreeSet 插入 null 元素时,TreeSet 使用 compareTo 方法与 null 元素进行比较,将会出现java.lang.NullPointerException
。
- 4)比较:
HashSet
和LinkedHashSet
使用equals 方法
来进行比较;TreeSet
使用compareTo 方法
进行比较来维持元素顺序。这就是为什么 compareTo 方法需要与 equals 方法实现保持一致的原因。当 compareTo 方法与 equals 方法实现不一致时,这违反了实现 Set 的特点(不允许元素重复)。
- 5)性能:
- HashSet 处理速度最快,其次是 LinkedHashSet(其处理速度几乎与 HashSet 相同),TreeSet 由于插入元素时需要排序,因此,TreeSet 处理速度稍慢。
HashSet > LinkedHashSet > TreeSet
。- 下表是关于增、删、判断元素是否存在三个方法的时间复杂度比较。HashSet、LinkedHashSet 由于利用 hash 函数将元素均匀分布到 bucket,其复杂度维持在 O(1);而 TreeSet 的复杂度维持在 O(log(n))。
- HashSet 处理速度最快,其次是 LinkedHashSet(其处理速度几乎与 HashSet 相同),TreeSet 由于插入元素时需要排序,因此,TreeSet 处理速度稍慢。
- 1)底层数据结构不同。
6、ConcurrentHashMap
ConcurrentHashMap 线程安全的具体实现方式/底层具体实现?
- ConcurrentHashMap底层:
- JDK1.8之前,是采用数组+链表组成。
ConcurrentHashMap
的主存是一个Segment数组
。Segment
继承了ReentrantLock类,所以是可重入锁,扮演锁的角色。它是对整个数组进行了分割分段(Segment,分段锁),每把锁只锁容器中一段数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。Segment
中存HashEntry 数组
,HashEntry
用于存储键值对数据。每个HashEntry
是链表结构。当对HashEntry
数组的数据进行修改时,必须首先获得对应的Segment
的锁。
- JDK1.8之后,数据结构是数组+链表/红黑二叉树 。
- 取消了
Segment 分段锁
,采用CAS + synchronized
来保证并发安全。链表长度超过一定阈值(8)时将链表(寻址时间复杂度为 O(N))转换为红黑树(寻址时间复杂度为 O(log(N)))。 - 锁粒度更细,
synchronized
只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,就不会影响其他 Node 的读写,效率大幅提升。
- 取消了
JDK1.8之前:
JDK1.8之后:
JDK 1.7 和 JDK 1.8 的 ConcurrentHashMap 实现有什么不同?
- 线程安全实现方式 :JDK 1.7 采用
Segment 分段锁
来保证安全, Segment 是继承自 ReentrantLock。JDK1.8 放弃了 Segment 分段锁的设计,采用Node + CAS + synchronized
保证线程安全,锁粒度更细,synchronized 只锁定当前链表或红黑二叉树的首节点。 - Hash 碰撞解决方法 : JDK 1.7 采用拉链法,JDK1.8 采用拉链法结合红黑树(链表长度超过一定阈值时,将链表转换为红黑树)。
- 并发度 :JDK 1.7 最大并发度是 Segment 的个数,默认是 16。JDK 1.8 最大并发度是 Node 数组的大小,并发度更大。
ConcurrentHashMap 和 Hashtable 的区别?🌟
- ConcurrentHashMap 和 Hashtable 的区别主要体现在实现线程安全的方式上不同。
- 底层数据结构: JDK1.7 的
ConcurrentHashMap
底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟 HashMap1.8 的结构一样,数组+链表/红黑二叉树。Hashtable
是采用 数组+链表 实现,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的; - 实现线程安全的方式(重要):
ConcurrentHashMap
:- 在 JDK1.7 的时候,
ConcurrentHashMap
底层采用 分段的数组+链表 实现,它是对整个数组进行了分割分段(Segment,分段锁),每把锁只锁容器中一段数据(下面有示意图),多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。 - JDK1.8时,
ConcurrentHashMap
底层采用 数组+链表/红黑二叉树 实现,采用CAS + synchronized
来保证并发安全。
- 在 JDK1.7 的时候,
Hashtable
(同一把锁):使用synchronized
来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。
HashTable:
JDK1.7 的 ConcurrentHashMap :ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成。
Segment 数组中的每个元素包含一个 HashEntry 数组,每个 HashEntry 数组属于链表结构。
JDK1.8 的 ConcurrentHashMap :Node 数组 + 链表 / 红黑树。不过,Node 只能用于链表的情况,红黑树的情况需要使用 TreeNode。当冲突链表达到一定长度时,链表会转换成红黑树。
7、Collections 工具类(不重要)
Collections 工具类常用方法:
- 排序
- 查找,替换操作
- 同步控制(不推荐,需要线程安全的集合类型时请考虑使用 JUC 包下的并发集合)
排序
void reverse(List list)//反转
void shuffle(List list)//随机排序
void sort(List list)//按自然排序的升序排序
void sort(List list, Comparator c)//定制排序,由Comparator控制排序逻辑
void swap(List list, int i , int j)//交换两个索引位置的元素
void rotate(List list, int distance)//旋转。当distance为正数时,将list后distance个元素整体移到前面。当distance为负数时,将 list的前distance个元素整体移到后面
查找,替换
int binarySearch(List list, Object key)//对List进行二分查找,返回索引,注意List必须是有序的
int max(Collection coll)//根据元素的自然顺序,返回最大的元素。 类比int min(Collection coll)
int max(Collection coll, Comparator c)//根据定制排序,返回最大元素,排序规则由Comparatator类控制。类比int min(Collection coll, Comparator c)
void fill(List list, Object obj)//用指定的元素代替指定list中的所有元素
int frequency(Collection c, Object o)//统计元素出现次数
int indexOfSubList(List list, List target)//统计target在list中第一次出现的索引,找不到则返回-1,类比int lastIndexOfSubList(List source, list target)
boolean replaceAll(List list, Object oldVal, Object newVal)//用新元素替换旧元素
同步控制
Collections 提供了多个synchronizedXxx()方法·,该方法可以将指定集合包装成线程同步的集合,从而解决多线程并发访问集合时的线程安全问题。
我们知道 HashSet,TreeSet,ArrayList,LinkedList,HashMap,TreeMap 都是线程不安全的。Collections 提供了多个静态方法可以把他们包装成线程同步的集合。
最好不要用下面这些方法,效率非常低,需要线程安全的集合类型时请考虑使用 JUC 包下的并发集合。
synchronizedCollection(Collection<T> c) //返回指定 collection 支持的同步(线程安全的)collection。
synchronizedList(List<T> list)//返回指定列表支持的同步(线程安全的)List。
synchronizedMap(Map<K,V> m) //返回由指定映射支持的同步(线程安全的)Map。
synchronizedSet(Set<T> s) //返回指定 set 支持的同步(线程安全的)set。
对线程安全的理解?🌟
链接: 线程安全怎么保障
- 不是线程安全,应该是内存安全。
- 当多个线程时,如果不用进行额外的同步控制或其他的协调操作,调用这个对象的行为都可以获得正确的结果,我们就说这个对象是线程安全的。
- 堆是Java虚拟机管理的内存中最大的一块,是线程共享的一块内存区域,进程的所有线程都可以访问到该区域,这是造成问题的潜在原因。
- 栈是线程私有的一块内存区域,每个线程都有其栈空间,并且一个线程无法访问其他线程的栈空间。因此,栈是线程安全的。