面试题
面向过程和面向对象的区别
面向过程是以函数为中心,函数之间的调用来实现功能。耦合度很高,复用性较低,并且不易维护。但它的执行效率较高(因为不用像面向对象一样去分配对象)
面向对象是以万物皆对象的思想,将事务抽象出属性和行为,对象之间的创建和调用,实现了低耦合,复用性高,并且易维护。
面向对象有哪些特征
面向对象是一种万物皆对象的编程思想,将事物抽象成对象,通
- 封装:是指将事物的属性和行为封装起来,只保留特定的接口与外部进行联系。外部在调用时不需要关心其内部的实现
- 继承:类通过继承可以获得起父类的属性和行为,并且对这些行为可以进行扩展
- 多态:不同的类对同一个方法做出不同的响应,多态主要有两种实现方式,重写和重载。重写是指子类继承父类后可以对父类的方法进行重写。而重载是指一个类具有多个同名但方法签名不相同的方法。在Java中,重写是依靠jvm的动态分派实现的,而重载是依靠静态分派实现的。静态分派是指编译器在编译时根据对象的静态类型就确定调用哪个方法,而动态分派是指虚拟机在运行期间根据对象的实际类型来确定具体要调用的方法
Java支持多继承吗
java不支持多继承,java是按更高层次进行抽象的。java能实现多继承的功能,使用接口和继承即可
方法重写规则
两小一大一同
两小:返回值小于等于父类返回值(普通数据类型不算),抛出异常小于等于父类返回值类型
一大:访问权限大于等于父类访问权限
一同:方法签名必须相同
ArrayList和LinkedList的区别
ArrayList和LinkedList都是List接口下的两个实现类,都是单值可重复可扩容的集合。ArrayList底层采用数组实现,LinkedList底层采用链表实现。ArrayList通过下标访问时时间复杂度可以达到O(1),而LinkedList在访问时时间复杂度较高O(n)。而ArrayList对节点进行增删时时间复杂度较高,而LinkedList在修改节点时复杂度较低。
LinkedList占用的空间更多,因为LinkedList为要多维护一个节点对象
如何选择ArrayList和LinkedList
如果明确没有数据的随机访问需求,或者经常需要插入和删除元素的时候才选择LinkedList,否者优先选用ArrayList
接口和抽象类的区别
- 接口和抽象类都不能被实例化
- 接口中只能定义常量和抽象方法,抽象类中可以定义普通变量和普通方法
- 接口没有构造器,抽象类可以有构造器(接口的构造器主要是用来初始化其定义的变量)
- 接口中的所有方法都是public修饰的,而抽象类中可以定义各种级别的方法
- 接口可以多继承,抽象类只能单继承
- 接口可以看作是对行为的抽象,抽象类更像是一种模板
如何选择接口和抽象类
- 如果是一个类具有什么什么行为就用接口
- 如果是一个类是什么就用抽象类
HashMap和HashTable的区别
-
HashMap是线程不安全的,HashTable是线程安全的。HashTable中几乎所有方法都使用的synchronized来修饰。
-
HashTable底层是使用数组加链表实现,HashMap1.8开始底层采用数组加链表加红黑树。
-
HashTable中不允许key或者value为null,而HashMap中key和value都可以为null
-
HashTable的初始值和扩容大小为11 old*2+1,而HashMap初始值是16每次扩容2倍
HashTable和ConcurrentHashMap为什么key-value不能为空
因为HashTable本身是线程安全的,说明HashTable是会在多线程的情况下使用的。
当线程使用get(key)时如果返回null可能会有两种情况,一种是key-value都不存在,一种是key存在但value为空。像HashMap这种单线程的Map中可以使用contains(key)来判断键值对是否存在,但在HashTable等多线程Map集合中使用则可能会出现二义性,即当线程A使用contains时返回false但此时线程B向Map中put了键值对。
HashMap
HashMap是一种键值对的存储集合。它的key必须是唯一的,他是基于Hash算法来定位数据的。在向Map中添加元素时它会先计算key的hash值然后根据hash和当前数组长度取余来定位到具体的一个槽位上
HashMap主要分两个大的版本,1.8之前HashMap采用数组加链表的实现。而1.8之后采用数组+链表+红黑树实现。在1.8之前,由于HashMap采用头插法,并发情况下可能会出现链表成环的问题。并且因为使用的是数组加链表,当map中元素哈希冲突过多时链表上的元素访问效率会降低到O(n),所以在1.8之后采用红黑树+链表来解决元素冲突。
HashMap的底层原理
HashMap主要分为1.7和1.8两个大的版本。在1.7中,HashMap底层采用数组加链表的形式实现。并且在扩容时采用头插法(并发情况下存在链表成环的问题)。在1.8中,HashMap底层采用数组加链表加红黑树的形式,并且扩容时采用尾插法避免了并发情况下链表成环的问题,但仍然时线程不安全的(此存在数据丢失问题)。1.8之后之所以采用红黑树,是因为哈希冲突过多时导致链表上的元素查询效率下降到O(1)。红黑树提高了桶中元素检索的效率。当链表上的节点达到8个时会转换成红黑树,当红黑树上的节点个数小于等于6时会重新转为链表。
这里还有一个注意点:链表转红黑树其实是有两个条件的①是链表长度大于等于8②是HashMap数组的长度要大于64。之所以这么做的原因是当数组长度小于64时通过扩容也能达到将元素重新分散的目的。
HashMap的扩容过程
HashMap每次添加元素后会判断当前map中元素的个数是否达到阈值,每次扩容的长度是原来的两倍。
HashMap中默认的初始化容量16,加载因子是0.75。
map扩容时会先创建出一个原数组大小两倍的数组,如果当前已经达到了HashMap的最大值则放弃扩容,否者依次遍历map中的元素。在1.7和1.8中定位有区别,1.7时是直接重新计算每个节点在新数组中的位置。而1.8时则通过位运算来定位节点在新数组中的位置(位运算:节点的哈希值与原数组长度进行与运算,其结果只有两种等于0不等于0)等于0时保持不同,不等于0时移动到新数组的原数组长度+偏移量的位置。
HashMap在扩容上有哪些优化
1.7时扩容都是重新hash值再进行定位的,1.8时借助两倍扩容的机制只计算最高位是否为0或者1,如果为0则定位到当前位置,如果为1则定位到旧位置+oldcap的位置
为什么HashMap的扩容因子是0.75
扩容因子是0.5很容易浪费空间,扩容因子太大会导致性能不好(比如扩容因子为1时,)。注意不是因为泊松分布
为什么HashMap转红黑树的阈值是8
泊松分布可以计算得出发生哈希冲突的节点的个数超过8的概率是极小的,如果节点个数设置太小。转红黑树比较频繁,设置太大红黑树又失去了作用
HashMap是线程安全的吗
HashMap是线程不安全的,1.7时存在扩容链表成环和数据丢失的问题。1.8虽然解决了链表成环问题但仍有数据丢失的问题。线程安全的有HashTable和ConcurrentHashMap
如何实现线程安全的HashMap
有两个方法:Collections.synchronizedMap()、ConcurrentHashMap
方法一:Collections内部的一个类实现了Map接口,使用了synchronized锁住了大部分代码所以性能比较差
方法二:使用ConcurrentHashMap,是juc并发包下提供的一个,其锁粒度较高,并发性能好适合读多写少的场景
ConcurrentHashMap
ConcurrentHashMap是一个线程安全的HashMap,它1.7和1.8实现线程安全的方式不同
1.7
1.7中ConcurrentHashMap底层是数组+Segment+分段锁实现的
主要通过segment分段锁来实现,主要原理是将原来HashMap的一个Entry数组拆分成多个segment。每个segment下有一个Entry数组,每个segment同时也是一个ReentryLock锁,并发操作时通过hash值先定位到segment再尝试获取当前segment的锁,获取成功后才进行操作。这样就降低了锁粒度,提高了并发能力。
1.8
1.8中ConcurrentHashMap底层和HashMap相同,都是使用数组+链表+红黑树实现的
其中大量使用CAS和Synchronized关键字。并且在1.8中锁的粒度细化到数组中具体的某个链表上,而1.7的锁粒度相对较大
并且ConcurrentHashMap的get方法是不需要上锁的,Map中的Value是volatile关键字修饰的所以保证了其内存可见性。
System.arrcopy和Arrays.copy的区别
StringBuilder和StringBuffer的区别
StringBuilder是线程不安全的,StringBuffer是线程安全的,StringBuffer线程安全的原因是它大部分方法都使用了synchronized修饰。当相对的StringBuider的效率较高。
注意:在单线程情况下StringBuffer并不会比StringBuilder慢,因为锁优化的存在
ClassNotFoundException和NoClassDefFoundError的区别
两个异常都是JVM找不到Class文件产生的异常,ClassNotFoundException是收检查的异常,一般我们在使用Class.forName()等动态加载类时如果找不到class文件就会报出错误。而NoClassDefFoundError是不需要我们处理的错误,这个错误的原因是编译时class文件存在,但运行时class文件不存在
出现NoClassDefFoundError异常的原因主要有两个:① 我们在编译后删除一部分class文件,这样在运行时就会出现这个错误,虚拟机在执行时找不到这个class文件 ② 某些类文件在加载到虚拟机时出错了,导致无法正确加载进去。
list的遍历方式
- for循环(效率不高)
- foreach循环
- 迭代器循环
hashmap遍历方式
- foreach-lamda表达式
- entry迭代器
- entryset
Java异常体系
Java中所有的异常都实现了Throwable接口,Throwable下主要分为Exception和Error两个子类,Exception异常表示可以被程序处理的异常,Error异常是不能被程序处理的异常,Error异常一般会导致程序进入不可恢复的状态。Exception下又分为受检异常和非受检异常,受检异常需要在代码中显式地进行处理(try-catch或throws抛出异常),非受检异常不要求进行显式处理但要避免
static和final
static和final都可以针对数据、方法、类
static修饰数据:该数据作为类变量存在,即一个类下的所有实例对象是共享的,并且类变量可以通过类名直接调用
final修饰数据:该数据作为常量存在的,是不可修改的。对于普通数据类型来说是不可修改的,对于引用数据类型来说,引用是不可修改的,引用对象中的数据是可以修改的
static修饰方法:该方法作为静态方法,可以直接通过类名进行调用
final修饰方法:该方法不能被复写
static修饰类:该类作为静态内部类存在,静态内部类不依赖于外部类,普通内部类需要先创建内部
final修饰类:该类不能被继承
创建对象的办法
- new
- Class.newInstance
- clone
- 反序列化
Java是什么语言
编译-解释型,静态强类型语言
静态:java在编译期进行类型检查
强类型:运行期间不允许变量类型转换(除非强制类型转换)
JVM、JRE、JDK
jvm是负责执行Class文件的
JRE是包括jvm以及java的核心类库
JDK是开发工具包,包含jre、编译器等等
JDK8新特性
- 接口加入default默认方法
- lamdba表达式
面向对象
什么是面向对象?
面向对象是一种程序设计思想
面向对象的三大特征
封装、继承、多态
封装
将事务的属性和行为封装成类,降低耦合,隐藏细节。保留特定的接口和外界联系,当内部发生变化时,不影响外部调用
继承
一个类通过继承另一个类后获得该类的属性和行为,并且可以扩展其原有的能力
多态
不同类的对象对同一个方法做出不同的响应就是多态
多态的实现方式主要有两种:重写、和重载
重写是指子类的对象对父类方法的扩展
重载是指一个类中有多个同名但方法签名不同的方法
重写的实现是通过虚拟机中的动态分派实现的,而重载是通过静态分派实现的。
动态分派是指虚拟机在运行时根据对象的实际类型来调用方法。(每次调用方法时,先判断对象的实际类型,再根据实际类型找到对应方法的直接引用)
静态分派是指编译器在编译时根据对象的静态类型确定要调用的方法,在类加载阶段进行解析确定要调用的方法的直接引用。
抽象和接口的区别
抽象类是对类的一个抽象,是一种模板;接口是对行为的一种抽象,是一种行为规范
-
接口可以多继承,抽象类只能单继承
-
接口中只有抽象方法,抽象类中可以有普通方法
-
抽象类中可以定义成员变量,接口中只能定义静态常量
-
两者都不能直接实例化
-
抽象类可以有构造方法,接口没有构造方法。
-
抽象类的抽象方法可以是public或者protect
两个的选择上:如果有抽象出来的公共方法,则使用抽象类,否者使用接口
Java中的值传递和引用传递
值传递:在方法调用时传递了一个对象的副本,副本被修改时不影响源对象
引用传递:传递的是对象的引用,外部对引用对象的修改会反映到源对象上
Java中其实只有值传递没有真正的引用传递
字符串
字符串结构比较
String操作的对象不可变,每次修改字符串都会生成新的字符串。
StringBuilder和StringBuffer操作的对象都是可变的但StringBuffer是线程安全的,StringBuilder是线程不安全的。StringBuffer线程安全的原因是它里面的方法都是用synchronized来修饰的。但相对的StringBuffer的执行效率要低。单线程推荐使用StringBuilder,多线程下推荐使用StringBuffer
执行效率比较
StringBuilder>StringBuffer>String
StringBuilder和StringBuffer效率要比String高的原因是,前两个对字符串的操作都是针对同一个对象的,而String每次操作都需要重新创建一个对象,这样效率自然要低一些。StringBuffer比StringBuilder低的原因是它方法需要上锁。
线程安全
StringBuilder是线程安全的,String也是线程安全的。String安全的原因是String对象只能读取不能修改,自然是线程安全的
String如何实现对象不可变
注意:String中的value数组虽然是使用final修饰的,但要知道对于基本数据类型来说final修饰后其值不能改变,但对引用数据类型来说final修饰后引用的内容是可以修改的,不能修改的是指向这个引用
在String源码中,value指向的字符串数组被final修饰,理论上该数组内容其实是可以被修改的,但String类中没有提供修改它的方法。对应的StringBuilder中提供了修改它的方法
理论上可以通过字段反射修改值
String的长度限制
Java中的字符串是有长度限制的,如果是在运行时拼接出来的字符串其长度限制为Integer的最大值,因为Java中的String是使用char数组(jdk1.7之后改用byte数组),而数组的最大长度就是Integer的最大值,所以运行时拼接字符串长度不超过Integer的最大值
但是直接定义的字符串最大长度更小,因为Class文件对字符串长度进行了限制,其最大长度为2个字节(2的16次方-1)
异常
Error和Exception的区别
Error和Exception都是java中定义的异常,并且都是Throable的子类。
Error主要指的是程序中出现的不可预料并且可能会是程序进入不可恢复状态的错误。
Exception是指程序中可以被预料的异常,需要程序员通过捕获异常或修改代码逻辑来避免的错误。Exception也分为可检查异常和不可检查异常,可检查异常是指异常需要在代码中显示的处理,而不可检查异常是指需要通过修改代码逻辑来避免发生的异常,不需要显示捕获。
注解
注解和注释相似,但注释是给人看的,而注解是给机器看的,机器在执行期间是可以获取到注解中配置的东西
元注解
Java中一共有四种元注解,元注解的作用是标识注解
@Target
该注解在哪些地方使用,可以选择的有 方法、类、字段、方法参数上
@Retention
该注解在哪些地方生效:Source(源码)< Class(字节码)< Runtime(运行时)
@Document
是否生成javadoc文档
@Inherited
子类是否可以继承父类注解
反射
反射机制提供了一种在运行期间获取类的所有属性和方法,以及对象的所有信息,以及调用方法。可以让代码更灵活
很多框架底层都使用了反射机制
缺点
反射的强大也带来了一定的安全性问题,并且反射机制的效率并不是很高
Java泛型
概念
概念
泛型是指类型参数化,由调用者来指定实际的数据类型。
好处
泛型的好处是我们可以针对泛化的数据类型编写代码,提高了代码的复用。
Java如何实现泛型
java中的泛型其实是伪泛型,通过编译阶段类型擦除来实现的。
java中的泛型在编译后,都会替换为原来的裸类型。例如ArrayList,和ArrayList在编译后是一样的,编译器只是会在泛型使用的位置将插入强制类型转换的指令。
裸类型是指类型泛型化的公共父类型,默认公共父类型为Object,可以通过extends和super限定类型
序列化-反序列化
概念
序列化:将对象转化为字节序列
反序列化:将字节序列转化为对象
作用
- 当内存中的对象过多时,可以将部分对象序列化后存放到硬盘中,使用时再反序列化到内存中。
- 进程远程通信时,发送方将对象序列化后通过网络传输,接收方再将数据反序列化恢复为对象。
SerialVersionUID
serialVersionUID适用于java序列化机制。简单来说,JAVA序列化的机制是通过 判断类的serialVersionUID来验证的版本一致的。在进行反序列化时,JVM会把传来的字节流中的serialVersionUID于本地相应实体类的serialVersionUID进行比较。如果相同说明是一致的,可以进行反序列化,否则会出现反序列化版本一致的异常,即是InvalidCastException。
不指定serialVersionUID,编译器也会根据类中的字段计算出一个UID,且不同编译器可能生成的UID不同。反序列化时就会出错,强烈建议显示定义UID
Transient瞬时变量
transient修饰的变量不会被序列化,序列化时虚拟机会跳过这个字段,我们反序列时对象的这个属性是默认值
Object
equals和hashcode的关系
两个方法都是Object类中定义的方法,也就是说所有类都有这两个方法。
equals是判断对象是否相等,hashcode获取的是一个hash码。
默认情况下hashcode返回的是对象的内存地址,equals方法默认实现是比较两个对象的hashcode是否相等。
一般我们在使用HashMap、HashSet等才会重写这两个方法。
复写规则:equals方法返回为true时,hashcode必须相同
hashcode相同的两个对象,equals不一定相同。equals判断相同的两个对象,hashcode必须相同。
Java关键字
static
static修饰变量时,该变量为静态变量(类变量),是全局实例共享的
static修饰方法时,该方法为静态方法,通过类名直接调用,不能被覆盖
final
final主要用来修饰类、方法、属性时
修饰类时表示类不能被继承
修饰方法时表示方法不能被重写
修饰属性时表示常量(如果是修饰的方法中定义的属性,final关键字的不变性是有编译器来保证的),如果属性是引用数据类型则表示该属性的引用不能被修改,但引用中的内容是可以被修改的。
final修饰的对象,虚拟机保证其线程访问的安全性,即保证一个对象可以被访问时,其内部的final变量一定是可见的。
换句话说final修饰的变量没有被创建时虚拟机保证其不可见
final 、finally、finalize的区别
final是作用在类、方法、属性上的,表示类不能被继承、属性不能被修改、方法不能被复写
finally是用在try-catch捕获异常的代码块中,我们一般将一定要执行的代码放到finally代码块中,一般用来释放资源。(有一些特殊情况,不会走finally代码比如在try-catch中调用system.exit或者根本没有进入try-catch中)
finalize是Object类的一个方法,因为Object是所有类的父类,所有类对象都有这个方法,这个方法只会在对象即将被垃圾回收器回收时执行,并且一个对象只会执行一次,如果这个对象通过finalize方法逃脱了垃圾回收,第二次再被回收时将不会执行这个方法。
位运算
移位运算符
右移
>>和>>>的区别
>>是带符号右移:正数右移高位补0,负数右移高位补1
>>>无符号右移:正数、负数右移高位都补0
运算符
& 与运算
两个数对应位置上都为1,才为1,否者为0
|或运算
两个数对应位置上有一个为1,则为1,否者为0
!非运算
单目运算符,将原数字1、0全部反转
^异或
两个数对应位置相同为1,不同为0
位运算快速取模
可以通过 value&(n-1),快速求出value对n的模,只针对2的n次方使用
集合框架
概述
Java集合,也叫容器,主要有两大接口:Collection,Map
Collection接口是单一元素集合,下面主要有List、Set、Queue
Map接口是存放键值对元素集合,下面主要有HashTable、HashMap、SortedMap
- List主要存放有序、可重复的单值集合
- Set集合存放无须不可重复的单值集合
- Map主要存放key-value键值对的集合(key不可重复、value可重复且一个key只能映射一个value)
Collection
Collection接口是Java单值集合的顶层接口,定义了集合中的一些基本方法,例如add,remove,size等等。Collection还继承了一个Iterable接口,表明其所有实现类都是可迭代的。Iterator就是Collection实现可迭代的关键,Collection的实现类将Iterator通过组合的方式放入类中,向外界提供一种统一的遍历访问方式。Collection下有三大子接口 List、Set、Queue
List
元素可重复的集合
Set
元素不可重复的集合
Queue
队列,先进后出的数据结构
Iterator迭代器
迭代器主要有三个方法 hasNext、next、remove。
迭代器只能单向移动,且不能重复使用。
迭代器的使用方式
while(itr.hasNext){
Object o = itr.next();
if(o==target){
itr.remove;
}
}
迭代器的使用原理
-
迭代器遍历时,类似于一根指针。初始时执行第一个元素的左边。
如图:| 1 2 3 4 5
-
当调用hasNext时会判断指针右侧是否还有元素,如果没有则返回false,反之为true
-
当调用next时,指针向右移动越过下一个元素,并且将越过的元素返回
如图:1 | 2 3 4 5 (此时就返回了1)
-
调用remove时,会将next新返回的元素给删除掉
为什么遍历删除元素时要使用迭代器,而不能使用for-each
for-each底层其实也是使用的迭代器。在使用迭代器时,迭代器内部会维护一个预期的modCount值,并且在遍历时都会先确认集合中的modCount和迭代器中维护的modCount的值是否相同,不同则直接抛出异常。而我们在使用foreach循环时,会直接调用集合的remove方法,这些方法会修改集合中的modCount的值,此时迭代器中的值没有被更新,下一次检查时自然会抛出异常。而使用迭代器遍历和删除元素时,调用的remove方法时迭代器中的。这些方法在执行后会将迭代器中的modCount更新为集合中的modCount,这样就可以安全的删除元素了。
modCount的作用就是集合中的一种安全检测机制,快速失败机制
ArrayList
参考资料:Java集合源码分析(一)ArrayList - LanceToBigData - 博客园 (cnblogs.com)
概述
-
ArrayList是可以动态增长的索引序列,是基于数组实现的可变长数组
-
以capacity属性作为封装数组的长度,扩容后capacity改变
-
ArrayList与Vector相比是线程不安全的(Vector中的方法都使用了synchronized修饰)
-
ArrayList的默认大小为10
数据结构
ArrayList底层使用数组作为存储空间
继承结构
继承AbstractList:ArrayList是通过继承AbstractList,让AbstractList来实现List接口。在AbstractList中规定一些通用方法,减少重复代码
实现List接口:既然ArrayList已经继承了AbstractList,为什么还让ArrayList再实现一次List呢,这其实是一个mistake,因为他写这代码的时候觉得这个会有用处,但是其实并没什么用,但因为没什么影响,就一直留到了现在。
实现RandomAccess接口:实现了这个接口类使用for循环遍历的比使用迭代器更快
实现Cloneable接口:实现了该接口的类,可以使用clone方法
实现Serializable接口:实现该接口的类表明可以被序列化
关键属性
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{
// 版本号
private static final long serialVersionUID = 8683452581122892189L;
// 默认初始化容量
private static final int DEFAULT_CAPACITY = 10;
// 空对象数组
private static final Object[] EMPTY_ELEMENTDATA = {};
// 默认空对象数组
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
// 元素数组
transient Object[] elementData;
// 实际元素大小,默认为0
private int size;
// 最大数组容量
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
}
构造方法
//无参构造
public ArrayList() {
//将数组实体赋值为大小为0的Object数组
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
//有参构造
public ArrayList(Collection<? extends E> c) {
//将集合中的数组元素直接赋值给ArrayList中的数组元素
//注意这里因为是直接赋值的,原集合和新生成的ArrayList其实是引用的同一个元素,所以外部发生修改时,ArrayList也会同步修改
elementData = c.toArray();
//同步新生成的ArrayList的size元素大小
if ((size = elementData.length) != 0) {
if (elementData.getClass() != Object[].class)
elementData = Arrays.copyOf(elementData, size, Object[].class);
} else {
//传入的集合大小为0,直接赋值为大小为0的数组
this.elementData = EMPTY_ELEMENTDATA;
}
}
//有参构造 initialCapacity 底层数组初始化大小(默认为10)
public ArrayList(int initialCapacity) {
if (initialCapacity > 0) { //检查设置的值是否大于0
//直接将存储容器新建为设置的大小
this.elementData = new Object[initialCapacity];
} else if (initialCapacity == 0) {
this.elementData = EMPTY_ELEMENTDATA;
} else {
throw new IllegalArgumentException("Illegal Capacity: "+
initialCapacity);
}
}
核心方法
add()方法
//E:待添加的元素,Object[]:存储容器,s:当前容器中的元素的个数
private void add(E e, Object[] elementData, int s) {
//检查当前元素个数与数组大小是否相等(是否需要扩容)
if (s == elementData.length)
//进行扩容
elementData = grow();
//将元素装入下标
elementData[s] = e;
//元素个数+1
size = s + 1;
}
grow()方法
private Object[] grow() {
return grow(size + 1);
}
//minCapacity的值是当前元素个数+1
//数组每次扩容为原来的1.5倍
private Object[] grow(int minCapacity) {
int oldCapacity = elementData.length;
if (oldCapacity > 0 || elementData != DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
int newCapacity = ArraysSupport.newLength(oldCapacity,
minCapacity - oldCapacity, /* minimum growth */
oldCapacity >> 1 /* preferred growth */);
return elementData = Arrays.copyOf(elementData, newCapacity);
} else {
return elementData = new Object[Math.max(DEFAULT_CAPACITY, minCapacity)];
}
}
总结:如果ArrayList初始化时传入了大小,则第一次向容器添加元素时不会进行初始化,如果初始化没有传入元素,则第一次添加元素时会触发初始化
remove()方法
有两种方法,一种可以根据下标删除元素,第二种是根据元素遍历容器查找相同元素的位置将其删除
删除后都需要移动数组 System.arrayCopy方法
LinkedList
LinkedList是底层由双向链表实现的单值列表。
LinkedList可以用来实现队列、栈、列表等多种数据结构。
因为底层是使用链表实现的,它的插入和删除效率比ArrayList要高,但查询速度较慢。
HashSet
Hashset底层使用HashMap,每次插入时都会向HashMap中插入一个值为null的键值对
因为HashMap中的key是唯一的,所以HashSet也保证了值唯一的特性。
LinkedHashSet
LinkedHashSet是基于LinkedHashMap实现的
TreeSet
TreeSet中的元素是排序且唯一的。基于红黑树实现的,其底层依赖TreeMap。
CopyOnWriteArrayList
参考资料:高并发编程之CopyOnWriteArrayList介绍_住手丶让我来的博客-CSDN博客_copyonwritearraylist
CopyOnWriteArrayList是ArrayList的并发版本,它解决并发的思路类似于读写锁(CopyOnWriteArrayList中的数组没有扩容机制,每次添加元素时都使用Array.copyOf来复制一个n+1大小的数组)。
它的读和读之间不上锁,甚至读和写之间也不加锁。只有写和写之间才上锁。CopyOnWrite的意思就是写时复制。
CopyOnWrite:在修改数据时不会直接对元数据进行修改,而是复制一个副本(整个数组的副本)对其进行修改,修改完成后才会将数据同步。
CopyOnWriteArrayList源码中,读操作没有进行任何的加锁,因为读操作不影响数据本身。只在写操作中对数据进行上锁,并使用写时复制。
存在的问题
由于采用了写时复制的方式,并发时可能会出现脏数据,即get获取到的数据是修改之前未同步的数据
Map
HashMap
面试题:聊一下HashMap
-
1.7和1.8的数据结构
1.7采用的是数组加链表的形式,每个节点是一个Entry节点,是它的一个内部类
1.7的插入方式是头插法,并发情况下扩容时会出现链表成环
1.8之后采用数组加链表加红黑树,采用尾插法避免了链表成环问题(但仍然存在数据覆盖问题)
并发情况下我们一般使用ConcurrentHashMap,ConcurrentHashMap也是分版本的。1.8之前底层数据结构是数组加链表,采用Segment分段锁的形式来实现。将原本HashMap中的数组分成多个Segment数组,每个Segment下面有一个对应的Entry数组,每次操作时是只需要对对应的Segment进行上锁即可,这样锁的粒度就下降了,HashTable效率之所以低就是因为它对每个操作都要求上锁,而哈希表的使用读的频率比写的频率高很多,HashTable读操作也需要上锁导致效率太低。而ConcurrentHashMap对读操作是不上锁的(ConcurrentHashMap之所以读操作不上锁并且不会读到脏数据,是因为采用Volatile关键字保证了数据的可见性和有序性)。
概述
-
JDK1.8之前 HashMap底层使用数组+链表的形式存储
即每个元素通过计算Hash值将元素放到对应位置上,当发生哈希冲突后将元素接在元素后形成链表
但这样的方式存在一个问题,链表还是需要遍历,当哈希冲突多了之后每次查需元素时需要遍历链表的时间加长。
-
JDK1.8之后HashMap底层使用==数组+链表+红黑树==的形式存储
初始时仍使用数组+链表,当某个链表上的长度超过TREEIFY_THRESHOLD的值时(默认为8),链表将转换为红黑树(转换过程是在HashMap重分配时rehashed)
-
HashMap是线程不安全的,jdk1.7之前采用头插法扩容时可能会出现链表成环,jdk1.8之后采用尾插法避免了链表成环但是还是会出现数据覆盖问题,所以多线程情况下不推荐使用HashMap。替代品有HashTable、Collections工具包下面的SynchronizedMap、ConcurrentHashMap
-
HashMap初始化时并没有真正构造table数组,而是等到put操作才会检查并初始化table数组
-
哈希表默认初始大小为16
-
允许的容量最大上限是2的30次方
-
JDK8后,哈希表中链表转换成红黑树
-
HashMap的中用于存储键值对的是一个Entry数组,每个Entry是一个链表结点
-
HashMap的总体结构
重要属性
-
DEFAULT_INITIAL_CAPACITY (初始容量)默认16
-
MAXIMUM_CAPACITY(允许的最大大小) 2的30次方
-
DEFAULT_LOAD_FACTOR(负载因子)默认0.75
-
threshold-------就是最大容量和负载因子的乘积(HashMap在resize的时候通过判断threshold,来决定是否要扩容)
-
TREEIFY_THRESHOLD(链表转换为红黑树的条件),当链表长度大于等于8时,链表重构为红黑数
-
UNTREEIFY_THRESHOLD(红黑树转为链表的条件),当红黑树长度小于6时,红黑树转化为链表
-
MIN_TREEIFY_CAPACITY (最小树形化阈值)
-
Node<K,V>
- HashMap中的静态内部类,也是HashMap中真正用来存储数据的类
final int hash; //对key值的hashcode进行hash运算后得到的值,避免重复计算 final K key; V value; Node<K,V> next; //存储指向下一个Entry的引用,单链表结构
扩容
hashmap的扩容时机
当哈希表中元素的个数超过当前容量与负载因子的乘积时,哈希表会进行重新哈希(rehashed)扩容为原来的两倍
例如初始大小为16,当表中元素=12(16*0.75)时,Hash表会进行rehashed将空间扩容为32
Resize方法
resize方法主要作用就是对hashmap扩容,其扩容时机是在未添加任何元素时进行初始化或者添加完元素后容量达到阈值
将每个键值对的hash值重新与新桶的容量计算,算出新的索引并放入
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
//获取旧的最大容量(还没有真正初始化时oldCap为0)
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//获取旧的阈值(阈值=最大容量X负载因子)
int oldThr = threshold;
int newCap, newThr = 0;
//如果旧的最大容量大于0,说明当前已经被初始化过了
if (oldCap > 0) {
//如果当前最大容量已经达到2的30次方,则不能继续扩容
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//在确保扩容之后最大容量不大于2的30次方时进行扩容
//最大容量和阈值都扩大为原来的两倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
//如果没有被初始化但阈值存在,则最大容量修改为阈值????
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
//说明当前hashmap还没有被初始化
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//重新计算阈值
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
//保存阈值
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
//新建table数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
//将旧的table数组中的键值对重新散列后放入新table数组
if (oldTab != null) {
//遍历旧数组
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
// 将旧数组当前位置置为空
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
// 计算是高位还是低位,低位原地不动,高位下标增加一倍
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
Hash值
int h;
//如果为null则计算hashcode=0
//如果不为null,则获取对象的hashcode,并与该code右移16位之后的数进行异或
//得到真实的值
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
-
如果key为null,则返回0,后续将检索下表为0的元素
-
如果key不为null:
-
将hashcode无符号右移16位得到新code并于原hashcode做异或^(异或:相同为1,不同为0)
例如对象生成的hashcode为7291c18f—(01110010100100011100000110001111)
真正被hashmap真正拿来计算槽值的code
其实是
-
原hashcode : 0111 0010 1001 0001 1100 0001 1000 1111
-
右移十六位的hashcode:0000 0000 0000 0000 0111 0010 1001 0001
-
异或后结果:1000 1101 0110 1110 0100 1100 1110 0001
-
-
最后参与运算的hashcode其实是异或后的结果
-
这样计算来代替原hashcode的原因是将高区16位2进制数的特征融入低区16位,减少hash碰撞的发生
HashMap中的hash算法总结_晴天-CSDN博客_hashmap的hash算法
为什么要对hashcode右移16位并与原hash值异或
因为在槽位计算时,其公式为(n-1)&hash
first = tab[(n - 1) & hash]
n就是当前table数组的大小,hash是计算之后的hash值
将两个值进行与运算
例如当前table默认长度为16,则运算结果可以看到,高位将会被二进制码锁屏蔽
hash:1000 1101 0110 1110 0100 1100 1110 0001
table大小:0000 0000 0000 0000 0000 1111
此时运算结果其实和高区的二进制没有关系,高区特征不能体现。如果不在计算hashcode的时候将高区特征融入低区,发生哈希碰撞的几率将提升
使用hash计算槽位时为什么不用求余,而使用&与运算
与运算效率比直接求余高
为什么HashMap的容量是2的n次方幂
因为采用的计算方式是(n-1)&hash,这种运算如果n的值是2的n次幂时,等价于取余运算,且与运算的方式计算速度更快
HashMap中判断key是否相同的条件
- key值结果hash函数散列后得到的hash值相同
- key引用的对象相同-----即 key1==key2
- key1.equals(key2)为true
上述的1必须满足,2.3任意满足一条即判断key值相同
重要方法
Put方法
put操作的返回值,如果put进去的key值hashmap中已经 存在了,则第二次put时会将被换出的value返回出来。
-
第一步:检查table数组是否为空
- 如果为空则进行hashmap初始化,构造HashMap时如果没有传入初始大小和负载因子,则采用默认值(16,0.75)
-
计算hash值(异或)
-
第二步:通过(n-1)&hash找到键值对要存放的桶
- 如果当前桶还没有被使用,则初始化一个entry将键值对放进去
- 如果当前桶已经被使用了
- 判断桶的首位bin(键值对)的key值是否和待放入的key值相同.如果key值判断相同则直接将当前位置置为新的value。
- 如果当前桶已经存在对象了,判断桶中存放的是否是红黑树,如果是红黑树则调用红黑树的存值方法将键值对插入到红黑树中。
- 如果当前桶对象是链表,则遍历链表
- 在到达尾结点前如果发现有与待插入key相同的key,则将当前位置的值置为新的value
- 到达尾结点后仍没有发现与待插入key值相同的key,则在尾结点后新增一个entry。并判断当前桶中的链表是否需要修改为红黑树。如果需要则进行修改。
-
第三步:检查是通过哪种方式插入的
- 如果是通过替换结点来插入键值对(即插入时hashmap中已经存在了相同的key),这种情况则直接返回被挤出的value
- 如果是新增结点来插入的键值对(即插入时hashmap中没有相同的key),这时需要判断当前hashmap的size是否达到了threshold(最大容量*装载因子),如果达到了,则进行扩容。并返回null
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//判断table数组是否还没有初始化,没有初始化则执行resize方法初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//通过(n-1)&hash找到要存放的位置
//当前桶还没有被使用,则初始化桶
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
//当前位置的桶已经被使用
else {
Node<K,V> e; K k;
//桶的首位entry与即将放入的键值对key重合(两个键值对的key值的hash值相等且满足两个key引用同一个对象或者key不为null的前提下两者的equals方法返回true)
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//如果当前entry是红黑树的结构,则调用红黑树的插入方法
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//当前位置的entry以链表形式存在
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;
}
//在链表中发现有与待插入键值对key值相同的结点
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
//进行替换(替换value的步骤延后到48行)
p = e;
}
}
//判断是否替换出了旧值
if (e != null) { // existing mapping for key
V oldValue = e.value;
//将原值修改为新插入的value
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
//返回被挤出的value
return oldValue;
}
}
//插入的元素是新的key
++modCount;
//判断是否需要扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
//因为是新插入的,所以没有替换出任何旧值,返回null
return null;
}
Get方法
根据key获取map中的value
- 第一步:根据放入的key值计算出对应的hash值
- 第二步:判断当前table是否为空,hash值对应位置上的桶是否为空,如果为空则直接返回null
- 第三步:判断桶中第一个节点是否与传入的key相同判断条件
- 第四步:判断后续节点是否为红黑树,如果为红黑树则调用红黑树的getNode方法获取
- 第五步:后续节点如果存在,则遍历节点并判断是否存在key相同的节点,存在则返回
- 第六步:没有找到key值相同的节点,返回null
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//判断当前map是否初始化以及传入的hash值对应的桶是否为null
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))))
//找到相同key值,返回对应节点
return e;
} while ((e = e.next) != null);
}
}
//没有找到对应的键值对,返回null
return null;
}
HashTable
与HashMap底层几乎相同,HashTable底层也是使用的数组+链表来实现的。
不同点是HashTable中的key和value都不能为null。并且HashTable是线程安全的,因为它的方法都使用synchronized来修饰的。
Tips:HashTable初始容量是11,与HashMap的16不同。并且HashTable中是直接求余的,没有HashMap中的操作融合高位特征重写求hash值的步骤。
并发也不推荐使用HashTable,推荐使用ConcurentHashMap
HashMap和HashTable的区别
HashMap是线程不安全的,HashTable是线程安全的。HashTable中的方法都使用synchronized来进行线程同步
HashMap的效率更高,HashTable效率较低。HashMap中允许null作为key值,HashTable中不允许
ConcurentHashMap
参考资料:简单总结ConcurrentHashMap - 简书 (jianshu.com)
ConcurentHashMap在1.7时采用数组+链表+Segment+分段锁来实现,1.8开始采用数组+链表+红黑树+CAS+synchronized+Volatile实现。
1.7版本
ConcurentHashMap中包含一个Segment数组,每个Segment中维护一个HashMap,ConcurentHashMap实现并发的原理就是将完整的HashMap分段之后,每次线程需要对HashMap上锁时,只需要获取对应的Segment的锁即可,不需要对整个Map上锁,ConcurentHashMap默认的Semant数组大小是16,也就是说ConcurentHashMap默认可以支持16个线程并发写
1.8版本
ConcurentHashMap抛弃segment分段锁,采用CAS+synchronized来实现,相当于在HashMap的操作中使用CAS和synchronized来实现并发写。原因是synchronized在1.6之后做了优化,不再像以前那么重量级了。synchronized锁优化
为什么ConcurrentHashMap和HashTable不支持key-value为null
ConcurrentHashmap和Hashtable都是支持并发的,这样会有一个问题,当你通过get(k)获取对应的value时,如果获取到的是null时,你无法判断,它是put(k,v)的时候value为null,还是这个key从来没有做过映射。HashMap是非并发的,可以通过contains(key)来做这个判断。而支持并发的Map在调用m.contains(key)和m.get(key),m可能已经不同了
LinkedHashMap
参考资料: Map 综述(二):彻头彻尾理解 LinkedHashMap_Rico’s Blogs-CSDN博客_linkedhashmap
LinkedHashMap是HashMap的子类,整体和HashMap类似。
HashMap有一个缺点是HashMap中的元素是无序的,而LinkedHashMap的作用就是解决HashMap中元素无序的问题。这里的无序指的插入元素的顺序。
但它内部多维护了一个双向链表来记录节点插入的顺序。每个Entry中多了before、after两个指针用来维护双向链表
LinkedHashMap 的存取过程基本与HashMap基本类似,只是在细节实现上稍有不同,这是由LinkedHashMap本身的特性所决定的,因为它要额外维护一个双向链表用于保持迭代顺序
用于LRU算法
LRU算法:最近最少使用,一种常用的页面置换算法,即每次淘汰都淘汰访问最少的元素
LinkedHashMap中有个属性为accessOrder
- accessOrder为true标识双线链表的顺序按访问顺序来排列(LinkedHashMap将最近使用的Entry放到双向循环链表的尾部,我们使用LRU算法时每次删除头节点就可以了)
- accessOrder为false时按插入顺序来排列。accessOrder默认为false即默认按插入顺序排列。
当使用LRU算法时将accessOrder设为true,并且复写removeEldestEntry(我们在这个方法中设定删除元素的条件),这样我们每次插入元素就会根据条件来判断是否删除最少使用的元素
哈希冲突
hash函数(散列函数),将任意长度的输入,通过散列算法后变成固定长度的输出。通常这种转换散列值的空间远小于输入空间,所以可能会出现多个输入结果散列后得到相同的值。这时候他们在数组中的存储位置就发生了冲突。即hash值相同造成的冲突就是哈希冲突。
开放定址法
一种解决哈希冲突的方法
发生哈希冲突时使用探测的方式查找另一个适合存储的位置
例如发生冲突时,向原来位置的左或者右移动一段距离,直到找到合适位置
线性探测再散列:di=1,2,3,4,5…
二次探测再散列:di=12,-12,22,-22…
伪随机序列再散列:di=伪随机序列
ThreadLocal就使用了开放定址法
链式地址法
数组每个位置存放的是一个链表,当发生冲突时将当前元素放到该位置上链表末尾
HashMap就是使用的链式地址法解决Hash冲突
再散列法
当发生哈希冲突时,对计算后的hashcode再次计算,直到能放入正确位置即可
快速失败机制
【原创】快速失败机制&失败安全机制 - why技术 - 博客园 (cnblogs.com)
快速失败是指系统中出现错误时,立即终止执行。
Java集合如何实现快速失败机制
集合中设置由一个modCount指,多个线程对集合进行操作时,集合中内容被修改时会使modCount值加一,如果某个线程在遍历该集合时,只要发现modCount的值被修改了,就会抛出异常终止遍历。
安全失败机制
安全失败是指,在遍历时会复制一份副本出来进行遍历。也就是说迭代器在遍历时其实遍历的是复制出来的副本,遍历期间集合中的修改都不会影响到副本中的数据。
JDK1.8新特性
接口默认方法
JDK8开始接口中可以定义方法体,使用static定义的是全局可访问的,使用defualt定义的方法是只有实现类可以访问的。
其主要作用是配合jdk1.8的stream流特性,因为Collection接口下的所有类都可以使用stream,如果放到实现类中需要改很多代码。
Lambda表达式
将方法体参数化,可以将方法作为参数