- 🛫 JDK和JRE傻傻分不清?
- 🛫 HelloWorld的输出都经历了啥?
- 🛫 Java的三个版本都是啥?
- 🛫 关于main方法你都知道啥?main方法被声明为private会怎样?
- 🛫 强制and自动类型转换都是啥?
- 🛫 关于变量和常量你都知道啥?
- 🛫 &和&&、|和||有什么区别?
- 🛫 可以修改一个字符串中的值吗?
- 🛫 字符串使用==和equals判等的区别?
- 🛫 空串""和null串有啥区别?
- 🛫 String StringBuilder StringBuffer的异同
- 🛫 关于输入输出你都知道啥?
- 🛫 关于控制路程都需要注意啥?
- 🛫 数组初始化的小细节
- 🛫 类?对象?他们的关系又是什么?
- 🛫 关于构造器你都知道什么?
- 🛫 关于类的源文件都需要知道什么?
- 🛫 重载和重写都是什么?区别又是什么?
- 🛫 关键字
- 🪂 this和super关键字
- 🪂 instanceof关键字
- 🪂 static关键字
- 🪂 final关键字
- 🛫 权限修饰符的作用范围知多少?
- 🛫 抽象类
- 🛫 抽象方法
- 🛫 interface接口
- 🪂 接口的结构
- 🪂 接口的实现和方法引用
- 🪂 为什么使用接口
- 🛫 方法的参数传递机制是什么?
- 🛫 关于包装类你都知道什么?
- 🪂 包装类基本知识
- 🪂 什么是装箱和拆箱?
- 🪂 包装类cache缓存机制
- 🛫 关于迭代器你都知道什么?
- 🪂 什么是迭代器?
- 🪂 迭代器的4个API
- 🪂 如何使用迭代器?
- 🛫 Collection集合接口知多少?
- 🪂 为什么不使用数组而是集合?
- 🪂Collection接口的API都有什么?
- 🪂AbstractCollection类知多少?
- 🛫 关于List集合类你都知道什么?
- 🪂 List接口三个实现类的异同?
- 🪂 ArrayList类源码浅析
- 🪂 LinkedList类源码浅析
- 🪂 vector类源码简析
- 🛫 关于Set集合类你都知道什么?
- 🪂 如何理解Set集合的无序不可重复?
- 🪂 Map集合类
- 🪂 HashSet类、HashMap类数据结构及源码浅析
- 🪂 LinkedHashSet类、LinkedHashMap类源码浅析
- 🪂 TreeSet类、TreeMap类源码浅析
🛫 JDK和JRE傻傻分不清?
好歹也是学了那么长时间的Java了,强烈的直觉告诉我JDK、JRE这俩指的肯定不是一个东西,一看书才知道我的直觉是真滴准(夸夸自己),既然不一样,那么接下来就扒一扒JDK和JRE的野史吧。
JDK 是指Java Development Kit 也就是我们常说的Java开发工具包,是程序员编写Java程序时必须使用的软件。JRE 是指Java Runtime Environment 也就是Java的运行时环境,是程序运行时必须依赖的软件。那么我们想要使用Java这门语言进行编程的话,究竟需要安装哪个呢?答案是我都要,一个编译一个运行缺一不可。不过当你下载了JDK之后就会发现,在jdk的安装目录下会有一个的jre文件夹,也就是意味着我们只需要下载jdk即可。jdk的下载安装配置十分简单:点击我仅需四步即可完成
既然是野史就说一说大家可能不知道的知识点,在1998~2006年之间,开发工具包被称为是SDK(Software Development Kit)后更名JDK,在某些场合中任然会见到这个术语;同一时间段内Java所出的版本被统称为java 2。看到这个名词不知道大家会不会联想到J2EE,猜的没错 J2EE的全称就是Java 2 Platform Enterprise Edition也就是Java 2 平台企业版,是在SUN公司领导下多家公司(Oracle、IBM、BEA等)共同制定的一套广泛认可的企业级分布式应用程序开发规范,后来强大如J2EE还是被Spring框架所冲击,导致这一切的就是那个恐怖如斯的男人——罗德·约翰森(Rod Johnson)
🛫 HelloWorld的输出都经历了啥?
以HelloWorld程序为例介绍java程序开发的几个步骤:😶程序员编写HelloWorld.java文件(源代码文件)🤔计算机使用javac.exe程序将HelloWorld.java文件编译成HelloWorld.class文件(字节码文件)🤨计算机使用java.exe程序将HelloWorld.class文件送到JVM中运行😲运行的过程中随时向核心类库中调用Java编写好的程序来支撑自己编写程序的运行手动操作编译运行的过程中有几个注意点:
- 1、编写的源代码文件以.java作为拓展名,放在C盘以外的盘符下,显示文件的拓展名防止文件的拓展名不是.java,否则会报javac: 找不到文件的错误
- 2、使用java.exe运行字节码文件的时候,HelloWorld后面没有拓展名
🛫 Java的三个版本都是啥?
Java SE(Java Standard Edition):标准版
Java EE(Java Enterprise Edition):企业版
Java ME(Java Micro Edition):微型版
🛫 关于main方法你都知道啥?main方法被声明为private会怎样?
public static void main(String[] args)
public: main方法的访问权限为public,方便虚拟机直接调用
static: 静态方法,虚拟机不用创建该类的对象即可调用它内部的main方法
void: 没有返回值,也就是说返回值为空
String[] args: 在使用命令行执行Java程序的时候,会通过命令行参数传递一些数据,这些数据就会被名为args的字符串数组所接收
首先,根据Java语言的开发规范,main方法必须被声明为public,当然只是规范而不是必须。如果想要使用private或者protected不将main方法对外开放的话也是可以的,而且源代码文件依旧会被正常编译为字节码(.class)文件,只不过在运行的时候会报"Main method not public"的异常错误。 出现上述情况的原因是:类加载后main方法被虚拟机所调用执行,但是由于main方法并不是public的,导致虚拟机无法调用该方法出现异常。从以上的结论中我们就可以推知,如果在intellij IDEA中不将main方法的访问权限设置为public的话,就会导致main方法左侧不会出现供代码运行的绿色小三角图标 main方法是Java程序的入口,所以每个Java程序都必须有一个main方法,但并不代表着每个类都必须有main方法
🛫 强制and自动类型转换都是啥?
Java是一种强类型语言,这就意味着我们在编译之前就应该为每一个变量声明一种类型。而在Java中数据类型又被分为基本数据类型和引用数据类型,基本数据类型又被分为四类八种,除了基本数据类型之外的所有数据都是引用数据类型自动类型转换
下面这张图片中,揭示了基本数据类型间的自动转换关系。箭头之间具有传递性,实线箭头代表着无精度损失的转换,虚线代表转换会伴随着数据精度的损失。其中char转int会将char字符对应的ASCII码赋给int型变量。 表达式(用一个二元运算符连接两个值)中的自动类型转换,小范围的值会自动转换为较大范围类型的值参与运算,所以说表达式运算的最终结果由最高类型的值决定。而且byte、short、char类型的值不管如何,都是直接转换成int参与远算,所以说(byte)+(byte)=(int),其他两种类型也是如此
强制类型转换
由自动类型转换可知,int类型旳值在必要的时候会自动转换为double类型,但是当我们需要将double转int的时候该怎么办呢?这个时候就要通过强制类型转换了。
浮点型强转为整型,直接丢掉小数只保留整数部分,而并不是简单的将结果四舍五入,Math.round()方法可以四舍五入(返回值是long型)。如果在强制转换的过程中,数值超过小范围取值的话,结果将会被截断成两个完全不同的值,eg:(byte)1500 = -36,原理如下: 显式转换一般就是强制类型转换,隐式转换除了自动类型转换还有一种,那就是结合赋值运算符,也就是+=、-=等。如果这种运算符得旳结果类型与左侧的的类型不一样且无法进行自动类型转换,就会默认使用强制类型转换将右侧的类型强转为左侧的数据类型
整型值和布尔值之间无法进行相互转换,无论是自动转换还是强制转换都不可以
最后,给两道题自测一下类型转换的掌握程度
🛫 关于变量和常量你都知道啥?
变量
首先,变量的声明遵循以下规范:
- 只能包含字母、数字、美元符(“$”)、下划线(“_”),但是不能以数字开头,且"$“和”_"并不推荐在命名的时候使用
- 不能使用Java中的保留字
- 变量的命名区分大小写
- 虽不强制要求,但是尽量使用小驼峰(第一个单词首字母小写其他单词的首字母大写)命名
其次,Java支持一次声明多个变量但并不提倡,因为这种形式会降低程序的可读性(int i, j;),可以使用逐一声明的形式,而且变量的声明要尽可能的靠近变量第一次使用的地方
最后,变量声明之后还必须要进行初始化才能使用,否则会编译器就会认为这个变量的使用是非法的。变量的初始化有三种机制:
- 构造器:Java中如果没有在构造器中显式的为字段设置初始值的话,就会将其设置成默认值,也就是不同数据类型数据所对应的默认值
- 显式赋值:也就是最常见的使用等于号
- 初始化代码块:Java中将类中使用花括号包裹起来的几行代码称为是初始化代码块,初始化代码块随着类的加载而加载,只要构造这个类的对象就会执行这个块里的代码。而且初始化代码块中的代码会在main方法之前执行,初始化代码块中定义的变量和构造器一样有默认值机制
类变量(static修饰的变量)、实例变量可以使用构造器、显式赋值和初始化块三种机制进行初始化,但是局部变量则只能也必须使用赋值语句进行显式初始化。常量
Java中常量必须在main方法的外部使用final进行定义,常量定义的时候必须进行初始化,一旦初始化之后就无法改变值的大小,且常量的命名必须全部大写。静态变量和静态常量
静态变量也叫类变量是使用static修饰的变量,静态变量属于类而不属于任何单个的对象,也就是说即使不创建该类的对象这个静态变量依然存在,不管创建多少个对象都是共享这一个静态变量。非静态变量也就是实例变量就不一样了,实例对象属于对象,必须使用对象调用无法直接调,每创建一个对象都会得到一个实例变量的副本,不同对象之间互不影响 静态常量的使用频率要明显高于静态变量,在静态常量定义的时候往往都会加上final修饰,这样的话它既可以被其他类直接使用类名调用,又不用担心字段值被调用后随意修改产生的安全问题。之前输出语句的时候使用的out就是一个final修饰的PrintStream类型的静态常量,PrintStream类里又内置了print、println、printf等方法用于输出
🛫 &和&&、|和||有什么区别?
&(逻辑与)和|(逻辑或)是逻辑运算符,&&(短路与)和||(短路或)是短路逻辑运算符。这两种运算符对应的的运算结果相同,与运算的话是全真为真、有假则假,或运算的是全假为假、有真则真。两种类型的运算符区别在于:逻辑运算符会执行完左右两边的表达式之后返回一个结果,短路逻辑运算符当能够得到结果的时候就会直接返回,也就是说短路与的左边结果为false就会直接返回false,短路或的左边结果为true就会直接返回true
补充一种进行位移运算的位运算符,位移运算就是对一个数值的二进制表示进行左右移动的运算,其中>>表示右移高位符号位补充,<<表示左移低位补0,>>>则是右移高位补0,注意:并没有<<<这个运算符
🛫 可以修改一个字符串中的值吗?
不能。因为Java中字符串并不是内置的数据类型,而是标准Java类库中提供的一个预定义类。字符串不只一种使用new的实例化方式,使用双引号(“”)括起来的字符都是String类的一个实例,使用new方式实例的对象对分配在堆内存中,使用双引号实例的对象在常量池中,字符串变量的值则是字符串对象的地址引用,不管使用哪种方式给字符串变量重新赋一个新值都是看上去改变了这个字符串的值,实际上只不过是改变了变量值的地址引用,原来的字符串对象还在内存中存储且值不变
由上图就得以验证我们之前的结论是正确的,使用双引号实例化的字符串对象存储在字符串常量池中,所以连续创建两个内容相同的字符串会指向同一个地址。而且使用new实例化的字符串对象存储在堆内存中,所以说使用两种方式实例化内容相同的字符串对象,地址会不同。接下来的三种方式改变字符串的内容,地址都和原地址不同,也就是证实了字符串是一个不可变的数据类型
🛫 字符串使用==和equals判等的区别?
==是对两个字符串变量的引用地址值是否相等进行判断,就算字符串的内容相等如果存储的位置不同也会返回false。String类中重写了Object的equals方法使之可以对字符串的内容进行判等,查看源码不难发现重写之后的equals方法是对两个字符串的地址、长度、每一个字符顺序进行判断,在判断的过程中但凡有一个不等就直接返回false。
结合字符串的两种实例化方式和两种判等方式
补充一个知识点,如果想忽略字母大小写比较两个字符串的内容是否相等,可以使用equalsIgnoreCase方法,用法与equals相同
🛫 空串""和null串有啥区别?
空串是一个长度为0内容为空的字符串,属于是一个字符串对象;null串表示这个变量并未引用任何的对象或者基本数据类型值,属于是一个供引用数据变量引用的值。空串引用String的API 会返回值,但是null串调用String的API 的话会报NullPointerException的错误。一般情况下都会使用if(str != null && str.length() != 0)对字符串进行检查,符合条件才会使用这个字符串。
🛫 String StringBuilder StringBuffer的异同
这三种都是Java中用来操作字符串的类。区别就是String声明一个不可变的对象,每次操作都会生成一个新的对象,并将新的对象地址赋给原来的字符串变量,而StringBuilder和StringBuffer都是在原来的对象上进行操作并不会产生新的对象,所以说在需要经常改变字符串内容的情况下最好使用这两种类。 StringBuilder和StringBuffer也是有区别的,StringBuffer线程安全但是性能相对较差,StringBuilder线程不安全但是性能较高,于是单线程的情况下推荐使用StringBuilder,多线程的话就使用安全的StringBuffer
🛫 关于输入输出你都知道啥?
输入
若是想要通过控制台进行输入操作的话,首先需要创建一个标准输入流对象,然后才能使用相应的方法进行键入值的读取,根据方法的不同读取的数据类型也不一样。使用new方法实例化scanner对象的时候,参数System.in是调用System类的静态常量in,这个静态常量的类型就是一个InputStream 使用scanner键入值需要注意:next方法获取单个单词的时候如果有不止一个单词的话,下一次执行读取方法的时候会继续读取,直到单词全都读取完成。读取数值的时候如果键入的类型不对的话会抛出异常,如果读取浮点数但是键入一个整数的话会自动转换
输出
文件的输出很简单,直接使用System类的静态常量out(PrintStream类型)调用打印方法即可。但是除了普通的输出之外,简单的格式化输出也需要掌握,比如格式化输出的两种方式,out的printf方法和String的format方法
🛫 关于控制路程都需要注意啥?
在学习控制流程之前,我们需要了解一下块的概念。块就是将若干条Java语句使用一对大括号括起来,又叫复合语句。需要注意的是:块确定了变量的作用范围,块之间可以嵌套,而且嵌套的几个块中不能声明同名的变量while循环和do-while循环的区别
while循环执行循环体之前就判断是否应该执行循环体,而do-while循环在执行循环体之后才进行判断,所以说while的循环体可能一次都不执行,但是do-while至少执行一次
for循环
关于for循环相信大家都已将不陌生了,接下来说的就是一些大家可能忽略的点。for循环内部定义变量的作用范围只在for循环里,当循环结束时变量也就失去了作用。循环条件尽量不要使浮点数,否则的话由于舍入误差的存在极有可能永远无法达到精确地最终值,也就是说会陷入死循环switch语句
switch语句中有很多需要大家注意的点。首先,switch语句中的case标签值不能重复。其次,choice选项和case标签的类型只能是char、byte、short、int的常量表达式或者包装类,枚举常量(标签中不用指定枚举名,可从choice选项推知),String字符串,切记choice选项不可以是一个条件判断语句
最后,如果没有break;语句的话会触发多个case分支又被称为是switch的穿透性,也就是说如果case分支语句没有break语句的话,会从与choice相匹配的那个case分支开始,执行之后所有的case分支语句,直至遇到break语句或者执行完整个switch语句。但是switch的穿透性也并不一定就是坏事,我们可以利用其穿透性完成一些代码的简化 流程中断语句break: 结束当前所在循环、条件语句或者switch所在分支的执行。continue: 结束本次循环,继续下一次循环(只能在循环语句中使用)
🛫 数组初始化的小细节
一维数组的初始化
数组就是用来存储相同类型的一个序列,所以在声明的时候需要使用数据类型对元素类型进行规范,而且一旦数组创建之后就固定了元素的类型与个数,一旦超出数组的长度就会抛出ArrayIndexOutOfBoundsException异常 以上四种初始化方式,第二种不能指定数组长度否则会报错;第三种不能直接将一个大括号中的所有元素赋值给一个已经声明过的数组变量,需要new之后再赋值;第四种声明的的时候必须指定数组长度(可以为0),且声明之后只能按照索引一个一个的进行初始化,未初始化之前使用数组类型对应的默认值
多维数组(以二维数组为例)
由此可见,二维数组的初始化方式与一维数组基本一致,其实就是一维数组存储一维数组,一般都是借助行和列的概念理解数组的两个维度。使用第四种方式对二维数组进行初始化的时候,必须指定二维数组的行数(列数可以不指定)。还有一点就是[]的位置不固定,可以是int[ ] a[ ]、int a[ ][ ],但一般都用int [ ] [ ] a的形式
🛫 类?对象?他们的关系又是什么?
类是一种抽象概念是构造对象的模块和蓝图,类是具有相同特性和行为的对象的抽象化。正如我们之前所知的,标准Java库中提供了很多的类供我们使用,但要是想要实现自定义的功能,我们还是需要创建一些自己的类以便描述自己的应用程序业务。
众所周知,Java是一门面向对象程序设计语言(Object Oriented Programming OPP ),在Java中万物都是对象,对象是一个具体的概念,拥有特定的行为和状态,且对象的行为和状态之间会产生相互影响。
类是一种抽象化概念,对象是一种具体的概念。对象可以通过类的实例化方式构造出来,类是对一种具有相同特性和行为对象的抽象化体现
🛫 关于构造器你都知道什么?
首先是构造器的简介:构造器又被称为构造函数,要想使用对象的话,首先必须要构造对象,在构造对象的同时构造器会运行并初始化类中字段的初始状态。关于构造器我们还需要了解的是:
- 构造器的命名应该与类相同
- 每个类中能够有一个以上的构造器
- 构造器的参数可以是0~无数个
- 构造器没有返回值
- 千万别在构造器中定义与实例字段同名的局部变量
- 构造器总是伴随着new操作串一起使用来实例化对象(实例化出来的Java对象都在堆中存储,使用new实例化会返回该对象的地址)
如果在自定义类编写的时候不去定义构造器的话,编译器会默认提供一个该类的无参构造器,无参构造器没有初始化方法体的话就会初始化所有的变量成该数据对应类型的默认值,有初始化方法体就按方法体对字段进行初始化。只要是你定义了一个有参构造器的话,就不会提供默认的无参构造器,此时使用无参构造器实例化对象的话就会产生异常
🛫 关于类的源文件都需要知道什么?
一个.java文件中只能有一个public修饰的公共类,但是可以有任意数目的非公共类,源文件的文件名就是由这个public修饰的类名加上.java后缀组成 当编译这个拥有两个类的.java文件时,编译器会在目录下创建两个类文件:一个是Test_construtor.class字节码文件,一个是Test.class字节码文件 main方法可以在任意一个类中(公共类或者非公共类都可以),运行的时候需要将包含main方法的类名交给java.exe程序,即可执行返回相应的结果 使用通配符*可以完成多个源文件的调用,这里假设People类和Fork类被分别放在两个源代码文件中,如果编译器发现People源文件使用到了Fork类的时候,就会查找名为Fork.class的字节码文件,如果找不到就会搜索Fork.java源代码文件进行编译。更重要的是,如果Fork.java的字节码文件有更新的话(也就是内容发生改变),java编译器就会自动重编译这个源代码文件。
🛫 重载和重写都是什么?区别又是什么?
方法重载
方法重载就是一个类中方法名相同但是方法的参数列表不相同的一堆方法,在这里参数列表不同说的是参数的类型、参数的个数、甚至于参数的顺序不一样。访问权限和方法的返回值类型不能作为方法是否重载的判断依据,也就是说当方法名和参数列表一样的时候,访问权限和方法的返回值类型不管是否一样都不算是方法重载。 方法出现重载的时候说明这个类中有两个以上的同名方法,当我们调用方法的时候编译器是如何确定到底调用的是哪个方法的呢?这就要说到重载解析的概念了,重载解析就是使用各个重载方法中的参数类型与特定方法调用所使用的值类型和顺序进行匹配,从而挑选出正确的方法,当然都找不到的话就会产生编译时异常。
方法签名的概念:方法名加参数类型的组合就是方法的签名,一个类中无法存在两个签名一样的方法方法重写
方法重写就是当子类继承超类之后就拥有了超类的属性和行为,但是超类不想原封不动的使用超类的行为,于是就重新定义超类的方法体。方法的重写应该注意的是:
- 重写发生在超类和子类之间
- 重写时的方法名、参数列表、返回值类型相同,但是如果重写方法的返回类型是被重写方法返回类型的子类的话也可以
- 重写方法的访问权限要大于被重写方法(public>protected>default>private)
- 重写方法不能抛出新的检查异常或者抛出比被重写方法范围更大的异常
- 子类无法重写超类的静态方法和私有方法
重载与重写的区别
1、前者实现的是编译时的多态性,而后者实现的是运行时的多态性。
2、重写发生在子类与父类之间
,重载发生在同一个类
里
3、重写同名同参
,重载同名不同参
(同名参数的类型、参数的个数、甚至于参数的顺序)
4、重写的返回值类型要兼容(大于)
被重写方法(public>protected>default>private),重载对返回值类型没有要求
5、子类无法重写超类的静态方法
和私有方法
🛫 关键字
🪂 this和super关键字
属性和方法的调用
this.属性或方法名调用当前对象中的属性或方法,如果本类中没有超类中有的话就调用超类中的,都没有的话就报错。super.属性或方法名只访问超类中的属性或方法,即使超类没有子类中有的话也会报错构造器的调用:
构造器的调用讲究比较大,首先构造器的调用使用的是this(参数列表)或者super(参数列表)。当子类继承超类并使用构造器进行初始化的时候,子类每一个构造器的第一行会有一行默认的隐式语句super();调用超类的无参构造器,且超类的无参构造器会先于子类构造器执行 前面说过如果超类中只定义了一个有参构造器的话,就不会提供超类的默认无参构造器,如果此时子类再定义构造器的话构造器会默认调用超类的无参构造器,这就会产生报错。解决方案就是超类定义无参构造器,或者在子类的构造器中显式使用Super(参数列表)调用超类的有参构造器this(参数列表)调用的是本类的构造器,super(参数列表)调用的是超类的构造器
🪂 instanceof关键字
a instanceof A用来判断某个实例变量a是否属于A这个类的类型,如果是的话就返回true否则返回false。instanceof 关键字的使用场景就是,在进行向下转型之前判断一下左边的对象是不是右边的子类,如果是的话才能进行类型强转,否则会出现ClassCastException异常。值得注意的是,如果 a instanceof A 返回true的话,A的父类放在右边也会返回true,但是A的子类放在右边不一定返回true。
🪂 static关键字
静态变量
使用static修饰的变量又称为是静态变量或者类变量,静态变量的特点是通过该类创建的所有对象共享一个变量,一个对象修改静态变量的值会造成其他所有对象对该静态变量的引用值发生改变;而不使用static修饰的实例变量的特征是,每个对象之间的变量修改互不影响。
静态方法
静态非静态之间的相互调用: 使用static修饰的方法又称为是静态方法,静态方法和静态变量的使用有很大的相似之处,值得注意的是,静态方法中无法调用该类的非静态方法和属性,而非静态方法可以调用静态和非静态的方法和属性。 静态方法内部无法使用的关键字: 值得注意的是,静态方法内部无法使用this和super关键字,因为this和super都是对对象属性或方法的引用,static是随着类的加载而加载,也就是说static的加载是在对象之前的,static加载的时候还没有对象的存在,所以说此时调用会产生逻辑错误导致报错 静态方法无法被重写覆盖: 此外比较重要的就是,static修饰的方法不能被重写覆盖,也就是说子类和超类中满足重写条件的static方法不会导致重写覆盖,使用多态调用的还是超类中的方法,与此同时private修饰的方法封装在超类中也无法被重写 静态方法调用: 还有就是,我们都知道static修饰的方法可以使用类名直接调用,但是并不是所有的静态属性都可以直接使用类名进行调用,因为如果静态属性被private修饰的话就无法使用类名调用,所以说“静态属性可以直接用类名调用”这句话是不正确的,应该是“非私有的静态属性可以直接用类名调用”
🪂 final关键字
修饰类:使用final修饰的类无法被其他类所继承,也就说该类不能拥有子类,比如String、System等类
修饰方法:使用final修饰的方法无法被重写
修饰变量:使用final修饰的“变量”的值无法修改,也就是一个常量,常量的初始化方式可以是显示初始化,代码块初始化,构造器初始化
修饰局部变量:可以在方法体中使用final修饰一个局部“变量”,或者方法的参数使用final修饰,无论是哪一种,该局部“变量”的值都无法进行修改
🛫 权限修饰符的作用范围知多少?
Java中一共定义了四种权限修饰符,它们按照作用范围从小到大依次是:private、缺省(default)、protected、public,其中缺省的意思就是在不使用权限修饰符的情况下就默认使用该修饰符。这四种权限修饰符可用来修饰属性、方法、构造器、内部类等结构,但是类只能使用缺省和public修饰
这里我以属性的调用为例,方法的效果与之相同,接下来就使用代码测试上图结论方便大家的理解。首先是属性定义的本类内部属性定义所在类的所在package下的所有类中属性定义所在类的不同package有继承关系的子类属性定义所在类的不同package的普通类
🛫 抽象类
一旦一个类使用abstract修饰之后就代表他是一个抽象类,抽象类的显著特征就是:抽象类无法实例化。有的小伙伴可能就会问了,既然抽象类已经无法实例化对象了,那么它是不是已经没有构造器了?不,抽象类依然有构造器,构造器的作用就是供子类继承的时候进行调用。
🛫 抽象方法
方法声明: 使用abstract修饰的方法只能有方法的声明不能有方法体和大括号
抽象的类与方法之间的关系: 包含抽象方法的类一定是一个抽象类,但是抽象类中不一定包含抽象方法。而且继承了抽象类的子类必须重写该类中所有的抽象方法(如果超类的超类中也有抽象方法,此时子类也必须重写超类的超类的抽象方法),否则子类也要使用abstract修饰成抽象类,这样的话子类也就无法实例化 abstract关键字不能使用的地方: 关键字只能用来修饰类和方法,无法修饰属性、构造器、代码块等结构。方法的声明中:abstract关键字无法与private修饰符、static关键字、final关键字一起使用,因为私有、静态、final方法无法被子类重写,但是抽象方法必须被子类重写,产生冲突。类的声明中:abstract关键字无法与final关键字一同使用,final类无法被继承那么它内部的抽象方法也就无法被重写,发生冲突
🛫 interface接口
接口的存在类似于抽象类,将一些类的共同行为特征作为抽象方法抽取出来,但是他又和抽象类有着本质的不同,接口根本就不是一个类。接下来我们就使用抽象类来类比学习接口的特性
接口源文件: 接口虽然不是一个类,但是接口在很多方面和类十分相似,比如接口的源文件:接口的源文件也是一个.java文件,可以通过javac命令进行编译生成.class字节码文件。一个.java源文件中只能有一个使用public修饰的类或者接口,这个源文件是以public修饰的接口或者类命名的
🪂 接口的结构
JDK 7以及之前,接口中只能定义公共静态常量和公共抽象方法,JDK 8之后接口中还可以定义公共静态方法和公共默认方法,所以说接口中的公共静态常量在声明的时候可以省略public static final,接口中的方法声明可以省略public
静态、默认方法: 接口中的静态方法无法通过接口实现类的对象调用,只能使用接口直接调用,但是接口中的默认方法可以通过接口实现类的对象调用,而且抽象超类中的静态方法也可以使用子类对象调用。子类无法重写覆盖超类或者接口中的静态方法,但是可以重写覆盖接口中的默认方法 接口构造器: 接口中绝对不能出现构造器,这也就意味着接口无法实例化创建对象,虽然抽象类也无法实例化创建对象,但是抽象类中有构造器(供子类调用)
🪂 接口的实现和方法引用
接口的实现: 接口无法实现接口但是可以继承接口或者类,类使用implements关键字实现接口,一个类可以实现多个接口使用逗号隔开。接口中定义了抽象方法,实现接口的类必须重写这个接口中的所有抽象方法(包括通过extends继承来的抽象方法),否则这个类就必须定义成抽象类就近引用、类优先、接口冲突原则:
① 如果一个类的超类和超类的超类中定义了一个同名变量或者方法的话,由于就近原则这个类中引用的就是超类的变量或者方法,如果子类中也有的话就近调用子类的 ②类和接口是一个同一级的概念,如果一个类的超类和它实现的接口中定义一个同名变量,这就会导致这个类中变量的引用不明确而报错,但是如果是方法的话就默认类优先原则调用超类中的方法 ③还有一种就是一个类实现多个接口中有同名变量或方法,由于接口冲突此时引用这个变量或者方法就会报错,需要重写该方法或者重定义该变量
总结来说同名情况下,多重继承变量方法都就近,一接口一超类变量报错、方法类优先,多实现接口冲突
🪂 为什么使用接口
可能有的小伙伴要问了,既然抽象类跟接口都可以定义抽象方法,它们的子类或者实现类也都必须实现这个抽象方法,那么为什么不直接使用抽象类,而是大费周折的再引入接口的概念呢?抽象类毕竟是个类,类的话就只能继承一个抽象超类,但是一个类可以实现无数多个接口,也就是说接口弥补了Java语言只能单继承的局限性。
接口的使用很广泛,比如想要实现序列化就要实现Serializable 接口,想要自定义对象的排序规则就要实现Comparable接口,重写CompareTo方法……
🛫 方法的参数传递机制是什么?
方法的参数分为基本数据类型和引用数据类型,基本数据类型就是前面说过的四类八种,具体都有什么可以参考下面这篇博客的🛫 强制and自动类型转换都是啥?这个问题部分,在Java中除了这四类八种的基本数据类型之外都是引用数据类型
在答题之前先介绍一下相关概念
参数传递机制的两个专业术语:按值调用 表示方法接收到的是调用者提供的值;按引用调用 表示方法接收到的是调用者提供的变量地址
方法参数的两种形式:实参 就是方法调用的时候方法名后面的括号里的数据;形参 方法声明的时候方法名后面的括号里的数据,这就相当于一种局部变量,只在它定义的这个方法内部有效
基本数据类型作参数
基本数据类型的参数采用按值调用的传递机制,也就是说方法调用的时候传过去的是一个值,但是实参并没有作为这个值传递过去,而是将实参的副本传递过去,方法中对形参的所有操作都是对实参副本的操作,并不会更改实参的实际值
引用数据类型作参数
引用数据类型的参数也是采用按值调用的传递机制,也就是说方法调用的时候传过去的也是一个值,一个实参的副本 但是看上面代码的运行结果,许多小伙伴是不是就懵了,不是说传过去的是一个实参的副本嘛,为什么实参的结果值也互换了呢?别急,仔细想一想,数组是一个引用数据类型也就是说是一个对象,那么实参的arr是个什么?当然是这个数组对象的引用地址了,那么传递的副本也就是这个引用地址的副本,形参按照这个引用地址的副本对数组进行操作,当然也就按照地址操作了实参所对应的对象的值了。这么一来是不就是可以解释的通了
综上所述,Java中方法的参数传递机制只有按值调用,并没有按引用调用
🛫 关于包装类你都知道什么?
🪂 包装类基本知识
数据类型 | 包装类 | 储存空间(byte) | 大小 |
---|---|---|---|
byte | Byte | 1字节 | 8位 |
short | Short | 2字节 | 16位 |
int | Integer | 4字节 | 32位 |
long | Long | 8字节 | 64位 |
float | Float | 4字节 | 32位 |
double | Double | 8字节 | 64位 |
char | Character | 2字节 | 16位 |
boolean | Boolean | 1字节 | 8位 |
🚩每一个基本数据类型都有一个对应的包装类,前六个类派生于公共的超类Number
🚩包装类是不可变的,也即是说一旦构造了包装器,就不允许更改它的值,与此同时包装类还是final修饰的,因此不能派生他们的子类
🚩泛型也必须声明成包装类型,而不能使用基本数据类型
🚩包装类的值比较需要使用equals方法,而不是==判断
🪂 什么是装箱和拆箱?
Java主张一切皆对象的思想,但是基本数据类型并不是对象,于是就有了包装类的概念。其中装箱就是将一个基本数据类型包装成其对应的包装类,拆箱就是反过来将包装类拆成对应的基本数据类型数据。根据转换时是否使用方法,装箱又称为自动拆装箱和手动拆装箱
手动装箱有两种方式:使用包装类构造器、包装类的valueOf方法;手动拆箱有一种:包装类的xxxValue方法。但是Java提供了自动拆装箱,如果二者相互复赋值的话就会自动转换而无需使用方法进行转换,且使用泛型定义的集合添加基本数据类型的时候也会自动装箱为对应的包装类型
🚩装箱和拆箱是编译器的工作
🪂 包装类cache缓存机制
从Java5开始,包装类新增了自动拆、装箱的功能,除此之外还新增了cache缓存机制,该机制会将取值在一定范围内的值创建成相应的对象缓存起来,这些缓存起来的对象在以后使用到的时候就可以直接用,这样就可以避免在这个范围内的值重复创建造成的内存损耗从而降低性能。为了更好的了解这个机制,让我们看一下接下来的这段代码写下自己的答案,然后结合cache机制的解释再推测一下答案,最终结果在后面给出 这里通过源码(下面给出),以Integer为例来了解一下包装类的cache缓存机制,Integer类中有一个IntegerCache类,这个类的主要作用即就是创建[-128,127]之间的所有对象并添加到cache数组中,等到调用valueOf方法的时候就使用if判断是不是在范围内,如果在的话就直接在cache数组中直接返回,反之就使用构造器创建一个对应的Integer对象返回
private static class IntegerCache {
static final int low = -128;
static final int high;
static final Integer cache[];
static {
// high value may be configured by property
int h = 127;
String integerCacheHighPropValue =
sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
if (integerCacheHighPropValue != null) {
try {
int i = parseInt(integerCacheHighPropValue);
i = Math.max(i, 127);
// Maximum array size is Integer.MAX_VALUE
h = Math.min(i, Integer.MAX_VALUE - (-low) -1);
} catch( NumberFormatException nfe) {
// If the property cannot be parsed into an int, ignore it.
}
}
high = h;
cache = new Integer[(high - low) + 1];
int j = low;
for(int k = 0; k < cache.length; k++)
cache[k] = new Integer(j++);
// range [-128, 127] must be interned (JLS7 5.1.7)
assert IntegerCache.high >= 127;
}
private IntegerCache() {}
}
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
但是你可能就会有疑问了,如果我就使用一次包装类的话,它也会缓存创建范围内的所有对象,这样的话怎么实现节省内存提升性能呢?当然,包装类的cache缓存机制是针对大程序而言的概念,小程序并不能很好地体现。于是当确定同值对象使用的次数很少时,我们就可以使用构造器来创建包装类对象,因为缓存类只能通过valueOf方法才会生效
你可能又有疑问了,使用自动装箱直接赋值的形式创建包装类对象会不会使用到缓存呢?答案是 会,因为自动装箱底层就是调包装类对应的valueOf方法,那么你是怎么确定的呢?对自动装箱和自动拆箱代码编译生成的字节码文件进行反编译得出下面的内容,根据12和19行得知:自动装箱调用的是valueOf方法,自动拆箱调用的是intValue方法
public class com.example.demo.code.AutoPacking {
java.lang.Integer i1;
int i2;
public com.example.demo.code.AutoPacking();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: iconst_1
6: invokestatic #2 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
9: putfield #3 // Field i1:Ljava/lang/Integer;
12: aload_0
13: new #4 // class java/lang/Integer
16: dup
17: iconst_2
18: invokespecial #5 // Method java/lang/Integer."<init>":(I)V
21: invokevirtual #6 // Method java/lang/Integer.intValue:()I
24: putfield #7 // Field i2:I
27: return
}
通过上面的分析,我们就可以很容易的得出上面那段代码的答案了
包装类的缓存范围
数据类型 | 包装类 | 缓存范围 |
---|---|---|
整型 | Byte、Short、Long、Integer | [-128,127] |
浮点型 | Float、Double | 无 |
字符型 | Character | [0,127] |
布尔型 | Boolean | true、false |
🛫 关于迭代器你都知道什么?
🪂 什么是迭代器?
所谓迭代的意思就是交换替代,迭代器并不是一种数据结构或者集合,而是可以过迭代器中的方法逐个访问集合中的每个元素的一种方法。提到迭代器最重要的就是Iterator接口,所有想要使用迭代器迭代的结构都需要实现或者继承这个接口
🪂 迭代器的4个API
Iterator接口包含4个方法,分别是next、hasNext、remove、forEachRemaining,接下来在学习构造器如何使用之前我们先学习一下它的四种方法
首先一开始构造器中的有一个类似于指针的标志,指在集合中第一个元素的前面,调用构器中的next方法会使这个"指针"的位置后移到第一和二个元素之间,然后返回它跨过的那个元素给构造器,也就是第一个元素,如果"指针"到最后一个元素后面再调用next方法的话就会导致抛出NoSuchElementException
remove方法则是删除当前元素,可以理解为next方法返回的那个元素,如果调用remove方法之前没有调用next方法或者是使用remove方法删除该元素之后都会导致构造器为空,此时调用remove方法就会导致程序抛出IllegalStateExceptions异常
hasNext方法就是判断迭代器是否还有可迭代的下一个元素,如果有的话就返回true否则返回false,为了避免上述两种异常,remove方法每次都要配合另外两种方法一起使用,每次调用next方法之前都使用hasNext方法判断一下,避免没有可迭代元素导致的异常,每一次调用remove之前都使用next方法获得元素,避免迭代器为空导致的状态异常
forEachRemaining方法则可以直接遍历迭代器中的每一个元素并调用方法参数中的Lambda表达式,直到迭代器将集合的元素全部迭代完为止
🪂 如何使用迭代器?
接下来将结合上面的描述给出一个将集合中的元素全部删除的标准代码,然后再给出两个错误的代码,大家可以在使用迭代器的时候规避一下
正确写法
// 创建一个ArrayList集合
Collection<String> strings = new ArrayList<>();
strings.add("a");
strings.add("b");
strings.add("c");
strings.add("d");
// 获得迭代器对象
Iterator<String> iterator = strings.iterator();
// 使用while循环迭代集合中元素 使用hasNext方法判断
while (iterator.hasNext()) {
// 使用next获取下一个元素
iterator.next();
// 删除这个元素
iterator.remove();
// 打印集合中元素
System.out.println(strings);
}
错误示范一
// 创建一个ArrayList集合
Collection<String> strings = new ArrayList<>();
strings.add("a");
strings.add("b");
strings.add("c");
strings.add("d");
// 获得迭代器
Iterator<String> iterator = strings.iterator();
// 使用while循环迭代集合中元素 使用next方法判断下一个元素是否为空
while (iterator.next() != null) {
// 输出这个元素
System.out.println(iterator.next());
}
第一个的错误原因:使用next方法判断下一个元素是否为空,这样就会导致"指针"到最后一个元素后面依旧会执行next方法,这样就会导致抛出NoSuchElementException异常;而且next方法每调用一次都会将"指针"向后移动一位,哪怕只是用于if判断。所以程序就会每隔一个元素输出一次,最后抛出NoSuchElementException异常错误示范二
// 创建一个ArrayList集合
Collection<String> strings = new ArrayList<>();
strings.add("a");
strings.add("b");
strings.add("c");
strings.add("d");
// 获取迭代器对象 并使用while循环迭代集合中元素 使用hasNext方法判断
while (strings.iterator().hasNext()) {
// 获取迭代器对象 并使用next获取下一个元素 然后输出
System.out.println(strings.iterator().next());
}
第二个的错误原因:每使用iterator方法获得一次集合对应的迭代器对象,都会默认将"指针"放到第一个元素的前面,于是第二个错误示范中一直使用集合中的第一个元素"a"进行判断有没有下一个元素,所以会导致程序陷入死循环,循环体里也会一直创建结合的迭代器对象,并将"指针"放到第一个元素的前面,然后调用next方法输出元素"a"
🛫 Collection集合接口知多少?
集合大体上可以分为两种,一种是单列的Collection集合,一种就是双列的Map集合,所谓的单双列可以理解为元素中数据的个数,单列集合一个数据作为元素存储,双列集合两个具有映射关系的数据作为元素存储。这一篇我们先学习Collection集合接口的内容,Collection集合按照元素存储是否有序又可分为List集合、Set集合
前面之所以先学习Iterator接口的原因就是,Collection接口继承了Iteator接口,于是它的子接口set和list都可以使用迭代器对集合中的元素进行迭代
🪂 为什么不使用数组而是集合?
在学习集合之前,我们将存储多个对象或者元素的任务都交给了数组,但是数组存储元素有以下缺点:①数组一旦初始化之后,长度就确定不可修改,元素个数超出数组长度的话会抛异常。②数组中提供的API很少,增加元素需要现将索引后的元素后移空出位置将元素添加进去,删除元素需要删除之后将后面的元素前移将空出来的位置补足,以上操作只能通过代码实现并没有现成的API可以使用。③数组存储数据的特点:有序、可重复,对于一些无需、不可重复的业务需求就很难满足
集合就可以很好的弥补数组的上述缺点,而且集合提供了一组较为完善的数据结构,我们可以根据具体的业务需求来选择具体使用的集合类型。比如说存储元素无序不可重复的Set集合,有序可重复的List集合,具有映射关系的Map集合等
🪂Collection接口的API都有什么?
🪂AbstractCollection类知多少?
以上给出的Collection接口中的API都是抽象方法,也就意味着每一个此接口的实现类都需要重写这些抽象方法,实际上Collection接口的直接或者间接实现类有很多,如果每一个都需要重写这些方法的话就会很是麻烦。于是Java类库的设计者提供了AbstractCollection类,该类中只将size方法和Iterator方法声明为抽象,其他方法都提供了默认实现,如果子类不提供这些方法的方法体的话就使用该类中的默认实现
下图可知,list、set集合的实现类都直接或者间接的继承了AbstractCollection类,为的就是简化重写Collection接口的抽象方法
🛫 关于List集合类你都知道什么?
我们将实现了List接口的类称为是List集合类,List集合类中元素存储有一个特点:有序、可重复,List接口常用的有三个实现类:ArrayList、LinkedList和Vector
🪂 List接口三个实现类的异同?
三者相同点:ArrayList、LinkedList和Vector都实现了List接口,所以它们存储数据的特点都一致,那就是有序、可重复
ArrayList和Vector相比,相同点就是底层结构上都用到了Object [ ]数组存储元素,不同点就是ArrayList是线程不安全但是效率高的,而Vector是线程安全但是效率低的,造成这个不同的原因就是Vector中的方法都使用了同步锁,这样在保证线程安全的同时也会降低它的效率
ArrayList和LinkedList相比最大的不同点就是底层存储结构,上面说过ArrayList使用的是Object [ ]数组存储元素,而LinkedList则是使用双向链表进行存储,双向链表的特点就是将每一个元素都存储在一个单独的链接(link)中,这个链接由三部分组成:上一个链接的运用、数据、下一个链接的引用,双向链表就是通过上下引用将所有的链接链成一张表。 要知道数组最令人诟病的就是对元素的添加和删除,每次操作都需要移动它后面的所有元素,双向链表每次添加和删除元素只和它前后的两link有关,只需要改变上下链接的引用即可,这样的话就可以很好的解决这个弊端。于是,涉及到频繁的添加删除操作的话,可以选择使用LinkedList集合。但是由于数组中可以使用索引快速定位一个元素,而链表则是需要从头开始顺着链查找,所以涉及到频繁的查询数据可以选择使用ArrayList
🪂 ArrayList类源码浅析
ArrayList的源码在jdk 7和jdk 8之间还是有些设计上的不同的,接下来就通过对两个版本的分析来体会不同点,并思考一下jdk 8改变设计的原因
jdk 7 使用无参构造器创建一个ArrayList对象会调用它的有参构造器并传参为10,也就是说使用无参构造器默认创建一个长度为10的Object [ ]数组。然后每次调用add方法添加元素之前都会通过ensureCapacityInternal方法判断当前集合再添加新元素,也就是集合中元素个数size + 1之后会不会大于数组长度,如果超过的话就调用grow方法进行扩容。扩容的时候先将当前数组长度扩大1.5倍,如果扩大1.5之后还是无法没有size + 1大的话直接将扩容后的数组长度设置为size + 1;如果扩大1.5倍之后大于给定的常量值,判断size + 1有没有大于这个常量值,大于的话数组长度设为整型的最大值,否则就设置成给定的常量值;至此扩容后的数组长度newCapacity就确定了,然后就是调用Arrays工具类的copyOf方法将原来的数组内容拷贝到长度为newCapacity的新数组中
数组扩容完成之后就是添加新元素,回到add方法中,将参数元素添加到数组中索引为size的位置,然后size + 1索引向后移(这一步就是size++的效果),如果数组无需扩容的话就直接执行添加操作
jdk 8 jdk 8 的时候ArrayList集合调用无参构造器默认创建一个空数组对象,而不是一个有长度的数组,这样做的好处就是可以节省内存提高效率。jdk 7 就是创建一个长度为10的数组,这样的话一旦加载ArrayList类就会给数组定义长度就要按照长度分配内存空间;而jdk 8 中则使用空数组解决了这个问题,等到ArrayList集合调用add方法添加元素的时候才会动态的创建数组,类似于单例设计模式的懒汉模式思想
调用add方法都会发生什么呢?根据上图源码浅析一下,为了便于区分不同的方法调用使用不同颜色标记。
①在添加元素之前,先将当前集合再添加新元素时的长度,也就是集合中元素个数size + 1之后的值,使用②③方法进行一系列的判断
②判断当前的数组是否为空,如果为空的话返回默认数组长度10与size + 1之间的最大值,否则直接返回size + 1
③将②中的返回值作为参数执行③方法判断size + 1的大小是否大于数组的长度,如果是的话就调用grow方法扩容
④扩容的机制和jdk 7中的一致,先将当前数组长度扩大1.5倍,如果扩大1.5之后还是无法没有size + 1大的话直接将扩容后的数组长度设置为size + 1;如果扩大1.5倍之后大于给定的常量值,判断size + 1有没有大于这个常量值,大于的话数组长度设为整型的最大值,否则就设置成给定的常量值;至此扩容后的数组长度newCapacity就确定了,然后就是调用Arrays工具类的copyOf方法将原来的数组内容拷贝到长度为newCapacity的新数组中
根据上述分析,梳理jdk 8 的时候ArrayList集合第一次添加元素流程:首先实例化ArrayList对象的时候会调用无参构造器创建一个空数组对象,然后第一次调用add方法添加元素会被拦截到方法②③进行判断,执行方法②的时候数组为空对象执行判断体返回10(默认数组长度)和1(size + 1)的最大值10,然后执行方法③10(方法②返回值作③的参数)减去0(空数组长度)>0,执行判断体中方法④扩容数组,空数组扩容1.5倍还是小于10,所以将新数组长度定为10,并将原空数组拷贝到新数组中(这一步虽然像废话但是代码中定义有),最后将add的参数放到索引为0的位置,然后索引自增1
所以说,有了上面分析的前车之鉴,大家如果在使用ArrayList集合的时候明显知道元素的个数,或者知道一定多于10个的话,可以在创建ArrayList对象的时候使用有参构造器指定底层数组的长度,这样的话就可以避免向集合对象中添加元素的时候多次扩容,提高程序的效率
🪂 LinkedList类源码浅析
LinkedList类内部定义了一个内部类Node也就是双向链表的一个节点,前面讲过它是由三个部分组成,于是内部类Node也包含三个属性与之对应(上一个节点的引用 => prev、数据 => item、下一个节点的引用 => next),然后使用无参构造器创建LinkedList对象底层什么都不创建并不会像ArrayList一样创建一个数组啥的,但是它会默认初始化类属性first(头结点)和last(尾结点)为null。
再之后就是调用add方法添加元素了,add方法底层使用的是linkLast方法,第一次添加元素的时候last的值为默认初始化的null,否则就是原链表的尾节点。将last的值赋值给 l 这个 l 就是通过有参构造器创建Node对象时的第一个参数,也就是将新链接的前一个节点引用指向原链表的尾节点,然后第二个参数是数据e,第三个参数是null(因为将数据添加到了最后,后面没有节点了,所以下一个节点的引用为null)。将创建好的Node对象赋值给last也就是指定新链表的尾节点,l 是null的话就将创建好的Node对象也赋值给first也就是指定新链表的头节点,表示新链表的头节点和尾节点都是新建节点(因为第一次添加元素双向链表中就只有一个节点);如果不为null的话就将原链表的尾节点的下一个链接的引用指向新节点。
根据上面的分析可以得知,所有的新节点都链在了双向链表的尾部,所以这种方法就是双向链表的尾插法
🪂 vector类源码简析
由于vector类已经很久未更新,于是它的底层源码就和ArrayList的jdk 7 版本的几乎一致,使用无参构造器创建对象的时候会调用有参构造器创建一个长度为10的object数组,只不过是扩容的时候会扩容到原来长度的2倍,它和ArrayList的区别前面也说过就是vector的方法上都加了锁,因此会牺牲性能来保证线程安全
🛫 关于Set集合类你都知道什么?
与list集合相类似的是,我们将实现了Set接口的类称为是Set集合类,Set集合类中元素存储有一个与List集合类正好相反的特点:无序、不可重复,Set接口常用的有三个实现类:HashSet、LinkedHashSet和TreeSet
🪂 如何理解Set集合的无序不可重复?
无序性指的是每次新增的元素,都根据元素的哈希值向set中进行存储,而非按照元素新增的顺序从左到右向set集合依次存入。
不可重复性指的是Set中新增的元素不会与已有的元素重复,判断是否重复的标准是:先使用hashCode方法获取元素的哈希值找到新元素的位置,如果结该位置已经有元素的话,再判断哈希值是否相等,如果还相等的话再使用equals()方法判断,如果还相等的话就说明说明该元素已经存在,不可添加进set
以上述不可重复性的判断标准,引用数据类型元素判断是否已重复的依据就是引用地址,因为hashCode()和equals()在未重写之前就是根据引用数据类型地址进行哈希值计算和判等,就算是属性相等的自定义类对象,依旧会被set集合认定为非重复元素。
于是想要属性相等的自定义类对象不再添加到set集合的话,就要重写自定义类的hashCode()和equals()方法
🪂 Map集合类
首先,HashSet的底层实现就是直接使用了HashMap,所以可以借助Set的知识来学习Map集合
Map集合相对于单列集合Collection而言是一种双列集合,也就是说Map集合中的所有元素都是成对出现的,一般存储的都是KV键值对的形式;尽管如此,实际上Map存储元素使用的是Entry,KV是Entry中的两个属性。Map集合主要有以下实现类:HashMap、LinkedHashMap、TreeMap、Hashtable、Properties,他们的关系如下图所示
Map集合类之间的区别
相同点:都直接或间接实现了Map接口,所以它们存储数据的特点都一致,那就是键值对形式、无序、不可重复。
HashMap与Hashtable: HashMap是新类线程不安全的但是效率高,Hashtable是老类线程安全的但是效率低。除此之外,HashMap的KV都可以存储null,但是Hashtable都不能存储null,否则就会抛出NullPointerException异常。
HashMap与LinkedHashMap: 只有一点区别,那就是LinkedHashMap因为使用了双向链表的前后元素指向
,所以可以实现按照元素的添加顺序遍历集合元素。
TreeMap: 底层使用红黑树,可以实现对集合元素进行排序,具体的排序规则可以参考TreeSet的自然排序和定制排序的设置方法
Properties: 是Hashtable的子类,它的KV都是String类型,常用于处理配置文件
🪂 HashSet类、HashMap类数据结构及源码浅析
由上图源码可知,HashSet底层使用的就是HashMap,HashSet新增元素就是把元素作为键向底层的HashMap新增一个元素。所以要知道HashSet的底层原理就要知道HashMap。
HashMap的源码在jdk 7和jdk 8之间还是有些设计上的不同的,接下来就通过对两个版本的分析来体会不同点,并思考一下jdk 8改变设计的原因
jdk 7
使用无参构造实例化HashMap对象的时候,底层调用默认初始化容量是16
、默认加载因子是0.75
有参构造器进行实例化。有参构造器首先判断初始容量是否小于零,小于零抛出异常;然后判断初始容量是否大于定义的最大容量,大于将初始容量赋值为定义的最大容量;再判断加载因子是否小于等于0或者为nan,满足则抛出异常。然后使用while循环左移运算(左移一次相当于乘2一回)找到数组的长度,接着通过min(数组的长度乘以加载因子和定义的最大容量+1)
得到数组长度临界值threshold
(作为后面扩容的依据),最后创建出来Entry数组。
使用put()方法新增元素的时候,先判断key是null的话直接新增或者value替换,然后调用hash()计算key的哈希值,此哈希值经过某种算法indexFor
(哈希与length-1进行与运算)计算得到在Entry数组中的存放位置,如果此位置上的数据为空直接添加成到此位置。如果此位置上的数据不为空,使用for循环判断新元素key与链表上的所有元素key的哈希值和equals是否都相等,都相等的话将value进行替换。都不相等的话也添加新元素。
添加新元素的时候,先判断是否需要扩容,也就是判断当前位置没有元素并且
当前数组元素大于等于数组长度临界值threshold
,如果满足的话就将数组长度扩容为原来的2倍,然后重新计算链表在新数组上的存放位置。最后再将新元素作为头元素放到该位置上的链表中,也就是createEntry方法中的先取出数组中该位置上链表的头元素,然后将新元素的下一个节点指向原链表形成新链表,然后将新链表放到数组的该位置上
jdk 8
使用无参构造实例化HashMap对象的时候,底层只有一个默认加载因子是0.75
的赋值操作,也就是说没有涉及到任何的数组创建。
使用put()方法新增元素的时候, 先判断tab数组是否为null或者长度为0,如果是的话就执行resize()方法进行扩容扩容过程下面讲。然后经过某种算法(哈希与length-1进行与运算)计算得到在Node数组中的存放位置,如果此位置上的数据为空直接添加成到此位置,否则的话判断新元素key与链表上头元素key的哈希值和equals是否都相等,相等value替换,不等且链表不为红黑树循环对比链表中剩余元素,找到替换找不到新增。每次新增之前都判断是否超过数组长度临界值(是否需要扩容),每次在链表上新增之后都判断链表长度是否超过8且数组长度是否超过64,超过就将链表转换为红黑树存储。
使用resize()方法进行扩容的时候,分为两种原数组长度为零且原加载因子为0
和原数组长度非零且原加载因子非0
。第一种原数组长度为零且原加载因子为0即初次执行put操作,经过if和else if最终进入else里,新数组赋值默认初始化容量是16
,计算新数组长度临界值即默认初始化容量是16
乘以默认加载因子是0.75
,依次创建新数组。第二种则是通过左移运算将长度扩容为原来的2倍。
当新增一个数组之后大于数组长度时,list才会进行扩容(原长度加上右移一位,也就是原长度的1.5倍);而map在超过数组长度临界值时就会扩容(直接2倍),这样做是为了避免数组中的链表过多,也就是说同一位置上的元素尽可能多一些形成树形存储,而数组长度临界值=
默认初始化容量是16
乘以默认加载因子是0.75
,于是可以通过减小加载因子,从而增大数组的扩容频率,提高map中数据的读取效率
🪂 LinkedHashSet类、LinkedHashMap类源码浅析
与上面一样,LinkedHashSet底层使用的就是LinkedHashMap,要知道LinkedHashSet的底层原理就要知道LinkedHashMap
LinkedHashMap的所有方法都是使用super()继承自父类,也就是HashMap,但是LinkedHashSet却可以按照元素的添加顺序输出,原因是LinkedHashMap使用的Entry在继承Node的基础上又添加了before和after属性,这样既可以知道同一位置链表上的下一个元素(next),还可以知道元素添加前后元素(before、after)从而可以按照元素的添加顺序输出
🪂 TreeSet类、TreeMap类源码浅析
与上面一样,TreeSet底层使用的就是TreeMap,要知道TreeSet的底层原理就要知道TreeMap
TreeMap的key必须是同一类型的数据,因为TreeMap会根据key进行排序输出,如果是不同类型的元素会报ClassCastException,排序方式可以分为自然排序和定制排序
自然排序
自然排序就是自定义类实现Comparable接口,然后重写compareTo方法,在该方法中定义排序规则。如果compareTo方法的返回值为0的话,TreeSet就会认为该元素为重复元素,重复元素不可添加进数组里。
@Data
public class Student implements Comparable{
private String name;
private int age;
@Override
public int compareTo(Object o) {
if (o instanceof Student){
Student student = (Student) o;
int compare = this.name.compareTo(student.name);
if (compare != 0) {
return compare;
} else {
return Integer.compare(this.age, student.age);
}
}else {
throw new RuntimeException("输入的格式有误");
}
}
}
public class TestFour {
public static void main(String[] args) {
Set set = new TreeSet();
Student stu = new Student("Tom",20);
Student stu1 = new Student("Jerry",20);
Student stu2 = new Student("Mary",23);
Student stu3 = new Student("June",21);
set.add(stu);
set.add(stu1);
set.add(stu2);
set.add(stu3);
for (Object o : set) {
System.out.println(o);
}
}
}
定制排序
定制排序是新建一个Comparator对象作为TreeSet对象实例化的参数,重写compare方法,在该方法中定义排序规则。如果compareTo方法的返回值为0的话,TreeSet就会认为该元素为重复元素,重复元素不可添加进数组里。
@Data
public class Student{
private String name;
private int age;
}
public class TestFour {
public static void main(String[] args) {
Set set = new TreeSet(new Comparator(){
@Override
public int compare(Object o1, Object o2) {
Student stu1 = (Student)o1;
Student stu2 = (Student)o2;
int result = stu2.getStuAge() - stu1.getStuAge();
if(result == 0){
result = stu2.getStuName().compareTo(stu1.getStuName());
}
return result;
}
});
Student stu = new Student("Tom",20);
Student stu1 = new Student("Jerry",20);
Student stu2 = new Student("Mary",23);
Student stu3 = new Student("June",21);
set.add(stu);
set.add(stu1);
set.add(stu2);
set.add(stu3);
for (Object o : set) {
System.out.println(o);
}
}
}
自然排序是自定义类中实现Comparable接口,然后重写compareTo方法;定制排序是新建一个Comparator对象作为TreeSet对象实例化的参数,重写compare方法。
如果自然排序和定制排序同时存在时,定制排序优先级更高