Java基础
一、Java基础
Java语言的特点
- Java语言能跨平台,即一次编写,处处运行,java编译器将源代码编译成字节码,该字节码能够在任何安装了java虚拟机(JVM)的系统上运行
- 面向对象:java语言是面向对象的语言,面向对象特性使得java代码更易于维护和复用
- 内存管理:java有自己的垃圾回收机制,自动管理内存和回收不再使用的对象,从而减少内存泄露和其他内存相关问题
- 支持多线程
- 支持JIT编译(即时编译器),它可以在程序运行时将字节码转换为本地机器码来提高程序运行速度
Java为什么是跨平台的
java编译器将java源代码编译成字节码文件,JVM负责将字节码文件翻译成特定平台下的机器码然后运行,也就是说,只要在不同的平台上安装对应的JVM,就可以运行字节码文件,运行我们编写的java程序
JVM、JDK、JRE三者关系
JVM(JavaVirtualMachine):java虚拟机
Java程序运行的环境,负责将Java字节码(由Java编译器生成)解释或编译成机器码,并执行程序
JDK(JavaDevelopmentKit):java开发工具包
JDK = JRE + java的开发工具(Java、Javac、Javadoc、Javap等等)
JRE(JavaRuntimeEnvironment):java运行环境
JRE = JVM + Java基础类库,基础类类库如java.lang包、java.util包
Java是解释和编译共存
- 编译型语言:在程序执行之前,整个源代码会被编译成机器码或者字节码,生成可执行文件。执行时直接运行编译后的代码,速度快,但跨平台性较差
- 解释型语言:在程序执行时,逐步解释执行源代码,不生成独立的可执行文件。通常由解释器动态解释并执行代码,跨平台性好,但执行速度相对较慢
- Java程序要经过先编译,后解释两个步骤,由java编写的程序需要先经过编译步骤,生成字节码文件,这种字节码必须由java解释器来解释执行(为了提高效率,会配合JIT即时编译器执行)
面向对象三大特性
封装:封装是把一个对象的状态信息隐藏在对象内部,不允许外部对象直接访问对象的内部信息,通过提供一些可以被外界访问的方法来对状态信息进行操作
功能:
- 良好的封装能够减少耦合
- 隐藏对象内部的复杂性,只对外公开简单的接口。便于外界调用,从而提高系统的可扩展性、可维护性以及安全性
- 可以对成员变量进行更精准的控制
继承:继承是使用已存在的类(父类)作为基础建立新类(子类)的技术、子类继承父类非私有的属性和行为,使得子类对象具有与父类相同的属性、相同的行为
功能:
- 提高代码的可复用性
多态:同一行为发生在不同的对象上会产生不同的结果
- 多态的具体体现
- 方法重载:方法重载是指同一个类中可以有多个同名方法,它们具有不同的参数列表(参数类型、参数个数、参数顺序不同),虽然方法名相同,但根据传入的参数不同,编译器会在编译时确定调用哪个方法
- 方法重写:方法重写是指子类能够提供对父类中同名方法的具体实现,在运行时,JVM会根据对象的实际类型确定调用哪个版本的方法,这是实现多态的主要方式
- 接口与实现:多个类可以实现同一个接口,并且用接口类型的引用来调用这些类的方法
- 向上转型和向下转型:在Java中,可以使用父类类型的引用指向子类对象,这是向上转型,向下转型是将父类引用转回其子类类型
- 重载和重写的区别
- 重载
- 方法名必须相同
- 形参列表必须不同(参数类型、个数、顺序,这其中至少一个不同)
- 返回值:无要求
- 方法重载发生在同一个类中
- 重写
- 子类方法的参数、方法名称要和父类方法完全一致
- 子类方法的返回类型需和父类方法一致,或者是父类方法返回类型的子类
- 子类方法不能缩小父类方法的访问范围(访问修饰符)
- 方法重写发生在子类和父类之间
Java数据类型
Java支持数据类型分为两类: 基本数据类型和引用数据类型。
基本数据类型共有8种,分为4类:
- 整型(byte、short、int、long)
- 浮点型(float、double)
- 字符型(char)
- 布尔型(boolean)
数据类型 关键字 内存占用/字节 默认值 取值范围 最小值符号 最大值符号 字节型 byte 1 0 -128~+127 Byte.MIN_VALUE Byte.MAX_VALUE 短整型 short 2 0 -32768~+32767 Short.MIN_VALUE Short.MAX_VALUE 整型 int 4 0 -231~ 231-1 Integer.MIN_VALUE Integer.MAX_VALUE 长整型 long 8 0L -263~263-1 Long.MIN_VALUE Long.MAX_VALUE 单精度浮点型 float 4 0.0f 1.4E-45~3.4028235E38 Float.MIN_VALUE Float.MAX_VALUE 双精度浮点型 double 8 0.0 4.9E-324~1.7977E+308 Double.MIN_VALUE Double.MAX_VALUE 字符型 char 2 '\u0000' 0~65535 Character.MIN_VALUE Character.MAX_VALUE 布尔型 Boolean 1或4 false true、false * * 注意:
- Java没有任何无符号形式的int、long、short或byte类型,但可以通过其包装类转换得到
- 整数的默认类型为int(声明Long型在末尾加上l或者L)
- 浮点数的默认类型为double(如果需要声明一个常量为float型,则必须在末尾加上f或者F)
- 八种基本数据类型的包装类:除了char是Character、int类型是Integer,其他都是首字母大写
- char类型是无符号的,不能为负,所以是0开始的
引用数据类型包括类(class)、接口(interface)和数组([])
包装类型和基本类型
基本数据类型和包装类型的区别:
- 默认值不同:包装类型的默认值为null,基本类型的默认值不为null,如int为0,boolean为false
- 声明的方式不同:基本类型不需要new关键字,包装类型需要new关键字创建对象分配内存空间
- 包装类型可用于泛型,基本类型不可以
- 存储位置不同:基本数据类型字节将值保存在值栈中,包装类型是把对象放在堆中,然后通过对象的引用来调用他们
自动类型转换和强制类型转换
Java中所有的数值型变量都可以相互转换,当把一个表数范围小的数值或变量直接赋给另一个表数范围大的变量时,可以进行自动类型转换,反之需要强制转换
自动类型转换使用细节
- 有多种类型数据混合运算时,系统会将所有数据转换成容量最大的那种,再进行运算
- 如若把大精度数据赋值给小精度类型,就会报错(小数由于精度问题,大赋小会丢失精度,必不可用),但整数大赋小时:1.赋予具体数值时,判断范围 2.变量赋值时,判断类型。反之进行自动类型转换
- byte、short、char三者不会相互自动转换,但可以计算,计算时首先转化为int
- boolean类型不参与自动转换
- 自动提升原则:表达式结果的类型自动提升为操作数中最大的类型
强制类型转换使用细节:
当进行数据从大到小转换时,用强制转换
强制类型转换只对最近的操作数有效,往往会使用()提升优先级
char可以保留int的常量值,但不能保存其变量值,此时需要强制类型转换
int a = 10; char b = 10; char c = (char)a;byte、short、char在进行运算时,当作int处理
&和&&有什么区别
- &&(短路与):如果&&左边的表达式的值是 false,右边的表达式会被直接短路掉,不会进行运算
- &(逻辑与):不管&左边的表达式的值是 false还是true,右边的表达式都会进行运算
- 例如在验证用户登录时判定用户名不是 null 而且不是空字符串,应当写为
username != null &&!username.equals(""),二者的顺序不能交换,更不能用&运算符,因为第一个条件如果不成立,根本不能进行字符串的 equals 比较,否则会产生 NullPointerException 异常。
switch是否能作用在byte/long/String上
- Java5 以前 switch(expr)中,expr 只能是 byte、short、char、int
- 从Java 5 开始,Java 中引入了枚举类型, expr 也可以是 enum 类型
- 从Java 7 开始,expr 还可以是字符串(String),但是长整型(long)在目前所有的版本中都是不可以的
对于自增自减运算符的理解
当运算符放在变量之前时(前缀),先自增/减,再赋值;当运算符放在变量之后时(后缀),先赋值,再自增/减
对于 JVM 而言,它对自增运算的处理,是会先定义一个临时变量来接收 i 的值,然后进行自增运算,最后又将临时变量赋给i
int i = 10; int j = ++i; //等价于 i = i + 1; j = i; 此时 i = 10; j = 10 int k = i++; //等价于 k = i; i = i + 1; 此时 i = 11; k = 10 i = 10; i = i++; //系统会先后执行 int temp = i; i = i + 1; i = temp i = ++i; //系统会先后执行 i = i + 1; int temp = i; i = temp
字符编码表
- ASCII 编码表,占用 1 byte,共有 128 个字符
- Unicode 编码表,占用 2 byte,字母汉字都占用 2 byte,这样可能浪费空间。0 - 127 的字符与 ASCII 相同,所以兼容 ASCII
- UTF-8 编码表,根据不同符号大小可变(1 - 6 byte),字母占用 1 byte,汉字占用 3 byte。是 Unicode 的改进,是互联网上使用最广的 Unicode 实现方式
- GBK 编码表,可以表示汉字,字母占用 1 byte,汉字占用 2 byte
- GB2312 编码表,可以表示汉字(GB2312 < GBK)
==和equals的区别
==:
- 对于基本数据类型,比较的是数值是否相等
- 对于引用数据类型,比较的是引用(地址)是否相同
equals:
- Object类中的equals的实现用的是this == obj,而所有引用数据类型均继承了Object类,因而默认情况下,equals方法的作用和 == 比较引入数据类型的规则一致,均比较的引用(地址)是否相等。
- 通常情况下,我们想要比较的是数值(属性)是否相等,因而会对equals进行重写,满足自身要求,如String、包装类等都重写了Object中的equals()方法,重写后,比较的不是两个引用的地址是否相同,而是比较两份对象的''实体内容''是否相同
重写equals()时为什么要重写hashCode()
在HashSet和HashMap等集合中,为了提升增删改查的效率,在存入数据时,一般首先根据对象的hashcode方法计算对象应该存储的位置,然后再根据equals方法判断同一个位置上的对象是否相等。如果我们重写了equals方法,比较的是对象的属性是否相等,此时如果不重写hashcode方法,可能导致两个属性值完全相等的对象通过equal方法判断是相等的,但是通过hashcode方法判断可能是不相等的,此时就出现了问题,本来equals判断相等的两个对象竟然都被存放到了set集合中!这是我们不希望看到的!
因此当我们重写equals()方法时,最好也要重写一下hashCode(),一般规定:
- equals相等,hashCode应该也相等
- hashCode相等,equals不一定相等
Java方法参数是值传递还是引用传递
值传递:是指在调用函数时将实际参数复制一份传递到函数中,这样在方法中如果对参数进行修改,将不会影响到实际参数
引用传递:是指在调用函数时将实际参数的地址直接传递到函数中,那么在方法中对参数所进行的修改,将影响到实际参数
Java里方法的参数传递方式只有一种:值传递
- 如果形参是基本数据类型,传递的是基本类型的字面量值的拷贝
- 如果形参是引用数据类型,传递的是该参量所引用的对象在堆中地址值的拷贝
成员变量与局部变量的区别
| 在类中的位置 | 作用范围 | 初始化值 | 修饰符 | 内存位置 | |
|---|---|---|---|---|---|
| 成员变量 | 类中,方法外 | 类中 | 有默认值(数值为0,布尔值为false,引用为null) | public、private、final等 | 堆区 |
| 局部变量 | 方法中或者方法声明上(形式参数) | 方法中 | 没有默认值,必须先定义,再赋值,最后使用 | 不能用权限修饰符修饰,可以用final修饰 | 栈区 |
Java权限修饰符区别
| 修饰符 | 同一个类 | 同一个包 | 不同包的子类 | 不同包的非子类 |
|---|---|---|---|---|
| public | √ | √ | √ | √ |
| protected | √ | √ | √ | |
| default(缺省) | √ | √ | ||
| private | √ |
this和super的区别
| 关键字 | 访问成员变量 | 调用成员变量 | 调用构造方法 |
|---|---|---|---|
| this | 访问本类中的成员变量,如果本类没有,则从父类中继续查找 | 访问本类中的成员方法,如果本类没有,则从父类中继续查找 | 访问本类中的构造方法 |
| super | 直接访问父类中的成员变量 | 直接访问父类中的成员方法 | 访问父类中的构造方法 |
抽象类和接口的对比
- 抽象类中可以定义构造函数,接口不能定义构造函数
- 抽象类中可以有抽象方法和具体方法,而接口中只能有抽象方法(public abstract)
- 抽象类中的成员权限可以是public、默认、protected(抽象类中抽象方法就是为了重写,所以不能被private修饰),而接口中的成员只可以是public(方法默认:public abstrat、成员变量默认:public static final)
- 抽象类中可以包含静态方法,而接口中也可以包含静态方法
static关键字
| 修饰对象 | 作用 |
|---|---|
| 变量 | 静态变量,类级别变量,所有实例共享同一份数据 |
| 方法 | 静态方法,类级别方法,与实例无关 |
| 代码块 | 在类加载时初始化一些数据,只执行一次 |
| 内部类 | 与外部类绑定但独立于外部类实例 |
| 导入 | 可以直接访问静态成员,无需通过类名引用,简化代码书写,但会降低代码可读性 |
String、StringBuilder、StringBuffer的区别
- String:不可变,线程安全
- StringBuilder:可变,线程不安全
- StringBuffer:可变,线程安全
- 性能:String<StringBuffer<StringBuilder
为什么String不可变
- String为什么不可变?
- 保存字符串的数组被final修饰且为私有的,并且String类没有提供/暴露修改这个字符串的方法
- String类被final修饰导致其不能被继承,进而避免了子类破坏String不可变
- 不可变的好处?
- 因为String的hash值经常被使用,例如String用做HashMap的key。不可变的特性可以使得hash值也不可变,因此只需要进行一次计算。在String类的定义中hash属性用于缓存hashcode
- 如果一个String对象已经被创建过了,那么就会从字符串常量池中取得引用。只有String是不可变的,才可能使用字符串常量池,如果字符串是可变的,某一个字符串变量改变了其值,那么其指向的变量的值也会改变,字符串常量池也就不是『常量』池了,就成了变量池
- String不可变性天生具备线程安全,可以在多个线程中安全地使用(因为没有一个线程可以修改其内部状态和数据,且其内部状态和数据也不会自行发生改变)
String str1 = new String("abc")和String str2 = "abc"的区别
直接使用双引号为字符串变量赋值时,Java 首先会检查字符串常量池中是否已经存在相同内容的字符串。如果存在,Java 就会让新的变量引用池中的那个字符串;如果不存在,它会创建一个新的字符串,放入池中,并让变量引用它
使用new String("abc")的方式创建字符串时,实际分为两步:
- 第一步,先检查字符串字面量"abc"是否存在字符串常量池中,如果没有则创建一个,如果已经存在,则引用它
- 第二步,在堆中再创建一个新的字符串对象,并将其初始化为字符串常量池中"abc"的一个副本
也就是说:
String s1 = "aaa"; String s2 = "aaa"; String s3 = new String("aaa"); System.out.println(s1 == s2);//输出为true,因为s1和s2引用的是字符串常量池中同一个对象 System.out.println(s1 == s3);//输出为false,因为s3是通过new关键字显示创建的,指向堆上不同的对象
String s = new String("abc")创建了几个对象
- 字符串常量池如果存在"abc"字符串,则常量池中不再创建该字符串,由于使用了 new 关键字,故在堆中创建"abc"的字符串对象,并指向该对象,共创建一个对象
- 字符串常量池如果不存在"abc"字符串,则常量池中创建该字符串,由于使用了 new 关键字,故在堆中创建"abc"的字符串对象,并指向该对象,共创建两个对象
字符串拼接是如何实现的
常量相加,看的是常量池
String str1 = "aa" + "bb"; //常量相加,看的是常量池上例由于构造器自身优化,相当于String str1 = "aabb";
变量相加,是在堆中
String a = "aa"; String b = "bb"; String str2 = a + b; //变量相加,是在堆中上例的底层是如下代码
StringBuilder sb = new StringBuilder(); sb.append(a); sb.append(b); str2 = sb.toString();
intern方法的作用
- 如果当前字符串内容存在于字符串常量池,直接返回字符串常量池中的字符串的引用
- 如果当前字符串内容不存在于字符串常量池,则将该字符串添加到字符串池中,并返回新加入字符串的引用
final、finally、finalize区别
- final:用于声明属性、方法和类,分别表示属性不可变、方法不可重写、被其修饰的类不可继承,如果修饰的是基本数据类型的变量,其数值一旦在初始化之后就不能更改,如果是引用类型的变量,在对其初始化之后就不能再让其指向另一个对象,但是引用指向的对象内容可以改变
- finally:异常处理语句结构的一部分,表示总是执行
- finalize:Object类的一个方法,在垃圾回收时会调用被回收对象的finalize方法,用于在垃圾回收器将对象从内存中清除出去之前做一些必要的清理工作
深克隆和浅克隆
- 浅克隆:被Clone的对象的所有变量都含有原来对象相同的值,而引用变量还是原来的引用【拷贝对象时仅仅拷贝对象本身(包括对象中的基本变量),而不拷贝对象包含的引用指向的对象。】
- 深克隆:被克隆对象的所有变量都含有原来的对象相同的值,引用变量也重新复制了一份【不仅拷贝对象本身,而且拷贝对象包含的引用指向的所有对象】
反射
什么是反射:Java的反射(reflection)机制是指在程序的运行状态中,可以构造任意一个类的对象,可以了解任意一个对象所属的类,可以了解任意一个类的成员变量和方法,可以调用任意一个对象的属性和方法。这种动态获取程序信息以及动态调用对象的功能称为Java语言的反射机制。反射被视为动态语言的关键
反射的应用场景:
- 通过反射运行配置文件内容(从而实现动态配置,而不是在代码中写死)
- 与注解相结合,如使用拦截器判断某个方法上是否有某注解,根据返回值执行不同的操作
- 动态创建对象,如Spring的IOC使用反射创建对象
- 动态代理,如Spring的AOP底层使用了动态代理,而动态代理底层需要使用反射
反射为什么耗性能:
- 反射调用方法时会从方法数组中遍历查找,并且进行安全检查,访问控制等操作会耗时
- 反射涉及了动态类型的解析,所以JVM无法对这些代码进行优化
- 反射调用过程中会产生大量的临时对象,这些对象会占用内存,可能会导致频繁gc,从而影响性能
反射的原理
- Java 程序的执行分为编译和运行两步,编译之后会生成字节码(.class)文件,JVM 进行类加载的时候,会加载字节码文件,将类型相关的所有信息加载进方法区,反射就是去获取这些信息,然后进行各种操作
获取Class字节码文件对象的三种方式:
Class.forName("全类名")
Class clazz1 = Class.forName("com.itheima.myreflect1.Student");最常用的方法
类名.class
Class clazz2 = Student.class;一般更多的是当作参数进行传递
对象.getClass()
Student s = new Student(); Class clazz3 = s.getClass()当我们已经有这个类的对象时,才可以使用
反射相关类
Java序列化
什么是序列化:对象序列化是一个用于将对象状态转换为字节流的过程,可以将其保存到磁盘文件中或通过网络发送到任何其他程序。而创建的字节流是与平台无关的,在一个平台上序列化的对象可以在不同的平台上反序列化,从字节流创建对象的相反的过程称为反序列化,序列化是为了解决在对象流进行读写操作时所引发的问题
序列化的实现:将需要被序列化的类实现Serializable接口,该接口没有需要实现的方法,只是用于标注该对象是可被序列化的,然后使用一个输出流(如:FileOutputStream)来构造一个ObjectOutputStream对象,接着使用ObjectOutputStream对象的writeObject(Objectobj)方法可以将参数为obj的对象写出,要恢复的话则使用输入流
Java序列化不包含静态变量吗
- 是的,序列化机制只会保存对象的状态,而静态变量属于类的状态,不属于对象的状态
如果有些变量不想序列化,怎么办
- 可以使用transient关键字修饰不想序列化的变量
解释一下序列化的过程和作用
第一步,实现Serializable接口
public class Person implements Serializable { private String name; private int age; // 省略构造方法、getters和setters }第二步,使用ObjectOutputStream来将对象写入输出流中
ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("person.ser"));第三步,调用ObjectOutputStream的writeObject方法,将对象序列化并写入到输出流中
Person person = new Person("沉默王二", 18); out.writeObject(person);说说有几种序列化方式
- Java 对象序列化 :Java 原生序列化方法即通过 Java 原生流(InputStream 和 OutputStream 之间的转化)的方式进行转化,一般是对象输出流 ObjectOutputStream和对象输入流ObjectInputStream
- Json 序列化:这个可能是我们最常用的序列化方式,Json 序列化的选择很多,一般会使用 jackson 包,通过 ObjectMapper 类来进行一些操作,比如将对象转化为 byte 数组或者将 json 串转化为对象
- ProtoBuff 序列化:ProtocolBuffer 是一种轻便高效的结构化数据存储格式,ProtoBuff 序列化对象可以很大程度上将其压缩,可以大大减少数据传输大小,提高系统性能
for、foreach、Stream的区别
- for循环是通过小标来遍历获取对应的元素
- foreach循环是通过迭代器来遍历的
- stream循环是通过可分迭代器Spliterator来遍历,分为并行流和普通的顺序流
- 性能对比:
- 循环ArrayList时,for > foreach >> stream
- 循环LinkedList时,stream=foreach > >for
Java中long和int可以互转吗
- 由于long类型的数据范围比int类型大,因此将int转换为long是安全的
- 将long转换为int可以通过强制类型转换来实现,但需要注意潜在的数据丢失或溢出问题,如果超出int范围,转换后的结果是截断后的低位部分
数据类型转换方式
- 自动类型转换(隐式转换):当目标类型的范围大于源类型时,Java会自动将源类型转换为目标类型,不需要显式的类型转换
- 强制类型转换(显式转换):当目标类型的范围小于源类型时,需要使用强制类型转换将源类型转换为目标类型,这可能会导致数据丢失或溢出
- 包装类型的转换:例如将字符串转换为整型int的Integer.parseInt()方法
String如何转成Integer
- String转成Integer,主要有两个方法
- Integer.parseInt(String s):主要用于将字符串解析为原始类型的int值,返回一个基本数据类型的int值,如果提供的字符串无法被正确解析为整数,则抛出NumberFormatException
- Integer.valueOf(String s):不仅将字符串解析为int值,还可以直接创建并返回对应的Integer对象,返回一个Integer对象,与parseInt一样,如果提供的字符串无法被正确解析为整数,则抛出NumberFormatException。对于一定范围内的整数值(通常是从-128到127),valueOf方法会从内部缓存池中返回现有的Integer实例,而不是每次调用时都创建新的对象,者有助于节省内存和提高性能
为什么使用BigDecimal而不用double
- double会出现精度丢失的问题,double执行的是二进制浮点数运算,二进制有些情况下不能准确的表示一个小数
- 使用BigDecimal可以确保精度的十进制数值计算,避免了使用double可能出现的舍入误差,需要注意的是,在创建BigDecimal对象时,应该使用字符串作为参数,而不是直接使用浮点数值,以避免浮点数精度丢失
装箱和拆箱
什么是装箱和拆箱
- 装箱是基本数据类型转换为对应的包装类型的过程
- 拆箱是包装类型转换为对应的基本数据类型的过程
//JDK5以前 Integer a = Integer.valueOf(3);//装箱 int b = a.intValue();//拆箱 //JDK5以后 Integer a1 = 3;//自动装箱 int b1 = a1;//自动拆箱
自动装箱的弊端:
在循环中进行自动装箱操作时,会创建多余的对象,影响程序的性能
Integer sum = 0; for(int i = 1000,i < 5000,i++){ sum += i; } //循环等价于 /* int result = sum.intValue() + i; Integer sum = new Integer(result); */在上面的循环中会创建近4000个无用的Integer对象,会降低程序的性能并且加重垃圾回收的工作量
虽然可以自动装箱、拆箱,但使用 == 直接比较两个包装类时,仍然是比较其地址。以下比较通常会失败:
Integer ia = 1000; Integer ib = 1000; System.out.print(ia == ib);// false但Java 实现仍有可能使其成立。Byte、Boolean 以及 Short、Integer 中 [-128, 127] 间的值已被包装到固定的对象中。对他们的比较可以成功。
Integer ia = 127; Integer ib = 127; System.out.print(ia == ib); // true
Java为什么要有包装类
- 对象封装有很多好处,可以把数据和处理这些数据的方法结合到一起,比如Integer就有parseInt()等方法来专门处理int型相关的数据
- 在Java中绝大部分方法或类都是用来处理类类型对象的,如ArrayList集合类就只能以类作为他的存储对象,所以包装类的存在是很有必要的
面向对象设计原则
- 单一职责原则(SRP):一个类应该只有一个引起它变化的原因,即一个类应该只负责一项职责。例子:考虑一个员工类,它应该只负责管理员工信息,而不应该负责其他无关工作
- 开放封闭原则(OCP):软件实体应该对扩展开放,对修改封闭。例子:通过制定接口来实现这一原则,比如定义一个图形类,然后让不同类型的图形继承这个类,而不需要修改图形类本身
- 里氏替换原则(LSP):子类对象应该能够替换掉所有父类对象。例子:一个正方形是一个矩形,但如果修改一个短形的高度和宽度时,正方形的行为应该如何改变就是一个违反里氏替换原则的例子
- 接口隔离原则(ISP):客户端不应该依赖那些它不需要的接口,即接口应该小而专。例子:通过接口抽象层来实现底层和高层模块之间的解耦,比如使用依赖注入
- 依赖倒置原则(DIP):高层模块不应该依赖低层模块,二者都应该依赖于抽象;抽象不应该依赖于细节,细节应该依赖于抽象。例子:如果一个公司类包含部门类,应该考虑使用合成/聚合关系,而不是将公司类继承自部门类
- 最少知识原则 (Law of Demeter):一个对象应当对其他对象有最少的了解,只与其直接的朋友交互
抽象类能加final修饰吗
- 不能,Java中的抽象类是用来被继承的,而final修饰符用于禁止类被继承或方法被重写,因此,抽象类和final修饰符是互斥的,不能同时使用
解释Java中的静态变量和静态方法
- 静态变量
- 共享性:所有该类的实例共享同一个静态变量,如果一个实例修改了静态变量的值,其他实例也会看到这个更改
- 初始化:静态变量在类被加载时初始化,只会对其进行一次分配内存
- 访问方式:静态变量可以直接通过类名访问,也可以通过实例访问,但推荐使用类名
- 静态方法
- 无实例依赖:静态方法可以在没有创建类实例的情况下调用。对于静态方法来说,不能直接访问非静态的成员变量或方法,因为静态方法没有上下文的实例
- 访问静态成员:静态方法可以直接调用其他静态变量和静态方法,但不能直接访问非静态成员
- 多态性:静态方法不支持重写,但可以被隐藏
在创建一个对象时,在有继承关系的多个类里代码调用顺序
- 父类静态代码块和静态初始化
- 子类静态代码块和静态初始化
- 父类普通代码块和普通初始化
- 父类构造器
- 子类普通代码块和普通初始化
- 子类构造器
泛型
- 什么是泛型
- 泛型是Java语言中的一个重要特性,它允许类、接口和方法在定义时使用一个或多个类型参数,这些类型参数在使用时可以被指定为具体的类型
- 泛型的主要目的是在编译时提供更强的类型检查,并且在编译后能够保留类型信息,避免了在运行时出现类型转换异常
注解
注解(Annotation)也被称为元数据(Metadata)。用于修饰 包、类、方法、属性、构造器、局部变量 等数据信息,和注释一样,注解不影响程序逻辑,但注解可以被编译或运行,相当于嵌入在代码中的补充信息
三个基本的 @Annotation:
- @Override:限定某个方法,是 重写 父类方法。该注解只能用于方法。如果你写了该注解,编译器会替你校验,看看是不是真的 重写 了父类方法
- @Deprecated:用于表示某个程序元素(类、方法等)已经过时
- @SuppressWarnings():抑制编辑器警告
JDK 的 元注解 是用于修饰其他注解的注解
@Rentention:指定注解的作用范围,有三种范围SOURCE、CLASS、RUNTIME
- RententionPolicy.SOURCE:编译器使用后,直接丢弃这种策略的注释
- RententionPolicy.CLASS:编译器把注解记录在 class 文件中。当运行 Java 程序时, JVM 不会保留注释。这是默认值
- RententionPolicy.RUNTIME:编译器把注解记录在 class 文件中。当运行 Java 程序时,JVM 会保留注解。程序可以通过反射获取该注解
@Target:指定注解的使用范围
@Target({TYPE, FIELD, METHOD, PARAMETER, CONSTRUCTOR, LOCAL_VARIABLE})@Documented:指定该注解会不会在 Javadoc 体现
@Inherited:子类会继承父类注解
Java创建对象有哪几种方式
使用new关键字:通过new关键字直接调用类的构造方法来创建对象
MyClass obj = new MyClass();使用Class类的newInstance()方法:通过反射机制,可以使用Class类的newInstance()方法创建对象
MyClass obj = (MyClass)Class.forName("com.example.MyClass").newInstance();使用Constructor类的newInstance()方法:同样是通过反射机制,可以使用Constructor类的newInstance()方法创建对象
Constructor<MyClass> constructor = MyClass.class.getConstructor(); MyClass obj = constructor.newInstance();通过clone()方法:如果类实现了Cloneable接口,可以使用clone()方法复制对象
MyClass obj1 = new MyClass(); MyClass obj2 = (MyClass)obj1.clone();使用反序列化:通过将对象序列化到文件或流中,然后再进行反序列化来创建对象
ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("object.ser")); out.writeObject(obj); out.clone(); ObjectInputStream in = new ObjectInputStrean(new FileInputStream("object.ser")); MyClass obj = (MyClass)in.readObject(); in.close();反射机制创建:反射允许在运行时创建对象,并且可以访问类的私有成员,在框架和工具类中比较常见
Class clazz = Class.forName("Person"); Person person = (Person)clazz.new Instance();
New出来的对象什么时候回收
- 通过关键字new创建的对象,由Java的垃圾回收器负责回收,垃圾回收器的工作是在程序运行过程中自动进行的,它会周期性地检查不再被引用的对象,并将其回收释放内存
RPC框架了解吗
- RPC(远程过程调用)是一种协议,允许程序调用位于远程服务器上的方法,就像调用本地方法一样。RPC 通常基于 Socket 通信实现
- RPC 框架支持高效的序列化(如 Protocol Buffers)和通信协议(如 HTTP/2),屏蔽了底层网络通信的细节,开发者只需关注业务逻辑即可
- 常见的RPC框架包括:
- gRPC:基于 HTTP/2 和 Protocol Buffers
- Dubbo:阿里开源的分布式 RPC 框架,适合微服务场景
- Spring Cloud OpenFeign:基于 REST 的轻量级 RPC 框架
- Thrift:Apache 的跨语言 RPC 框架,支持多语言代码生成
二、Java异常
Java异常
介绍下Java异常
- Java异常体系主要基于两大类:Throwable类及其子类,Throwable有两个重要的子类:Error(错误)和Exception(异常)
- Error(错误):Java虚拟机无法解决的严重问题,如JVM系统内部错误,资源耗尽等严重情况,Error是严重错误,程序会崩溃
- Exception(异常):其他因编程错误或偶然的外部因素导致的一般性问题,可以使用针对性的代码进行处理
- 运行时异常:编译器不要求强制处置的异常。一般是指编程的逻辑错误,是程序员应该避免其出现的异常,java.lang.RuntimeException 类及它的子类都是运行时异常
- 编译时异常:是编译器要求必须处置的异常
- 常见的运行时异常:
- NullPointerException:空指针异常
- ArithmeticException:数学运算异常
- ArrayIndexOutOfBoundsException:数组下标越界异常
- ClassCastException:类型转换异常
- NumberFormatException:数学格式异常
- 常见的编译异常:
- SQLException:操作数据库时,查询表可能发生异常
- IOException:操作文件时,发生的异常
- FileNotFoundException:操作一个不存在的文件时,发生的异常
- ClassNotFoundException:加载类,而该类不存在时,发生的异常
- EOFException:操作文件,到文档末尾,发生的异常
- IllegalArguementException:参数异常
异常处理方式
try-catch异常处理:java提供 try 和 catch 块 来处理异常。try 块用于包含可能出错的代码,catch 块用于处理 try 块中的异常。可以根据需要在程序中有多个 try - catch 块
- try-catch使用细节:
- 如果异常发生了,则异常发生后面的代码块都不执行,直接进入 catch 块
- 如果异常未发生,则顺序执行 try 代码块,catch 块不执行
- 如果希望不管是否异常,都执行一些代码,则使用 finally
- 可以有多个 catch 捕获不同的异常。要求 子类异常在前,父类异常在后
- 可以进行 try - finally 配合使用(不写 catch)。这种用法相当于没有捕获异常,此时程序如果出错会直接退出
- 如果没有出现异常,执行 try 中所有语句,不执行 catch 语句,最后执行 finally 语句
- 如果出现异常,则 try 块异常发生后,剩余语句不执行。之后执行 catch 语句,最后,执行 finally 语句
throws异常处理:如果一个方法可能生成某种异常,但是并不能确定如何处理这种异常,则此方法应显式地声明抛出异常,表明该方法将不对这些异常进行处理,而由调用者负责处理,throws 后面的异常类型可以是方法中产生的异常类型,也可以是它的父类
- throws使用细节:
- 对于编译异常,程序中必须处理
- 对于运行异常,程序中若没有处理,默认处理是 throws
- 子类重写父类方法时,子类方法抛出的异常类型必须和父类一致,或者是父类抛出异常类型的子类型
- 如果有try - catch就不必throws了
意义 位置 后面跟的东西 throws 异常处理的一种方式 方法声明时 异常类型 throw 手动生成异常对象关键字 方法体中 异常对象 异常处理代码分析题:
public class TryDemo{ public static void main(String[] args){ System.out.println(test1());//输出结果为2 } public static int test1(){ int i = 0; try{ i = 2; return i; }finally { i = 3; } } }在执行finally之前,JVM会将i的结果暂存起来,然后finally执行完毕后,会返回之前暂存的结果,而不是返回i,所以即时i已经被修改为了3,最终返回的还是之前暂存起来的结果2
三、Java集合
常见的集合框架
Collection(单列集合):主要由List、Set、Queue组成
- List代表有序、可重复的集合,典型代表就是封装了动态数组的ArrayList、封装了链表的LinkedList和封装了动态数组的Vector
- ArrayList线程不安全,效率高,底层使用Object[]elementData存储
- LinkedList插入和删除效率高,底层使用双向链表存储
- Vector线程安全,效率低,底层使用Object[]elementData存储
- Set代表有序、不可重复的集合,典型代表就是HashSet和TreeSet
- HashSet线程不安全,可以存储null值
- TreeSet可以按照添加对象的指定属性,进行排序
- Queue代表队列,典型代表就是双端队列ArrayDeque,以及优先队列PriorityQueue
Map(双列集合):代表键值对的集合,典型代表就是HashSet、TreeSet和HashTable
- HashMap线程不安全,效率高,可以存储null的key和value
- TreeSet可以对添加的key-value进行排序,实现排序遍历,底层使用红黑树,增删改查的平均和最差时间复杂度均为O(longn)
- Hashtable线程安全,效率低,不能存储null的key和value,Hashtable实现原理、功能和HashMap类似
List相关知识
1.ArrayList、LinkedList、Vector比较
底层结构 增删效率 改查效率 线程安全性 ArrayList 可变数组 低(数组扩容) 高 线程不安全 LinkedList 双向链表 高(链表追加) 低 线程不安全 Vector 可变数组 低(数组扩容) 高 线程安全 2.ArrayList、Vector的扩容机制(LinkedList不需要扩容)
- ArrayLIst扩容机制:
- 创建ArrayList对象时,如果使用无参构造器,则elementData初始容量为0
- 如果使用指定大小构造器,则初始容量为指定大小
- 扩容场景:如果是无参构造器生成的初始长度为0的elementData,则将其容量置为10,否则容量扩容为1.5倍
- Vector扩容机制:
- 创建Vector对象时,如果使用无参构造器,则elementData初始容量为10
- 如果使用指定大小构造器,则初始容量为指定大小
- 扩容场景:默认扩容为2倍,如果使用有参构造器指定的capacityIncrement,则按该增量进行扩容
如何选择各种集合
- 如果改查操作多,选择ArrayList,一般来说,在程序中,80%-90%都是查询,大部分情况下,选择ArrayList
- 如果增删操作多,选择LinkedList
- 如果需要保证线程安全,选择Vector
Queue相关知识
- PriorityQueue:PriorityQueue是一个无界优先级队列,底层以数组存储元素,Priority不允许null元素,也不允许不可比较的元素,PriorityQUeue中的元素以自然顺序,或传入的比较器决定的顺序排序,其中最小的元素位于队头,最大元素位于队尾
- Priority扩容机制:
- 如果使用无参构造器,则初始容量为11
- 如果使用指定大小构造器,则初始容量为指定大小
- 扩容场景:容量小于64的场合容量变为2倍+2,否则容量变为1.5倍
- ArrayDeque:ArrayDeque 是一个基于数组的双端队列,可以在两端插入和删除元素
Map相关知识
HashMap相关知识
- JDK7前,HashMap底层是数组+链表(链表的作用是解决hash冲突问题),JDK8后,HashMap底层是数组+链表+红黑树(红黑树是为了解决链表可能过长,增删改查慢的问题),HashMap不保证映射的顺序
- HashMap 没有实现同步(没有 synchronized),是线程不安全的,效率高
HashMap扩容机制
- HashMap 底层维护了 Node 类型的数组 table。默认为 null
- 创建对象时,默认构造器将加载因子(loadfactor)初始化为 0.75,也能指定那些初始容量和加载因子,默认构造器第一次添加元素的场合,table 扩容为 16,临界值为 16 * 0.75 = 12
- 添加时容量不够的场合,需要扩容,默认构造器第一次添加元素的场合,table 扩容为 16,临界值为 16 * 负载因子(默认为0.75) = 12,扩容的场合,容量变为 2 倍,临界值相应变化,然后把原来的元素重新计算哈希值,放到新的数组中,这一步也是 HashMap 最耗时的操作。JDK 8 中,如果一条链表的元素个数超过8,并且table的大小>=64,会进行树化,当红黑树的元素减少到一定程度,会被重新转化为链表
HashMap添加元素
- 添加k-v时,通过key的哈希值得到其在table的索引,判断索引位置是否被占用
- 未占用的场合,直接添加
- 占用的场合,判断其key是否相同,相同的场合,替换value,否则,按照树或链表的方式处理
HashMap索引计算过程3
计算hash值:根据hashCode()方法计算相对应的hash值
举例:String类的hashCode()方法是根据字符串内容计算的一个整数值,对于每个字符,它将字符的Unicode值乘以31的幂次方,然后将这些结果相加
'hello'.hashCode() = 'h' * 31^4 + 'e' * 31^3 + 'l' * 31^2 + 'l' * 31^1 + 'o' * 31^0 = 104 * 31^4 + 101 * 31^3 + 108 * 31^2 + 108 * 31^1 + 111 * 31^0 = 99162322扰动处理:通过改变原始哈希码的值,使得不同的键更有可能分配到不同的桶中,从而减少哈希冲突的概率
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }索引计算:将扰动处理后的哈希值与数组大小相与,得到索引值
index = hash(key) & (capacity - 1) = 99160795 & (16 - 1) = 99160795 & 15 = 3你对红黑树了解多少,为什么不用二叉树/平衡树
- 红黑树是一种自平衡的二叉查找树:
- 每个节点要么是红色,要么是黑色;
- 根节点永远是黑色;
- 所有的叶子节点都是是黑色的(下图中的 NULL 节点);
- 红色节点的子节点一定是黑色的;
- 从任一节点到其每个叶子的所有简单路径都包含相同数目的黑色节点。
- 为什么不用二叉树:二叉树是最基本的树结构,每个节点最多有两个子节点,但是二叉树容易出现极端情况,比如插入的数据是有序的,那么二叉树就会退化成链表,查询效率就会变成 O(n)
- 为什么不用平衡二叉树:平衡二叉树比红黑树的要求更高,每个节点的左右子树的高度最多相差 1,这种高度的平衡保证了极佳的查找效率,但在进行插入和删除操作时,可能需要频繁地进行旋转来维持树的平衡,维护成本更高
为什么使用红黑树
- 链表的查找时间复杂度是 O(n),当链表长度较长时,查找性能会下降。红黑树是一种折中的方案,查找、插入、删除的时间复杂度都是 O(log n)
红黑树怎样保持平衡
旋转
染色
HashMap的put流程
第一步,通过hash方法计算key的哈希值
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }第二步,数组进行第一次扩容
if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length;第三步,根据哈希值计算key在数组中的下标,如果对应下标没有数组,直接插入
if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null);否则,判断是否为相同的key,是则覆盖value,不是的话需要判断是否为树节点,是则向树中插入节点,否则向链表中插入数据
else { Node<K,V> e; K k; if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); else { for (int binCount = 0; ; ++binCount) { if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); 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; } } }注意,在链表中插入节点的时候,如果链表长度大于等于8,则需要把链表转换为红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash);所有元素处理完后,还需要判断是否超过阈值threshold,超过则扩容
if (++size > threshold) resize();HashMap插入数据流程图
HashMap如何查找元素
使用扰动函数,获取新的哈希值
计算数组下标,获取节点
当前节点和key匹配,直接返回
否则,当前节点是否为树节点,是则查找红黑树
否则遍历链表查找
只重写equals方法没重写hashcode方法,map执行get操作时会发生什么
如果只重写 equals 方法,没有重写 hashcode 方法,那么会导致 equals 相等的两个对象,hashcode 不相等,这样的话,这两个对象会被放到不同的桶中,这样就会导致 get 的时候,找不到对应的值
例:当你插入一个 Person 对象到 HashMap 中时,它会根据该对象的 hashCode() 计算出一个索引位置并存入相应的桶中,当你稍后使用另一个逻辑上相等的 Person 对象(即 equals() 返回 true)调用 get() 方法时,因为这个新对象的 hashCode() 可能与原来的对象不同,所以它可能指向不同的桶。结果是,即使存在一个逻辑上相等的对象,HashMap 也无法找到它,因为它会在错误的位置寻找
如果初始化HashMap时传入的容量为17时,会如何处理
- HashMap 会将这个值转换为大于或等于 17 的最小的 2 的幂。这是因为 HashMap 的设计是基于哈希表的,而哈希表的大小最好是 2 的幂,这样可以优化哈希值的计算,并减少哈希冲突
初始化HashMap的时候需要传入容量值吗
- 如果预先知道Map将存储大量键值对,提前指定一个足够大的初始容量可以减少因扩容导致的重哈希操作,从而提高性能
- 当然了,过大的初始容量会浪费内存,特别是当存储的元素远少于初始容量时,如果不指定初始容量,HashMap将使用默认的初始容量16
解决哈希冲突有哪些方法呢
- 再哈希法:准备两套哈希算法,当发生哈希冲突时,使用另外一种哈希算法,直到找到空槽为止,对哈希算法的设计要求比较高
- 开放选址法:遇到哈希冲突时,就去寻找下一个空的槽,有3种方法:
- 线性探测:从冲突的位置开始,依次往后找,直到找到空槽
- 二次探测:从冲突的位置x开始,第一次增加12个位置,第二次增加22,直到找到空槽
- 双重哈希:和再哈希法类似,准备多个哈希函数,发生冲突的时候,使用另外一个哈希函数
- 拉链法:也就是所谓的链地址法,当发生哈希冲突时,使用链表将冲突的元素串起来,HashMap使用的正是拉链法
HashMap如何判断key相等
HashMap判断两个key是否相等,依赖于key的equals()方法和hashCode()方法,以及==运算符
- hashCode():首先,使用key的hashCode()方法计算key的哈希码。由于不同的key可能有相同的哈希码,hashCode()只是第一步筛选
- equals():当两个
key的哈希码相同时,HashMap还会调用key的equals()方法进行精确比较。只有当equals()方法返回true时,两个key才被认为是完全相同的- == :当然了,如果两个key的引用指向同一个对象,那么它们的hashCode()和equals()方法都会返回true,所以在equals判断之前会优先使用==运算符判断一次
如果哈希值相同,并且键对象要么是指向同一个实例(引用相等),要么通过equals()方法判断逻辑上相等,那么就认为找到了匹配的键。这个逻辑确保了即使两个不同的对象实例具有相同的哈希码和内容(例如,两个new String("hello")),它们也会被认为是相等的键
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))为什么选择了0.75作为HashMap的默认加载因子呢
- 假如默认加载因子我们设的比较大,元素比较多,空位比较少的时候才扩容,那么发生哈希冲突的概率就增加了,查找的时间成本就增加了
- 我们默认加载因子设的比较小的话,元素比较少,空位比较多的时候就扩容了,发生哈希碰撞的概率就降低了,查找时间成本降低,但是就需要更多的空间去存储元素,空间成本就增加了
JDK8对HashMap主要做了哪些优化呢
- 底层数据结构由数组 + 链表改成了数组 + 链表或红黑树的结构,原因:如果多个键映射到了同一个哈希值,链表会变得很长,在最坏的情况下,当所有的键都映射到同一个桶中时,性能会退化到 O(n),而红黑树的时间复杂度是 O(logn)
- 链表的插入方式由头插法改为了尾插法,原因:头插法虽然简单快捷,但扩容后容易改变原来链表的顺序
- 扩容的时机由插入时判断改为插入后判断,原因:可以避免在每次插入时都进行不必要的扩容检查,因为有可能插入后仍然不需要扩容。
- 优化了哈希算法,只进行了一次异或操作,但仍然能有效地减少冲突,并且能够保证扩容后,元素的新位置要么是原位置,要么是原位置加上旧容量大小
HashMap是线程安全的吗,多线程下会有什么问题
- HashMap 不是线程安全的,主要有以下几个问题:
- 多线程下扩容会死循环。JDK1.7 中的 HashMap 使用的是头插法插入元素,在多线程的环境下,扩容的时候就有可能导致出现环形链表,造成死循环,不过,JDK 8 时已经修复了这个问题,扩容时会保持链表原来的顺序
- 多线程的 put 可能会导致元素的丢失。因为计算出来的位置可能会被其他线程的 put 覆盖。本来哈希冲突是应该用链表的,但多线程时由于没有加锁,相同位置的元素可能就被干掉了
- put 和 get 并发时,可能导致 get 为 null。线程 1 执行 put 时,因为元素个数超出阈值而导致出现扩容,线程 2 此时执行 get,就有可能出现这个问题
map的同步和非同步
- Hashtable是Map接口的一个早期同步实现,它的所有方法都是同步的,以保证线程安全,随着 JDK 版本的升级,Java 提供了更好的线程安全 Map 实现,如 ConcurrentHashMap,如果是在单线程环境下,可以使用 HashMap
有什么办法能够解决HashMap线程不安全的问题呢
HashMap不是线程安全的,一次在早期JDK版本中,是用Hashtable来保证线程安全的
- Hashtable 是直接在方法上加synchronized关键字,比较粗暴,因此在 Java 的后期版本中,更推荐使用ConcurrentHashMap和Collections.synchronizedMap(Map)包装器
- Collections.synchronizedMap返回的是Collections工具类的内部类,内部是通过 synchronized 对象锁来保证线程安全的
- ConcurrentHashMap在 JDK 7 中使用了分段锁来保证线程安全,在 JDK 8 中使用了CAS(Compare-And-Swap)+ synchronized 关键字
HashMap内部节点是有序的吗
- HashMap是无序的,根据hash值随机插入,如果想使用有序的Map,可以使用LinkedHashMap或者TreeMap
LinkedMap和TreeMap是如何实现有序的
- LinkedHashMap 维护了一个双向链表,有头尾节点,同时 LinkedHashMap 节点 Entry 内部除了继承 HashMap 的 Node 属性,还有 before 和 after 用于标识前置节点和后置节点,可以实现按插入顺序或访问顺序排序
- TreeMap 通过 key 的比较器来决定元素的顺序,如果没有指定比较器,那么 key 必须实现Comparable接口,TreeMap 的底层是红黑树,红黑树是一种自平衡的二叉查找树,每个节点都大于其左子树中的任何节点,小于其右子节点树种的任何节点,插入或者删除元素时通过旋转和着色来保持树的平衡,查找的时候通过从根节点开始,利用二叉查找树的性质,逐步向左或者右子树递归查找,直到找到目标元素
TreeMap和HashMap的区别
- HashMap 是基于数组+链表+红黑树实现的,put 元素的时候会先计算 key 的哈希值,然后通过哈希值计算出元素在数组中的存放下标,然后将元素插入到指定的位置,如果发生哈希冲突,会使用链表来解决,如果链表长度大于 8,会转换为红黑树
- TreeMap 是基于红黑树实现的,put 元素的时候会先判断根节点是否为空,如果为空,直接插入到根节点,如果不为空,会通过 key 的比较器来判断元素应该插入到左子树还是右子树
- 在没有发生哈希冲突的情况下,HashMap 的查找效率是 O(1)。适用于查找操作比较频繁的场景,TreeMap 的查找效率是 O(logn)。并且保证了元素的顺序,因此适用于需要大量范围查找或者有序遍历的场景
Set相关知识
- HashSet底层实现
- HashSet 其实是由 HashMap 实现的,只不过值由一个固定的 Object 对象填充,而键用于操作
- HashSet 主要用于去重,比如,我们需要统计一篇文章中有多少个不重复的单词,就可以使用 HashSet 来实现
- HashSet 会自动去重,因为它是用 HashMap 实现的,HashMap 的键是唯一的(哈希值),相同键的值会覆盖掉原来的值
- HashSet和ArrayList区别
- ArrayList 是基于动态数组实现的,HashSet 是基于 HashMap 实现的
- ArrayList 允许重复元素和 null 值,可以有多个相同的元素;HashSet 保证每个元素唯一,不允许重复元素,基于元素的 hashCode 和 equals 方法来确定元素的唯一性
- ArrayList 保持元素的插入顺序,可以通过索引访问元素;HashSet 不保证元素的顺序,元素的存储顺序依赖于哈希算法,并且可能随着元素的添加或删除而改变
I/O流
Java中IO流分为几种
Java IO流的划分可以根据多个维度进行,包括数据流的方向、处理数据单位、流的功能以及流是否支持随机访问等
按照数据流的方向如何划分
- 输入流(Input Stream):从源(如文件、网络等)读取数据到程序
- 输出流(Output Stream):将数据从程序写出到目的地(如文件、网络、控制台等)
按照处理数据单位如何划分
- 字节流:以字节为单位读写数据,主要用于处理二进制数据,如图像文件、音频等
- 字符流:以字符为单位读写数据,主要用于处理文本数据
按照功能如何划分:
节点流:(Node Streams):直接与数据源或目的地相连,如 FileInputStream、FileOutputStream
处理流(Processing Streams):对一个已存在的流进行包装,如缓冲流 BufferedInputStream、BufferedOutputStream
管道流(Piped Streams):用于线程之间的数据传输,如 PipedInputStream、PipedOutputStream
IO流
字节流: InputStream: 字节输入流:FileInputStream 字节输入处理流:BufferedInputStream 输入对象处理流:ObjectInputStream OutputStream: 字节输出流:FileOutputStream 字节输出处理流:BufferedOutputStream 输出对象处理流:ObjectOutputStream 字节打印流:printStream 字符流: Reader: 字符输入流:FileReader 字符输入处理流:BufferedReader 转换流:InputStreamReader Writer: 字符输出流:FileWriter 字符输出处理流:BufferedWriter 转换流:OutputStreamWriter 字符打印流:printWriterSystem.in 标准输入 编译类型:InputStream 运行类型:BufferedInputStream
System.out 标准输出 编译类型:PrintStream 运行类型:PrintStreamJava缓冲区溢出,如何预防
Java缓冲区溢出主要是由于向缓冲区写入的数据超过其能够存储的数据量,可以采用这些措施来避免:
- 合理设置缓冲区大小:在创建缓冲区时,应根据实际需求合理设置缓冲区的大小,避免创建过大或过小的缓冲区
- 控制写入数据量:在向缓冲区写入数据时,应该控制写入的数据量,确保不会超过缓冲区的容量,Java的ByteBuffer类提供了remaining()方法,可以获取缓冲区剩余的可写入数据量
字节流和字符流的区别
- 字节流是以8位字节为单位读写数据的流,每个字节代表了一个0到255之间的整数值,适合处理二进制数据,如图像、音频文件等非文本文件,以及需要直接操作原始字节的情况
- 字符流是以16位Unicode字符为单位读写数据的流,这意味着它可以处理更广泛的语言字符集,主要用于处理文本数据,因为文本通常是由字符组成的,而字符流能够自动处理字符编码问题,使得处理文本更加方便
BIO、NIO、AIO之间的区别
Java提供了多种IO模型来处理输入和输出操作,包括传统的阻塞IO、非阻塞IO和异步IO
BIO(Blocking I/O):采用阻塞式 I/O 模型,线程在执行 I/O 操作时被阻塞,无法处理其他任务,适用于连接数较少的场景
NIO(New I/O 或 Non-blocking I/O):采用非阻塞 I/O 模型,线程在等待 I/O 时可执行其他任务,通过 Selector 监控多个 Channel 上的事件,适用于连接数多但连接时间短的场景
AIO(Asynchronous I/O):使用异步 I/O 模型,线程发起 I/O 请求后立即返回,当 I/O 操作完成时通过回调函数通知线程,适用于连接数多且连接时间长的场景
JDK1.8新特性
Java 8 允许在接口中添加默认方法和静态方法
public interface MyInterface { default void myDefaultMethod() { System.out.println("My default method"); } static void myStaticMethod() { System.out.println("My static method"); } }Lambda 表达式描述了一个代码块(或者叫匿名方法),可以将其作为参数传递给构造方法或者普通方法以便后续执行
public class LamadaTest { public static void main(String[] args) { new Thread(() -> System.out.println("沉默王二")).start(); } }Stream 是对 Java 集合框架的增强,它提供了一种高效且易于使用的数据处理方式
List<String> list = new ArrayList<>(); list.add("中国加油"); list.add("世界加油"); list.add("世界加油"); long count = list.stream().distinct().count(); System.out.println(count);Java 8 引入了一个全新的日期和时间 API,位于java.time包中。这个新的 API 纠正了旧版java.util.Date类中的许多缺陷
LocalDate today = LocalDate.now(); System.out.println("Today's Local date : " + today); LocalTime time = LocalTime.now(); System.out.println("Local time : " + time); LocalDateTime now = LocalDateTime.now(); System.out.println("Current DateTime : " + now);引入 Optional 是为了减少空指针异常
Optional<String> optional = Optional.of("沉默王二"); optional.isPresent(); // true optional.get(); // "沉默王二" optional.orElse("沉默王三"); // "bam" optional.ifPresent((s) -> System.out.println(s.charAt(0))); // "沉"
Lambda表达式了解多少
Lambda表达式主要用于提供一种简洁的方式来表示匿名方法,使Java具备了函数式编程的特性,通过Lambda表达式,可以将行为(如算法或动作)作为参数传递给另一个方法,或者返回一个行为,从而支持函数式编程风格
new Thread(() -> System.out.println("Hello World")).start();
Java并发编程
并行和并发有什么区别
- 并行是指多个处理器同时执行多个任务,每个核心实际上可以在同一时间独立地执行不同的任务
- 并发是指系统有处理多个任务的能力,但是任意时刻只有一个任务在执行。在单核处理器上,多个任务是通过时间片轮转的方式实现的。但这种切换非常快,给人感觉是在同时执行
对线程安全的理解
- 线程安全是并发编程中一个重要的概念,如果一段代码块或者一个方法在多线程环境中被多个线程同时执行时能够正确地处理共享数据,那么这段代码块或者方法就是线程安全的
什么是进程和线程
- 进程说简单点就是我们在电脑上启动的一个个应用,比如我们启动一个浏览器,就会启动一个浏览器进程。进程是操作系统资源分配的最小单位,它包括了程序、数据和进程控制块等
- 线程是操作系统中调度的最小单位,它是进程中的独立执行单元。多个线程可以共享同一个进程的资源,如内存和文件句柄,但每个线程都有自己独立的栈和寄存器。与进程相比,线程的创建和上下文切换开销更小,因此在需要并发执行任务时,多线程是一种常用的解决方案
线程的共享内存
线程之间想要进行通信,可以通过消息传递和共享内存两种方法来完成。那 Java 采用的是共享内存的并发模型
线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了共享变量的副本。当然了,本地内存是 JMM 的一个抽象概念,并不真实存在
线程A和线程B之间如想通信的话,必须要经历下面2个步骤:
- 线程A把本地内存A中的共享变量副本刷新到主内存中
- 线程B到主内存中读取线程A刷新过的共享变量,再同步到自己的共享变量副本中
线程有几种创建方式
第一种:继承Thread类,重写run()方法,调用start()方法启动线程,缺点是由于Java不支持多重继承,所以如果类已经继承另一个类了,就不能使用这种方法了
class ThreadTask extends Thread { public void run() { System.out.println("看完二哥的 Java 进阶之路,上岸了!"); } public static void main(String[] args) { ThreadTask task = new ThreadTask(); task.start(); } }第二种:实现Runnable接口,重写run()方法,然后创建Tread对象,将Runnable对象作为参数传递给Thread对象,调用Start()方法启动线程,这种方法的优点是可以避免Java的单继承限制,并且更加符合面向对象的编程思想,因为Runnable接口将任务代码和线程控制的代码解耦了
class RunnableTask implements Runnable { public void run() { System.out.println("看完二哥的 Java 进阶之路,上岸了!"); } public static void main(String[] args) { RunnableTask task = new RunnableTask(); Thread thread = new Thread(task); thread.start(); } }第三种,实现Callable接口,重写call()方法,然后创建FutureTask对象,参数为Callable对象,紧接着创建Thread对象,参数为FutureTask对象,调用start()方法启动线程,该方法的优点是可以获取线程的执行结果
class CallableTask implements Callable<String> { public String call() { return "看完二哥的 Java 进阶之路,上岸了!"; } public static void main(String[] args) throws ExecutionException, InterruptedException { CallableTask task = new CallableTask(); FutureTask<String> futureTask = new FutureTask<>(task); Thread thread = new Thread(futureTask); thread.start(); System.out.println(futureTask.get()); } }多线程三种实现方式对比
优点 缺点 继承Thread类 编程简单,可以直接使用Thread中的方法 可扩展性差,不能继承其他的类 实现Runnable接口 扩展性强,实现接口的同时还能继承其他类 编程复杂,不能直接使用Thread中的方法 实现Callable接口 扩展性强,实现接口的同时还能继承其他类 编程复杂,不能直接使用Thread中的方法 调用start()方法时会执行run()方法,为什么不直接调用run()方法
在Java中,启动一个新的线程应该调用其start()方法,而不是直接调用run()方法,当在调用start()方法时,会启动一个新的线程,并让这个新线程调用run()方法,这样,run()方法就在新的线程中运行,从而实现多线程并发,如果直接调用run()方法,那么run()方法就在当前线程中运行,没有新的线程被创建,也就没有实现多线程的效果
也就是说,start() 方法的调用会告诉 JVM 准备好所有必要的新线程结构,分配其所需资源,并调用线程的
run()方法在这个新线程中执行
线程有哪些常见的调度方法
说说线程等待和通知
线程等待方法:
- wait():当一个线程A调用一个共享变量的wait()方法时,线程A会被阻塞挂起,直到发生下面几种情况才会返回:
- 线程B调用了共享对象notify()或者notifyAll()方法
- 其他线程调用了线程A的interrupt()方法,线程A抛出InterruptedException异常返回
- wait(long timeout) :这个方法相比 wait() 方法多了一个超时参数,它的不同之处在于,如果线程 A 调用共享对象的 wait(long timeout)方法后,没有在指定的 timeout 时间内被其它线程唤醒,那么这个方法还是会因为超时而返回
- wait(long timeout, int nanos),其内部调用的是 wait(long timout) 方法。
唤醒线程主要有下面两个方法:
notify():一个线程 A 调用共享对象的 notify() 方法后,会唤醒一个在这个共享变量上调用 wait 系列方法后被挂起的线程
一个共享变量上可能会有多个线程在等待,具体唤醒哪个等待的线程是随机的
notifyAll():不同于在共享变量上调用 notify() 方法会唤醒被阻塞到该共享变量上的一个线程,notifyAll 方法会唤醒所有在该共享变量上调用 wait 系列方法而被挂起的线程
说说线程休眠
- sleep(long millis):Thread 类中的静态方法,当一个执行中的线程 A 调用了 Thread 的 sleep 方法后,线程 A 会暂时让出指定时间的执行权
- 但是线程 A 所拥有的监视器资源,比如锁,还是持有不让出的。指定的睡眠时间到了后该方法会正常返回,接着参与 CPU 的调度,获取到 CPU 资源后就可以继续运行
说说让出优先权
- yield():Thread 类中的静态方法,当一个线程调用 yield 方法时,实际是在暗示线程调度器,当前线程请求让出自己的 CPU,但是线程调度器可能会“装看不见”忽略这个暗示
说说线程中断
- Java 中的线程中断是一种线程间的协作模式,通过设置线程的中断标志并不能直接终止该线程的执行。被中断的线程会根据中断状态自行处理
- void interrupt() 方法:中断线程,例如,当线程 A 运行时,线程 B 可以调用线程 interrupt() 方法来设置线程的中断标志为 true 并立即返回。设置标志仅仅是设置标志, 线程 B 实际并没有被中断,会继续往下执行
- boolean isInterrupted() 方法: 检测当前线程是否被中断
- boolean interrupted() 方法: 检测当前线程是否被中断,与 isInterrupted 不同的是,该方法如果发现当前线程被中断,则会清除中断标志
线程有几种状态
在Java中,线程共有6种状态:
状态 说明 NEW 当线程被创建后,如通过new Thread(),它处于新建状态。此时,线程已经被分配了必要的资源,但还没有开始执行 RUNNABLE 当调用线程的start()方法后,线程进入可运行状态。在这个状态下,线程可能正在运行也可能正在等待获取 CPU 时间片,具体取决于线程调度器的调度策略 BLOCKED 线程在试图获取一个锁以进入同步块/方法时,如果锁被其他线程持有,线程将进入阻塞状态,直到它获取到锁 WAITING 线程进入等待状态是因为调用了如下方法之一:Object.wait()或LockSupport.park()。在等待状态下,线程需要其他线程显式地唤醒,否则不会自动执行 TIME_WAITING 当线程调用带有超时参数的方法时,如Thread.sleep(long millis)、Object.wait(long timeout)或LockSupport.parkNanos(),它将进入超时等待状态。线程在指定的等待时间过后会自动返回可运行状态 TERMINATED 当线程的run()方法执行完毕后,或者因为一个未捕获的异常终止了执行,线程进入终止状态。一旦线程终止,它的生命周期结束,不能再被重新启动 也就是说,线程的生命周期可以分为五个主要阶段:新建、可运行、运行中、阻塞/等待、和终止,线程在运行过程中会根据转台的变化在这些阶段之间切换:
什么是线程上下文切换
- 使用多线程的目的是为了充分利用CPU,但是我们知道,并发其实是一个CPU来应付多个线程
- 为了让用户感觉多个线程是在同时执行的,CPU资源的分配采用了时间片轮转也就是给每个线程分配一个时间片,线程在时间片内占用CPU执行任务,当线程使用完时间片后,就会处于就绪状态并让出CPU让其他线程占用,这就是上下文切换
线程可以被多核调度吗
- 当然可以,在现代操作系统和多核处理器的环境中,线程的调度和管理是操作系统内核的重要职责之一
- 操作系统的调度器负责将线程分配给可用的 CPU 核心,从而实现并行处理
- 多核处理器提供了并行执行多个线程的能力。每个核心可以独立执行一个或多个线程,操作系统的任务调度器会根据策略和算法,如优先级调度、轮转调度等,决定哪个线程何时在哪个核心上运行
守护线程了解吗
- Java 中的线程分为两类,分别为 daemon 线程(守护线程)和 user 线程(用户线程)
- 在 JVM 启动时会调用 main 方法,main 方法所在的线程就是一个用户线程。其实在 JVM 内部同时还启动了很多守护线程, 比如垃圾回收线程
- 那么守护线程和用户线程有什么区别呢?区别之一是当最后一个非守护线程束时, JVM 会正常退出,而不管当前是否存在守护线程,也就是说守护线程是否结束并不影响 JVM 退出。换而言之,只要有一个用户线程还没结束,正常情况下 JVM 就不会退出
线程间有哪些通信方式
线程间传递信息有多种方式,比如使用共享对象、wait()和notify()方法、Exchanger和CompletableFuture
- 使用共享对象,多个线程可以访问和修改同一个对象,从而实现信息的传递,比如说volatile 和 synchronized 关键字
- 关键字volatile用来修饰成员变量,告知程序任何对该变量的访问均需要从共享内存中获取,而对它的改变必须同步刷新回共享内存,保证所有线程对变量访问的可见性
- 关键字sychronized可以修饰方法,或者以同步代码块的形式来使用,确保多个线程在同一个时刻,只能有一个线程在执行某个方法或某个代码块
- 使用wait()和notify()
- 一个线程调用共享对象的 wait() 方法时,它会进入该对象的等待池,并释放已经持有的该对象的锁,进入等待状态
- 一个线程调用共享对象的 notify() 方法时,它会唤醒在该对象等待池中等待的一个线程,使其进入锁池,等待获取锁
- 使用 Exchanger,Exchanger 是一个同步点,可以在两个线程之间交换数据。一个线程调用 exchange() 方法,将数据传递给另一个线程,同时接收另一个线程的数据
- 使用 CompletableFuture,CompletableFuture 是 Java 8 引入的一个类,支持异步编程,允许线程在完成计算后将结果传递给其他线程
说说sleep和wait的区别
sleep 会让当前线程休眠,不涉及对象类,也不需要获取对象的锁,属于 Thread 类的方法;wait 会让获得对象锁的线程实现等待,要提前获得对象的锁,属于 Object 类的方法
- 所属类不同:
- sleep()方法属于Thread类
- wait()方法属于Object类
- 锁行为不同:
- 当线程执行 sleep 方法时,它不会释放任何锁。也就是说,如果一个线程在持有某个对象的锁时调用了 sleep,它在睡眠期间仍然会持有这个锁
- 而当线程执行 wait 方法时,它会释放它持有的那个对象的锁,这使得其他线程可以有机会获取该对象的锁
- 使用条件不同:
- sleep()方法可以在任何地方被调用
- wait()方法必须在同步代码块或同步方法中被调用,这是因为调用
wait()方法的前提是当前线程必须持有对象的锁。否则会抛出 IllegalMonitorStateException 异常- 唤醒方式不同
- 调用 sleep 方法后,线程会进入 TIMED_WAITING 状态(定时等待状态),即在指定的时间内暂停执行。当指定的时间结束后,线程会自动恢复到 RUNNABLE 状态(就绪状态),等待 CPU 调度再次执行
- 调用 wait 方法后,线程会进入 WAITING 状态(无限期等待状态),直到有其他线程在同一对象上调用 notify 或 notifyAll,线程才会从 WAITING 状态转变为 RUNNABLE 状态,准备再次获得 CPU 的执行权
什么是线程安全
- 多线程安全是指在并发环境下,多个线程访问共享资源时,程序能够正确地执行,而不会出现数据不一致或竞争条件等问题。反之,如果程序出现了数据不一致、死锁、饥饿等问题,就称为线程不安全
有个int的变量为0,十个流程对其进行++操作(循环100000次),结果是大于小于还是等于10万,会不会出现线程安全问题
在这个场景中,最终的结果会小于 100000,原因在于多线程环境下,++ 操作不是一个原子操作,会出现线程安全问题
- int++ 实际上可以分解为三步:
- 读取变量的值
- 将读取到的值加 1
- 将结果写回变量
- 多个线程在并发执行 ++ 操作时,可能出现以下竞态条件
- 线程 1 读取变量值为 0
- 线程 2 也读取变量值为 0
- 线程 1 进行加法运算并将结果 1 写回变量
- 线程 2 进行加法运算并将结果 1 写回变量,覆盖了线程 1 的结果
说一个线程安全的使用场景
一个常见的使用场景是在实现单例模式时确保线程安全
- 单例模式确保一个类只有一个实例,并提供一个全局访问点。在多线程环境下,如果多个线程同时尝试创建实例,单例类必须确保只创建一个实例
- 饿汉式是一种比较直接的实现方式,它通过在类加载时就立即初始化单例对象来保证线程安全
- 懒汉式单例则在第一次使用时初始化,这种方式需要使用双重检查锁定来确保线程安全,volatile 用来保证可见性,syncronized 用来保证同步
能说一下Hashtable数据结构底层嘛
- 与 HashMap 类似,Hashtable 的底层数据结构也是一个数组加上链表的方式,然后通过 synchronized 加锁来保证线程安全
ThreadLocal是什么
ThreadLocal是Java中提供的一种用于实现线程局部变量的工具类。它允许每个线程都拥有自己的独立副本,从而实现线程隔离,用于解决多线程中共享对象的线程安全问题
- 在 Web 应用中,可以使用 ThreadLocal 存储用户会话信息,这样每个线程在处理用户请求时都能方便地访问当前用户的会话信息
- 在数据库操作中,可以使用 ThreadLocal 存储数据库连接对象,每个线程有自己独立的数据库连接,从而避免了多线程竞争同一数据库连接的问题
- 在格式化操作中,例如日期格式化,可以使用 ThreadLocal 存储 SimpleDateFormat 实例,避免多线程共享同一实例导致的线程安全问题
使用ThreadLocal通常分为四步:
创建ThreadLocal
//创建一个ThreadLocal变量 public static ThreadLocal<String> localVariable = new ThreadLocal<>();设置ThreadLocal的值
//设置ThreadLocal变量的值 localVariable.set("沉默王二是沙雕");获取ThreadLocal的值
//获取ThreadLocal变量的值 String value = localVariable.get();删除ThreadLocal的值
//删除ThreadLocal变量的值 localVariable.remove();ThreadLocal有哪些优点
- 线程隔离:每个线程访问的变量副本都是独立的,避免了共享变量引起的线程安全问题。由于 ThreadLocal 实现了变量的线程独占,使得变量不需要同步处理,因此能够避免资源竞争
- 数据传递方便:ThreadLocal 常用于在跨方法、跨类时传递上下文数据(如用户信息等),而不需要在方法间传递参数
你在工作中有用到过ThreadLocal吗
- 有用到过,用来存储用户信息,假如在服务层和持久层也要用到用户信息,就可以在控制层拦截请求把用户信息存入 ThreadLocal,这样我们在任何一个地方,都可以取出 ThreadLocal 中存的用户信息
ThreadLocal是如何实现的呢
ThreadLocal本身不存储任何值,它只是一个映射,来映射线程的局部变量,当一个线程调用ThreadLocal的get或set方法时,实际上是访问线程自己的ThreadLocal.ThreadLocalMap
ThreadLocalMap 是 ThreadLocal 的静态内部类,它内部维护了一个 Entry 数组,key 是 ThreadLocal 对象,value 是线程的局部变量本身
早期的ThreadLocal 不是这样的,它的ThreadLocalMap中使用Thread作为Key,也是最简单的实现方式
优化后的方案有两个好处
- Map 中存储的键值对变少了
- ThreadLocalMap 的生命周期和线程一样长,线程销毁的时候,ThreadLocalMap 也会被销毁
ThreadLocal 的实现原理就是,每个线程维护一个 Map,key 为 ThreadLocal 对象,value 为想要实现线程隔离的对象
- 当需要存线程隔离的对象时,通过 ThreadLocal 的 set 方法将对象存入 Map 中
- 当需要取线程隔离的对象时,通过 ThreadLocal 的 get 方法从 Map 中取出对象
- Map 的大小由 ThreadLocal 对象的多少决定
什么是弱引用,什么是强引用
强引用,比如说 User user = new User("沉默王二") 中,user 就是一个强引用,new User("沉默王二") 就是一个强引用对象,当 user 被置为 null 时(user = null),new User("沉默王二") 将会被垃圾回收;如果 user 不被置为 null,即便是内存空间不足,JVM 也不会回收 new User("沉默王二") 这个强引用对象,宁愿抛出 OutOfMemoryError
弱引用,比如下面这段代码:
ThreadLocal<User> userThreadLocal = new ThreadLocal<>(); userThreadLocal.set(new User("沉默王二"));
- userThreadLocal 是一个强引用,new ThreadLocal<>() 是一个强引用对象
- new User("沉默王二") 是一个强引用对象
- 在 ThreadLocalMap 中,key = new ThreadLocal<>() 是一个弱引用对象。当 JVM 进行垃圾回收时,如果发现了弱引用对象,就会将其回收
ThreadLocal内存泄漏是怎么回事
通常情况下,随着线程 Thread 的结束,其内部的 ThreadLocalMap 也会被回收,从而避免了内存泄漏
但如果一个线程一直在运行,并且其 ThreadLocalMap 中的 Entry.value 一直指向某个强引用对象,那么这个对象就不会被回收,从而导致内存泄漏。当 Entry 非常多时,可能就会引发更严重的内存溢出问题
如何解决内存泄漏问题:很简单,使用完ThreadLocal 后,及时调用 remove() 方法释放内存空间,remove()方法会将当前线程的 ThreadLocalMap 中的所有 key 为 null 的 Entry 全部清除,这样就能避免内存泄漏问题
try { threadLocal.set(value); // 执行业务操作 } finally { threadLocal.remove(); // 确保能够执行清理 }为什么要将key设计成弱引用
- 弱引用的好处是,当内存不足时,JVM会主动回收掉弱引用的对象,一旦 key 被回收,ThreadLocalMap 在进行 set、get 的时候就会对 key 为 null 的 Entry 进行清理
- 总结一下,在 ThreadLocal 被垃圾收集后,下一次访问 ThreadLocalMap 时,Java 会自动清理那些键为 null 的条目(参照源码中的 replaceStaleEntry 方法),这个过程会在执行 ThreadLocalMap 相关操作(如
get(),set(),remove())时触发ThreadLocalMap如何解决Hash冲突
- 我们可能都知道 HashMap 使用了链表来解决冲突,也就是所谓的链地址法
- ThreadLocalMap没有使用链表,自然也不是用链地址法来解决冲突了,它用的是另外一种方式——开放定址法
- 如上图所示,如果我们插入一个 value=27 的数据,通过 hash 计算后应该落入第 4 个槽位中,而槽位 4 已经有了 Entry 数据,而且 Entry 数据的 key 和当前不相等。此时就会线性向后查找,一直找到 Entry 为 null 的槽位才会停止查找,把元素放到空的槽中
- 在 get 的时候,也会根据 ThreadLocal 对象的 hash 值,定位到 table 中的位置,然后判断该槽位 Entry 对象中的 key 是否和 get 的 key 一致,如果不一致,就判断下一个位置
ThreadLocalMap扩容机制
在ThreadLocalMap.set()方法的最后,如果执行完启发式清理工作后,未清理到任何数据,且当前散列数组中Entry的数量已经达到了列表的扩容阈值(len*2/3),就开始执行rehash()逻辑:
if (!cleanSomeSlots(i, sz) && sz >= threshold) rehash();再看看rehash()具体实现,这里会先去清理过期的 Entry,然后还要根据条件判断size >= threshold - threshold / 4 也就是size >= threshold* 3/4来决定是否需要扩容
private void rehash() { //清理过期Entry expungeStaleEntries(); //扩容 if (size >= threshold - threshold / 4) resize(); } //清理过期Entry private void expungeStaleEntries() { Entry[] tab = table; int len = tab.length; for (int j = 0; j < len; j++) { Entry e = tab[j]; if (e != null && e.get() == null) expungeStaleEntry(j); } }接着看看具体的resize()方法,扩容后的newTab的大小为老数组的两倍,然后遍历老的 table 数组,散列方法重新计算位置,开放地址解决冲突,然后放到新的newTab,遍历完成之后,oldTab中所有的entry数据都已经放入到newTab中了,然后 table 引用指向newTab
Java内存模型
谈谈你对Java内存模型的理解
Java 内存模型(Java Memory Model)是一种抽象的模型,简称 JMM,主要用来定义多线程中变量的访问规则,用来解决变量的可见性、有序性和原子性问题,确保在并发环境中安全地访问共享变量
JMM 定义了线程内存和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了共享变量的副本,用来进行线程内部的读写操作
- 当一个线程更改了本地内存中共享变量的副本后,它需要将这些更改刷新到主内存中,以确保其他线程可以看到这些更改
- 当一个线程需要读取共享变量时,它可能首先从本地内存中读取。如果本地内存中的副本是过时的,线程将从主内存中重新加载共享变量的最新值到本地内存中
说说你对原子性、可见性、有序性的理解
- 原子性:指的是一个操作是不可分割的,要么全部执行成功,要么完全不执行
- 可见性:指的是一个线程对共享变量的修改,能够被其他线程及时看到
- 有序性:指的是程序代码的执行顺序与代码中的顺序一致,在没有同步机制的情况下,编译器可能会对指令进行重排序,以优化性能。这种重排序可能会导致多线程的执行结果与预期不符
原子性、可见性、有序性都应该如何保证呢
- 原子性:JMM 只能保证基本的原子性,如果要保证一个代码块的原子性,需要使用synchronized
- 可见性:Java 是利用volatile关键字来保证可见性的,除此之外,final和synchronized也能保证可见性
- 有序性:synchronized或者volatile都可以保证多线程之间操作的有序性
谈谈synchronized和volatile
- synchronized:可以保证原子性、有序性和可见性
- 需要获取到锁,从而保证原子性
- 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中,从而保证可见性
- 一个变量在同一时刻只运行一条线程对其进行lock操作,从而保证有序性
- 可以使用在变量、方法、和类级别
- 可能会造成线程的阻塞
- volatile:可以保证有序性和可见性,不能保证数据的原子性
- 对于volatile变量,新值可以立即同步到主内存中,且其他线程会通过嗅探技术判断本地内存中的volatile变量是否发生了修改,一旦发生了修改,则使本地内存数据失效,从而保证可见性。通过插入内存屏障,从而保证有序性
- 写volatile变量时,可以确保volatile写之前的操作不会被编译器重排序到volatile写之后
- 读volatile变量时,可以确保volatile读之后的操作不会被编译器重排序到volatile读之前
- 仅能使用在变量级别
- 不会造成线程的阻塞
锁
synchronized用过吗,怎么使用
在Java中,synchronized是最常用的锁,它使用简单,并且可以保证线程安全,避免多线程并发访问时出现数据不一致的情况
synchronized修饰方法
public synchronized void increment() { this.count++; }当在方法声明中使用了 synchronized 关键字,就表示该方法是同步的,也就是说,线程在执行这个方法的时候,其他线程不能同时执行,需要等待锁释放
如果是静态方法的话,锁的是这个类的 Class 对象,因为静态方法是属于类级别的
public static synchronized void increment() { count++; }synchronized修饰代码块
public void increment() { synchronized (this) { this.count++; } }同步代码块可以减少需要同步的代码量,颗粒度更低,更灵活。synchronized 后面的括号中指定了要锁定的对象,可以是 this,也可以是其他对象
synchronized实现原理
- synchronized 修饰代码块时,JVM 会通过 monitorenter、monitorexit 两个指令来实现同步:
- monitorenter 指向同步代码块的开始位置
- monitorexit 指向同步代码块的结束位置
- synchronized修饰方法时,JVM会通过ACC_SYNCHRONIZED标记符来实现同步
monitorenter、monitorexit 或者 ACC_SYNCHRONIZED 都是基于 Monitor 实现的
所谓的 Monitor 其实是一种同步工具,也可以说是一种同步机制。在 Java 虚拟机(HotSpot)中,Monitor 是由ObjectMonitor 实现的,可以叫做内部锁,或者 Monitor 锁
ObjectMonitor的工作原理:
ObjectMonitor 有两个队列:_WaitSet、_EntryList,用来保存 ObjectWaiter 对象列表
_owner,获取 Monitor 对象的线程进入 _owner 区时, _count + 1。如果线程调用了 wait() 方法,此时会释放 Monitor 对象, _owner 恢复为空, _count - 1。同时该等待线程进入 _WaitSet 中,等待被唤醒
这个过程就和 Monitor 机制比较相似:
- 门诊大厅:所有待进入的线程都必须先在入口 Entry Set挂号才有资格
- 就诊室:就诊室_Owner里里只能有一个线程就诊,就诊完线程就自行离开
- 候诊室:就诊室繁忙时,进入等待区(Wait Set),就诊室空闲的时候就从等待区(Wait Set)叫新的线程
- 所以我们就知道了,同步是锁住的什么东西:
- monitorenter,在判断拥有同步标识 ACC_SYNCHRONIZED 抢先进入此方法的线程会优先拥有 Monitor 的 owner ,此时计数器 +1
- monitorexit,当执行完退出后,计数器 -1,归 0 后被其他进入的线程获得
Java中有哪些常用的锁,在什么场景下使用?
- 内置锁(synchronized):Java中的 synchronized 关键字是内置锁机制的基础,可以用于方法或代码块。当一个线程进入 synchronized 代码块或方法时,它会获取关联对象的锁;当线程离开该代码块或方法时,锁会被释放。如果其他线程尝试获取同一个对象的锁,它们将被阻塞,直到锁被释放。其中,syncronized加锁时有无锁、偏向锁、轻量级锁和重量级锁几个级别。偏向锁用于当一个线程进入同步块时,如果没有任何其他线程竞争,就会使用偏向锁,以减少锁的开销。轻量级锁使用线程栈上的数据结构,避免了操作系统级别的锁。重量级锁则涉及操作系统级
的互斥锁- ReentrantLock:Java.util.concurrent.locks.ReentrantLock 是一个显式的锁类,提供了比synchronized 更高级的功能,如可中断的锁等待、定时锁等待、公平锁选项等。ReentrantLock使用 lock()和 unlock()方法来获取和释放锁。其中,公平锁按照线程请求锁ReentrantLock的顺序来分配锁,保证了锁分配的公平性,但可能增加锁的等待时间。非公平锁不保证锁分配的顺序,可以减少锁的竞争,提高性能,但可能造成某些线程的饥饿
- 读写锁(ReadWriteLock):java.util.concurrent.locks.ReadWriteLock 接口定义了一种锁,允许多个读取者同时访问共享资源,但只允许一个写入者。读写锁通常用于读取远多于写入的情况,以提高并发性
- 乐观锁和悲观锁:悲观锁(Pessimistic Locking)通常指在访问数据前就锁定资源,假设最坏的情况,即数据很可能被其他线程修改。synchronized 和 ReentrantLock 都是悲观锁的例子。乐观锁(Optimistic Locking)通常不锁定资源,而是在更新数据时检査数据是否已被其他线程修改。乐观锁常使用版本号或时间戳来实现
- 自旋锁:自旋锁是一种锁机制,线程在等待锁时会持续循环检查锁是否可用,而不是放弃CPU并阻塞。通常可以使用CAS来实现。这在锁等待时间很短的情况下可以提高性能,但过度自旋会浪费CPU资源
什么是锁升级
锁升级是 Java 虚拟机中的一个优化机制,用于提高多线程环境下 synchronized 的并发性能。锁升级涉及从较轻的锁状态(如无锁或偏向锁)逐步升级到较重的锁状态(如轻量级锁和重量级锁),以适应不同程度的竞争情况
Java对象头例的Mark Word会记录锁的状态,一共四种状态:
无锁状态,在这个状态下,没有线程试图获取锁
偏向锁,当第一个线程访问同步块时,锁会进入偏向模式。Mark Word 会被设置为偏向模式,并且存储了获取它的线程 ID
偏向锁的目的是消除同一线程的后续锁获取和释放的开销。如果同一线程再次请求锁,就无需再次同步
当有多个线程竞争锁,但没有锁竞争的强烈迹象(即线程交替执行同步块)时,偏向锁会升级为轻量级锁
线程尝试通过CAS(Compare-And-Swap)将对象头的 Mark Word 替换为指向锁记录的指针。如果成功,当前线程获取轻量级锁;如果失败,说明有竞争
重量级锁,当锁竞争激烈时,轻量级锁会膨胀为重量级锁
重量级锁通过将对象头的 Mark Word 指向监视器(Monitor)对象来实现,该对象包含了锁的持有者、锁的等待队列等信息
synchronized 和 ReentrantLock 的区别
synchronized 是一个关键字,ReentrantLock 是Lock接口的一个实现
区别 synchronized ReentrantLock 锁实现机制 对象头监视器模式 依赖AQS 灵活性 不灵活 支持超时、响应中断、尝试获取锁 释放锁形式 自动释放锁 显式调用unlock() 支持锁类型 非公平锁 非公平锁&公平锁 条件队列 单条件队列 多个条件队列 可重入支持 支持 支持 它们都可以用来实现同步,但也有一些区别:
- ReentrantLock 可以实现多路选择通知(绑定多个 Condition),而 synchronized 只能通过 wait 和 notify/notifyAll 方法唤醒一个线程或者唤醒全部线程(单路通知)
- ReentrantLock 必须手动释放锁。通常需要在 finally 块中调用 unlock 方法以确保锁被正确释放;synchronized 会自动释放锁,当同步块执行完毕时,由 JVM 自动释放,不需要手动操作
- ReentrantLock 通常能提供更好的性能,因为它可以更细粒度地控制锁;synchronized 只能同步代码块或者方法,随着 JDK 版本的升级,两者之间性能差距已经不大了
并发量大的情况下,使用 synchronized 还是 ReentrantLock?
在并发量特别高的情况下,ReentrantLock 的性能可能会优于 synchronized,原因包括:
- ReentrantLock 提供了超时和公平锁等特性,可以更好地应对复杂的并发场景
- ReentrantLock 允许更细粒度的锁控制,可以有效减少锁竞争
- ReentrantLock 支持条件变量 Condition,可以实现比 synchronized 更复杂的线程间通信机制























浙公网安备 33010602011771号