1 、java基础
1、面向对象特性
-
封装:将数据和操作数据的方法封装到一起,隐藏内部细节,只保留一些对外接口,用于操作数据。用户无需知道操作数据的细节,通过对象提供的接口来访问对象。减少耦合,提高内聚。
-
继承:子类能获取直接父类的父类中的结构,不能直接获取父类中private权限的属性或方法,子类继承超类的一切,只不过看不到private修饰的内容,但可以通过调用父类本身的方法来获取和利用,有没有继承和能不能直接访问是两码事。继承应该遵循里氏替换原则,子类对象必须能够替换掉所有父类对象。
-
多态:多态分为编译时多态和运行时多态:
- 编译时多态主要指方法的重载
- 运行时多态指程序中定义的对象引用所指向的具体类型在运行期间才确定
运行时多态有三个条件:
- 继承
- 覆盖(重写)
- 向上转型
2、a=a+b和a+=b区别
a+=b会有隐式类型转化,会将a转化为合适的结果类型。
byte a = 127;
byte b = 127;
b = a + b; // error : cannot convert from int to byte
b += a; // ok
(因为 a+b 操作会将 a、b 提升为 int 类型,所以将 int 类型赋值给 byte 就会编译出错)
3、接口与抽象类的区别
回答:接口没有构造方法,只能定义常量,默认被public static final修饰,1.8之后有了default方法(用来改变接口方法,不用更改实现类),static是属于接口的方法,不能被重写,不能用实现类实例调用,只能使用接口来调用。default方法能够重写,继承。
A:成员的区别
抽象类:
构造方法:有构造方法,用于子类实例化使用。
成员变量:可以是变量,也可以是常量。
成员方法:可以是抽象的,也可以是非抽象的。接口:
构造方法:没有构造方法
成员变量:只能是常量。默认修饰符:public static final
成员方法:jdk1.7只能是抽象的。默认修饰符:public abstract (推荐:阿里手册不写默认修饰符)
jdk1.8可以写以default和static开头的具体方法B:类和接口的关系区别
类与类:
继承关系,只能单继承。可以多层继承。类与接口:
实现关系,可以单实现,也可以多实现。
类还可以在继承一个类的同时实现多个接口。接口与接口:
继承关系,可以单继承,也可以多继承。C:体现的理念不同
抽象类里面定义的都是一个继承体系中的共性内容。
接口是功能(动作)的集合,是一个体系额外的功能,是暴露出来的规则。
4、静态方法与非静态方法区别
1、生命周期不同
静态方法在类加载的时候就已经加入到内存中,而非静态方法是需要创建对象之后才能调用。
静态方法的生命周期跟相应的类一样长,静态方法和静态变量会随着类的定义而被分配和装载入内存中。一直到线程结束,静态属性和方法才会被销毁。(也就是静态方法属于类)
非静态方法的生命周期和类的实例化对象一样长,只有当类实例化了一个对象,非静态方法才会被创建,而当这个对象被销毁时,非静态方法也马上被销毁。(也就是非静态方法属于对象)
2、调用方法不同
3、静态方法只能调用静态变量,静者横静
4、静态方法不能够重写。
5、枚举与常量
在我们定义常量的时候,由于常量是有限个的,且不变的,这时我们就可以使用枚举类来描述常量。枚举中的实例的类型还是枚举的类名。
枚举语法
public enum 枚举类名{
实例对象1(),实例对象2();
成员变量;
private 枚举类名(参数){
构造方法;
}
成员方法;
}
枚举中的枚举就是这个类的实例变量,只不过是有限个的,所以直接写死了,且构造方法是私有的,不能实例化,要用枚举类型直接:枚举类名.实例对象1;
实例变量是可以调用枚举类的构造方法来实例化的,但是外部类中是不能的,因为已经实例化过了。既然是实例,就能调用成员方法。
在设计常量的时候,如果常量是有限的几个,可以使用枚举来写,这样能够减少出错。
使用场景:
- 定义常量:
public enum Fruit {
APPLE("苹果"),BANANA,PEER;
private String name;
Fruit() {
}
Fruit(String name) {
this.name = name;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
class te{
public static void main(String[] args) {
String fruit=Fruit.APPLE.getName();
System.out.println(fruit);
}
}
6、java中的自动转型
基本类型之间经常会需要进行数值类型转换,数值范围较小的数据类型可以自动转换为数值范围大的基本类型。
如图所示:
6个实心箭头:表示两个数值类型无信息丢失的转换。
3个虚线箭头:表示两个数值类型的转换可能会存在精度损失。
因此,将两个不同数值类型进行算数操作时,会自动将两个类型转换为同一个类型,再进行计算。
自动转型:byte < short < char < int < long < float < double
当两个数值类型有一个是 double 类型,另一个数值也会转为 double 类型。
否则,有一个数值类型是 float 类型,另一个转换为 float 类型。
否则,有一个数值类型是 long 类型,另一个转换为 long 类型。
否则,两个数值都会被转换为 int 类型。
向上转型。
7、 3*0.1 == 0.3 将会返回什么? true 还是 false?
false,因为有些浮点数不能完全精确的表示出来。
class sub extends father{
public static void main(String[] args) {
double v = 3 * 0.1;
System.out.println(v);
System.out.println(v==0.3);
}
}
8、能在 Switch 中使用 String 吗?
从 Java 7 开始,我们可以在 switch case 中使用字符串,但这仅仅是一个语法糖。内部实现在 switch 中使用字符串的 hash code
9、对equals()和hashCode()的理解?
回答:如果两个对象根据 equals(Object) 方法相等,则对两个对象中的每一个调用 hashCode 方法必须产生相同的整数结果。既然我们重写了equal方法并且返回true,那么hashcode就一定要相等,如果不重写hashcode就会和hashcode的定义产生矛盾。原来的hashcode是根据内存地址计算出来的。
- 为什么在重写 equals 方法的时候需要重写 hashCode 方法?
因为有强制的规范指定需要同时重写 hashcode 与 equals 是方法,许多容器类,如 HashMap、HashSet 都依赖于 hashcode 与 equals 的规定。
下面是hashcode的规定。所以一定要从写hashCode。
- 有没有可能两个不相等的对象有相同的 hashcode?
有可能,两个不相等的对象可能会有相同的 hashcode 值,这就是为什么在 hashmap 中会有冲突。相等 hashcode 值的规定只是说如果两个对象相等,必须有相同的hashcode 值,但是没有关于不相等对象的任何规定。
- 两个相同的对象会有不同的 hash code 吗?
不能,根据 hash code 的规定,这是不可能的
10、final、finalize 和 finally 的不同之处?
- final 是一个修饰符,可以修饰变量、方法和类。如果 final 修饰变量,意味着该变量的值在初始化后不能被改变。
- Java 技术允许使用 finalize() 方法在垃圾收集器将对象从内存中清除出去之前做必要的清理工作。这个方法是由垃圾收集器在确定这个对象没有被引用时对这个对象调用的,但是什么时候调用 finalize 没有保证。
- finally 是一个关键字,与 try 和 catch 一起用于异常的处理。finally 块一定会被执行,无论在 try 块中是否有发生异常
11、String、StringBuffer与StringBuilder的区别?
1、可变性方面:String是不可变的,对string类型进行拼接会new一个新的对象出来。stringbuffer和stringbuilder不会产生新对象,适合频繁变更的字符串。
2、线程安全方面:String是使用final修饰的,线程安全。buffer的方法是使用synchronized修饰的,线程安全。builder是线程不安全的,但是线程效率高。
12、this和super区别
this指向当前对象,super是关键字。this和super不能同时出现在一个构造器中。
13、位运算符
java中有三种移位运算符
<<
:左移运算符,x << 1
,相当于x乘以2(不溢出的情况下),低位补0>>
:带符号右移,x >> 1
,相当于x除以2,正数高位补0,负数高位补1>>>
:无符号右移,忽略符号位,空位都以0补齐
14、wait和sleep区别
最主要的不同点在于wait是挂起,会释放资源,而sleep是睡眠,不会释放锁。
其他一些不太重要的点:wait来自object,sleep来自Thread
wait、notify、notifyall需要放在同步代码块中,sleep不需要
wait不需要捕获异常。sleep需要捕获异常
15、负数取余数
http://ceeji.net/blog/mod-in-real/
我们由此可以总结出下面两个结论:
- 对于任何同号的两个整数,其取余结果没有争议,所有语言的运算原则都是使商尽可能小。
- 对于异号的两个整数,C++/Java语言的原则是使商尽可能大,很多新型语言和网页计算器的原则是使商尽可能小。
16、继承
子类能继承父类的非私有成员方法和非私有成员变量,对于静态成员也是可以继承的,对于final能继承,不能重写
关于方法和类上注解的继承关系
https://blog.csdn.net/imonkeyi/article/details/121625205
17、socket
客户端:new Socket(ip地址,服务器端口号)
服务端:new ServerSocket(监听端口号)
- Server端通过new ServerSocket()创建ServerSocket对象,ServerSocket对象的accept()方法产生阻塞,阻塞直到捕捉到一个来自Client端的请求。当Server端捕捉到一个来自Client端的请求时,会创建一个Socket对象,使用此Socket对象与Client端进行通信。
18、Integer的valueof和parseInt的区别
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-aZ4tTf2L-1668351878747)(1 java基础/image-20220901101305444.png)]
2 、泛型
1、泛型方法
这里使用来标明这是一个泛型方法
在参数类型上为Class 表示这个参数类型是Class,同时指定T的类型是什么,在使用该方法的时候要传入具体类型来表示T。这里的c表示的Class类的变量。
方法参数是Class。使用对象.getClass、类名.Class、Class.forName(“全类名”)可获得具体的Class对象。
2、上界与下界
都是指定泛型的范围的。
上界:表示传入的泛型T只能是Number或者Number的子类
class Info<T extends Number>{ // 此处泛型只能是数字类型
private T var ; // 定义泛型变量
public void setVar(T var){
this.var = var ;
}
public T getVar(){
return this.var ;
}
public String toString(){ // 直接打印
return this.var.toString() ;
}
}
class demo1{
public static void main(String args[]){
Info<Integer> i1 = new Info<Integer>() ; // 声明Integer的泛型对象
}
}
下界<? super String>:表示参数只能接收类型为String或者String的父类。
好像只能定义在方法上,不能使用在类上限定类泛型的上界。
class Info<T>{
private T var ; // 定义泛型变量
public void setVar(T var){
this.var = var ;
}
public T getVar(){
return this.var ;
}
public String toString(){ // 直接打印
return this.var.toString() ;
}
}
class GenericsDemo21{
public static void main(String args[]){
Info<String> i1 = new Info<String>() ; // 声明String的泛型对象
Info<Object> i2 = new Info<Object>() ; // 声明Object的泛型对象
i1.setVar("hello") ;
i2.setVar(new Object()) ;
fun(i1) ;
fun(i2) ;
}
public static void fun(Info<? super String> temp){ // 只能接收String或Object类型的泛型,String类的父类只有Object类
System.out.print(temp + ", ") ;
}
}
3、注解
1、注解的作用
用于对代码进行声明。
生成文档,通过代码里标识的元数据生成javadoc文档。@Api
编译检查,通过代码里标识的元数据让编译器在编译期间进行检查验证。@Overwrite
编译时动态处理,编译时通过代码里标识的元数据动态处理,例如动态生成代码。lombok中的@Data
运行时动态处理,运行时通过代码里标识的元数据动态处理,例如使用反射注入实例。spring中的@Autowried
2、注解的常见分类
Java自带的标准注解,包括@Override
、@Deprecated
和@SuppressWarnings
,分别用于标明重写某个方法、标明某个类或方法过时、标明要忽略的警告,用这些注解标明后编译器就会进行检查。
元注解,元注解是用于定义注解的注解,包括@Retention
、@Target
、@Inherited
、@Documented
@Retention
用于标明注解被保留的阶段@Target
用于标明注解使用的范围@Inherited
用于标明注解可继承@Documented
用于标明是否生成javadoc文档
自定义注解,可以根据自己的需求定义注解,并可用元注解对自定义注解进行注解
4、异常
1、异常的结构层次
Throwable 是 Java 语言中所有错误与异常的超类。
- Error 类及其子类:程序中无法处理的错误,表示运行应用程序中出现了严重的错误。
- Exception 程序本身可以捕获并且可以处理的异常。Exception 这种异常又分为两类:运行时异常和编译时异常
- 运行时异常:RuntimeException,编译时不检查,运行时程序可以选择处理或者不处理,错误原因为:逻辑错误。
- 非运行时异常:编译时必须处理的异常,例如:IOException,就是我们写代码时必须抛出或者处理的异常。
5、反射
https://blog.csdn.net/qq_44715943/article/details/120587716
1、什么是反射?
java的反射是在运行状态中,可以获取Class类对象及其内部信息(方法、属性、构造函数等Class对象.getMethod)以及反向控制实例对象的能力(就是方法对象.invoke(对象名)来调用。
2、获取Class对象的三种方法的区别
类加载过程分:加载阶段、连接阶段和初始化阶段
(1)类名.class:JVM将使用类装载器,将类装入内存(前提是:类还没有装入内存),不做类的初始化工作,返回Class的对象。
(2)Class.forName(“类名字符串”):装入类,并做类的静态初始化,返回Class的对象。
(3)实例对象.getClass():对类进行静态初始化、非静态初始化;返回引用运行时真正所指的对象(子对象的引用会赋给父对象的引用变量中)所属的类的Class的对象。
详细:https://blog.csdn.net/weixin_48720080/article/details/123089583
6、SPI机制
1、SPI机制是什么?
有点像注册中心?META-INF/services相当于注册表,ServiceLoader相当于nacos
Service Provider Interface 服务提供者接口
这是jdk内部提供的一种服务提供发现机制,可以用来启动框架拓展和替换组件。就像java.sql.Driver接口,每个服务提供者可以实现这个接口来实现连接数据库。而java的SPI机制可以为某个结构寻找服务发现。
目的是为了:将装配的控制权移到程序外,解耦合。
实现机制:如果这个jar包实现了一个接口,就会在jar包中的Meta-INF/service/下创建一个以这个服务接口命名的文件,这个文件中有接口的具体实现类类名,可以根据这个类名进行实例化,进而使用服务。
jdk发现服务的工具类是:java.util.ServiceLoader
7、集合
1、树
https://www.zhihu.com/question/30317295/answer/1246106121 红黑树
https://mp.weixin.qq.com/s/POX8QV9JFrRcAi-q-sJvOA 平衡二叉树B数
右旋——》儿子想当爹,所以爹变爷。其实就是将左子树中最大的当成原根节点的左孩子(一定是),左子树第一个变成根节点。
左旋——》爷变爹,爹变儿。这个就是将右子树的左节点变成左子树的右孩子。目的就是保持原来的有序性,同时空出一个节点来变成根节点。
LR其实就是通过一次右旋来变成LL
RL通过一次左旋变成RR。
红黑树特点:
1、每一个结点都有一个颜色,要么为红色,要么为黑色;
2、树的根结点为黑色;
3、树中不存在两个相邻的红色结点(即红色结点的父结点和孩子结点均不能是红色);
4、从任意一个结点(包括根结点)到其任何后代 NULL 结点(默认是黑色的)的每条路径都具有相同数量的黑色结点。与平衡树不同的是,红黑树在插入、删除等操作,不会像平衡树那样,频繁着破坏红黑树的规则,所以不需要频繁着调整,这也是我们为什么大多数情况下使用红黑树的原因。
2、集合类
Set
- TreeSet 基于红黑树实现,支持有序性操作,例如根据一个范围查找元素的操作。但是查找效率不如 HashSet,HashSet 查找的时间复杂度为 O(1),TreeSet 则为 O(logN)。
- HashSet 基于哈希表实现,支持快速查找,但不支持有序性操作。并且失去了元素的插入顺序信息,也就是说使用 Iterator 遍历 HashSet 得到的结果是不确定的。
- LinkedHashSet 具有 HashSet 的查找效率,且内部使用双向链表维护元素的插入顺序。
List
- ArrayList 基于动态数组实现,支持随机访问。
- Vector 和 ArrayList 类似,但它是线程安全的。
- LinkedList 基于双向链表实现,只能顺序访问,但是可以快速地在链表中间插入和删除元素。不仅如此,LinkedList 还可以用作栈、队列和双向队列。
Queue
- LinkedList 可以用它来实现双向队列。
- PriorityQueue 基于堆结构实现,可以用它来实现优先队列。
1、ArrayList和LinkedList区别
不相同点
- 数据结构方面,ArrayList是一个Object数组,默认初始长度为10,而LinkedList的底层数据结构为双向链表
- 在添加数据方面,因为ArrayList是数组,对数据的添加删除要比链表的LinkedList开销要大
- 在数据查询方面:ArrayList的数组能够更快的查找。因为数组在内存中的地址是连续的。而LinkedList需要通过指针去访问内存地址,效率较低。
相同点
- 两者的add的方法默认都是尾插法。两者都是线程不安全的。
适用场景分析
当需要对数据进行对随机访问的时候,选用 ArrayList。
当需要对数据进行多次增加删除修改时,采用 LinkedList。
如果容量固定,并且只会添加到尾部,不会引起扩容,优先采用 ArrayList。
当然,绝大数业务的场景下,使用 ArrayList 就够了,但需要注意避免 ArrayList 的扩容,以及非顺
序的插入。
2、ArrayList的扩容机制
https://blog.csdn.net/Super_WBW/article/details/124805421
使用ArrayList()创建ArrayList对象时,不会定义底层数组的长度,当第一次调用add(E e) 方法时,初始化定义底层数组的长度为10,之后调用add(E e)时,如果需要扩容,则调用grow(int minCapacity) 进行扩容,长度为原来的1.5倍。
扩展:ArrayList还有有参构造器:ArrayList(int initialCapacity)和ArrayList(Collection<? extends E> c),前则创建可以指定初始容量的集合,后者创建一个包含指定collection元素的集合,两者的底层数组初始化和扩容机制与上述ArrayList()一样,且添加方法add(int index, E e)和add(E e)的扩容机制一样。
3、fast-fail是什么
https://blog.csdn.net/Mypromise_TFS/article/details/70142997
在使用迭代器来遍历集合时,如果有另一个线程来修改了集合,这时就会出现
这时只要使用迭代器或者foreche来遍历才会出现的问题,使用普通的for循环不会出现这样的错误。代码方面原因为:每次使用迭代器去访问集合元素时,都会去检查集合的modCount,如果和exceptModCount一致才会去访问,否则报错。
modCount有点像版本号那样,用来记录是否被更改。
解决方法:
- 使用CopyOnWriteArrayList来存储数据,这个会复制一份副本来读,而不是在原来的集合上去操作。CopyOnWriteArrayList是一个线程安全,读操作时无锁的ArrayList。
- 使用 集合工具类Collections.unmodifiableMap(map); 来保证该集合不可被更改。
- 使用concurrent包下的相关类来替代。
4、for与foreche遍历区别
public class test1 {
public static void main(String[] args) {
ArrayList<Integer> integers = new ArrayList<>();
integers.add(1);
integers.add(2);
for (Integer integer : integers) {
//此处有这句话即使遍历完了也不会继续往下执行,并且会报并发修改异常。如果没有则不会删除最后一个元素
if (integer.equals(2)){
integers.remove(integer);
}
}
for (Integer integer : integers) {
System.out.println(integer);
}
}
}
出现原因同样是是检查moddification标志位出现变化。
foreach循环也称增强for循环,jdk1.5以后才有的,从名称我们就可以看到他是对for循环的一种增强,它是基于指针直接移动的,不能进行增删操作
5、CopyOnWriteArrayList
回答:读的时候不加锁,在写的时候复制一个数组出来,之后修改完成之后将引用指向复制集合。
- CopyOnWriteArrayList(写数组的拷贝)是ArrayList的一个线程安全的变体,CopyOnWriteArrayList和CopyOnWriteSet都是线程安全的集合,其中所有可变操作(add、set等等)都是通过对底层数组进行一次新的复制来实现的。
- 它绝对不会抛出ConcurrentModificationException的异常。因为该列表(CopyOnWriteArrayList)在遍历时将不会被做任何的修改。
- CopyOnWriteArrayList适合用在“读多,写少”的“并发”应用中,换句话说,它适合使用在读操作远远大于写操作的场景里,比如缓存。它不存在“扩容”的概念,每次写操作(add or remove)都要copy一个副本,在副本的基础上修改后改变array引用,所以称为“CopyOnWrite”,因此在写操作是加锁,并且对整个list的copy操作时相当耗时的,过多的写操作不推荐使用该存储结构。
3、Map
1、总览
TreeMap
基于红黑树实现。
HashMap
1.7基于哈希表实现,1.8基于数组+链表+红黑树。
HashTable
和 HashMap 类似,但它是线程安全的,这意味着同一时刻多个线程可以同时写入 HashTable 并且不会导致数据不一致。它是遗留类,不应该去使用它。现在可以使用 ConcurrentHashMap 来支持线程安全,并且 ConcurrentHashMap 的效率会更高(1.7 ConcurrentHashMap 引入了分段锁, 1.8 引入了红黑树)。
LinkedHashMap
使用双向链表来维护元素的顺序,顺序为插入顺序或者最近最少使用(LRU)顺序。
2、HashMap的身份证
- HashMap是以键值对存储数据的集合容器。
- HashMap是非线性安全的。
- HashMap的底层数据结构:JDK7是数组加链表,JDK8是数组加链表加红黑树。在链表长度大于8的时候,链表进化成红黑树。树转为链表是在树节点小于6的时候。
- HashMap初始化数组大小为16,当key的数量超过容量*负载因子(默认为0.75)时,就会发生扩容。扩容后的大小为原来的两倍。https://www.cnblogs.com/ysocean/p/9054804.html
- 1.7时扩容后位置需要重写计算hashcode,新版不用,直接计算高位,判断是否为1,1表示原来位置加上原数组长度。0表示不用动。
3、HashMap初始化容量非得是2的幂次方,2的倍数不行么,奇数不行么?
回答:因为HashMap计算位置的算法是取余操作,在十进制中是直接取余数的,在计算机中需要让取余操作相同的前提就是:数组的长度必须是2的n次方,不然会出现一些永远也用不不到的位置。并且&运算速度比%运算速度更快。
取余(%)操作中如果除数是 2 的幂次,则等价于与其除数减一的与(&)操作(也就是说hash % length == hash &(length - 1) 的前提是 length 是 2 的 n 次方)。并且,采用二进制位操作 & ,相对于 % 能够提高运算效率。
https://www.cnblogs.com/ysocean/p/9054804.html
2的幂次方:hashmap在确定元素落在数组的位置的时候,计算方法是(n - 1) & hash,n为数组长度也就是初始容量 。hashmap结构是数组,每个数组里面的结构是node(链表或红黑树),正常情况下,如果你想放数据到不同的位置,肯定会想到取余数确定放在那个数组里,计算公式:hash % n,这个是十进制计算。在计算机中, (n - 1) & hash,当n为2次幂时,会满足一个公式:(n - 1) & hash = hash % n,计算更加高效。
奇数不行:在计算hash的时候,确定落在数组的位置的时候,计算方法是(n - 1) & hash,奇数n-1为偶数,偶数2进制的结尾都是0,经过hash值&运算后末尾都是0,那么0001,0011,0101,1001,1011,0111,1101这几个位置永远都不能存放元素了,空间浪费相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这样就会造成空间的浪费而且会增加hash冲突。
4、HashMap扩容
注意一下:在同一个链表中他们的哈希码基本不同,相同的是他的低位,因为他们是通过和数组长度-1的二进制相与得到的,而数组-1的二进制一般只有低位的为1,抛弃了高位,所以说并不是在相同的链表中哈希吗就相同,相同的只是低位。
https://www.nowcoder.com/questionTerminal/7d64658af3224d2a9d34fcd4ea0f5552
标准回答
向HashMap中添加数据时,有三个条件会触发它的扩容行为:
- 如果数组为空,则进行首次扩容。
- 将元素接入链表后,如果链表长度达到8,并且数组长度小于64,则扩容。
- 添加后,如果数组中元素超过阈值,即比例超出限制(默认为0.75),则扩容。
每次扩容时都是将容量翻倍,即创建一个2倍大的新数组,然后再将旧数组中的数组迁移到新数组里。由于HashMap中数组的容量为2^N,所以可以用位移运算计算新容量,效率很高。
加分回答
在数据迁移时,为了兼顾性能,不会重新计算一遍每个key的哈希值,而是根据位移运算后(左移翻倍)多出来的最高位来决定,如果高位为0则元素位置不变,如果高位为1则元素的位置是在原位置基础上加上旧的容量。
举个例子,来演示一下数据迁移时,元素在新数组里位置的判定。假设旧数组长度为16(00010000),则翻倍后新数组的长度为32(00100000)。我们从十进制数字上看不出什么,但是从二进制数字却可以看出二者的明显差别,即翻倍后新值的高位多了1。
这上面好像错了,应该是拿旧容量(不用减一)来与上旧哈希值,这样就是能算出高位的差异来了。比如原来大小为16,扩容为两倍即32之后,使用16&旧hash,看结果为1还是为0就能判断是否需要移动了。
如果我们把这两个值作为掩码,对key的哈希值(原来的hash值已经存起来了)做按位与运算,就能求出最高位那一位的差异。如果这一位是0则该元素的位置不变,否则该元素的位置就在原位置基础上加16。这个方式很巧妙,它省略了重新计算哈希值的时间,同时新增出来的一位是0或1是随机的,这样就把产生冲突的节点均匀的分散到新的槽里了。
就像计网里面的,通过求出高位(网络号)计算出该节点是不是大于原数组的长度,如果是1表示大于16,说明需要将他加上十六在加上主机号就是在新数组里面的位置了,这样就能将扩容出来的位置填满,同时减少了计算。这样将同一个链表中的数区分开来,并且将他们放在了高位上,更加均匀。
https://blog.csdn.net/royal_lr/article/details/106454547
上面那段话才是重点,下面的是计算位置的散列算法。
有些为1有些为0,1的直接加载新的上面,重写变成链。
上面的红字还是有一点错误,我们只需要计算多出来的一位就行了,低位已经计算完了,只需要将原来的哈希码&上原来数组长度的二进制就能获得到高位了。(左移哈希码也行)
1.7的扩容死循环问题
https://blog.csdn.net/qq_46728644/article/details/124805021
由于1.7中扩容时使用的是头插法,当有两个线程同时要扩容同一个链表时,t2一个线程因为时间片到(已经创建了一个新数组),另一个t1则直接执行扩容,t1扩容完成之后,t2的指针还是没有变,经过三次循环之后,会将第二个元素的next指向第一个元素,这时就会形成循环链表,且之后两个。
解决方法:1.8之后变成了尾插法,不会出现循环链表了。
由于1.7中扩容时使用的是头插法,当有两个线程同时要扩容同一个链表时,t2一个线程因为时间片到(已经创建了一个新数组),另一个t1则直接执行扩容,t1扩容完成之后,t2的指针还是没有变,经过三次循环之后,会将第二个元素的next指向第一个元素,这时就会形成循环链表,且之后两个。
1、线程一先完成扩容
2、线程二获取时间片开始扩容,移动t2.e所指向的元素1到新数组中,因为52.2和t2.e.next前面已经初始化过了,所以指针执行没有变
3、修改e和e.next的指向,e=e.next;e.next=e.next.next;
因为t1修改了原来的指针方向,所以e=2,e.next=1
4、使用头插法移动e指向的元素
5、修改指针指向,和第三步类似
此时已经出现了问题,next指向了null,e指向了1
6、再次进行一次移动e指向的元素
二、jdk1.8解决方法
在1.8中,将头插法变成尾插法之后问题解决
5、HashMap和HashTable的区别
1、HashTable实现Map接口同时也继承了Dictionary类
2、HashTable是线程安全的,这样导致他的效率并不高,但是比较安全。
3、HashTable的Key和value都不能为null,而HashMap的Key可以为null,但是只能有一个,value可以有多个null。
4、HashTable的key是直接使用hash求余数的值的(即使用%),而HashMap是使用hash&(数组长度-1)来求得节点在数组中的位置的。&的效率比%快,但是这也要有前提条件,就是数组的长度必须是2的n次方。HashMap的大小如果指定了大小但是 不是你2的n次方,会直接使用最接近的2的n次方来代替。
5、初始容量不同,table是11,扩容大小为2n+1,map是原来的两倍。
6、ConcurrentHashMap实现原理
https://blog.csdn.net/qq_37659383/article/details/82924833
特点:
- ConcurrentHashMap在1.7之前是使用分段锁来实现的,Segment+HashEntry,Segment初始大小也是16,同样也是2的n次方。1.8之后变成了Node+CAS+Synchronized实现。减小了锁粒度,并且能保证并发安全。
- CAS是比较并交换的意思。在我们要更新一个值的时候,如果有多个线程同时修改,这样就会发生错误,这时我们的第一想法就是加锁,将共享资源锁上,这样就能保证线程安全了。但是这样就大大降低了效率,在很多时候,其实是比较少的并发修改的,这时我们就引出了CAS比较并交换。通过比较拿到的值,和期望的值,来不断的循环来尝试,这种方法并没有加锁,这也算一种乐观锁把。
- 优势:在高并发条件下,锁只是锁一小部分,其他部分的读写不受影响。
- key和value都不能为null,和HashMap区别开来。
实现的一些关键点:
1.7:ConcurrentHashMap使用的就是锁分段技术,ConcurrentHashMap由多个Segment组成(Segment下包含很多Node,也就是我们的键值对了),每个Segment都有把锁来实现线程安全,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。
因此,关于ConcurrentHashMap就转化为了对Segment的研究。这是因为,ConcurrentHashMap的get、put操作是直接委托给Segment的get、put方法
ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成。Segment是一种可重入锁ReentrantLock,在ConcurrentHashMap里扮演锁的角色,HashEntry则用于存储键值对数据。一个ConcurrentHashMap里包含一个Segment数组,Segment的结构和HashMap类似,是一种数组和链表结构, 一个Segment里包含一个HashEntry数组,每个HashEntry是一个链表结构的元素, 每个Segment守护者一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先获得它对应的Segment锁。
1.8:
1、JDK1.8的ConcurrentHashMap中Segment虽保留,但已经简化属性,仅仅是为了兼容旧版本。
2、ConcurrentHashMap的底层与Java1.8的HashMap有相通之处,底层依然由“数组”+链表+红黑树来实现的,底层结构存放的是TreeBin对象,而不是TreeNode对象;
3、ConcurrentHashMap实现中借用了较多的CAS算法,unsafe.compareAndSwapInt(this, valueOffset, expect, update); CAS(Compare And Swap),意思是如果valueOffset位置包含的值与expect值相同,则更新valueOffset位置的值为update,并返回true,否则不更新,返回false。
ConcurrentHashMap既然借助了CAS来实现非阻塞的无锁实现线程安全,那么是不是就没有用锁了呢??答案:还是使用了synchronized关键字进行同步了的,在哪里使用了呢?在操作hash值相同的链表的头结点还是会synchronized上锁,这样才能保证线程安全。
7、HashMap的PUT与GET方法
回答:
put方法:首先在HashMap构造函数并不会进行创建数组,只有在第一次进行put时使用resize进行创建数组,初始容量为16。在调用put方法时,先使用hash&(capital-1)查找插入位置,如果为空则直接插入,否则判断该位置是否为TreeNode,即判断是链表还是红黑树结构,如果是红黑树结构则调用红黑树的插入方法插入数据,否则使用链表的尾插法插入数据,在使用尾插法插入完数据时还有检测插入完当前元素之后是否超过了8个节点,超过报个节点要调用treeifyBin将该链表转化为红黑树。
get方法:这个比较简单,就是先根据hash值计算出在数组中的位置,之后判断第一个节点是否是要找的数组,不是的话判断是否是红黑树,是就直接使用红黑树的方法获取值,不是的话就遍历链表获取值即可。
resize方法:这个方法在初始化和扩容的时候都会调用。
初始化:直接使用预定义好的参数实例化一个Node数组即可。
扩容:判断当前容量大小是否超过HashMap定义的最大值,如果大于了还要扩容,则将integer的最大值赋值给数组扩容阈值,表示把阈值调到最大,这样以后就不会超过0.75这个阈值了,就不再进行扩容了。这次并没有进行真正意义上的扩容。
如果没有超过则直接创建原数组两倍大小的数组,将原来的节点复制过去即可,1.8的不会重新计算hash值,直接使用原来的hash值和新的的容量-1相与,看高位是否为0,为0表示该节点在新数组中的位置也是一样的,直接将他复制到一样的位置去即可。为1表示要将该节点放到新数组扩容出来的另一半上面去,位置为原位置加上原数组长度,遇到相同位置值时也是用尾插法或者红黑树解决冲突。
final Node<K,V>[] resize() {
//获取当前数组
Node<K,V>[] oldTab = table;
//判断当前数组大小,如果为null表示是新创建的,否则为数组长度
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//获取resize阈值
int oldThr = threshold;
int newCap, newThr = 0;
//这里判断是否是初次创建数组,如果是初次则初始化,否则根据旧容量大小判断情况
if (oldCap > 0) {
//如果旧容量大于定义的最大值1<<30,大约10亿,则将int最大值赋值给原数组阈值,不进行扩容数组了,且不移动节点
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//正常扩容为原来的两倍
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;
//创建新数组,初始化化大小为16,阈值为12
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"})
//实例化数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
//这个是扩容的机制,不是初始化
if (oldTab != null) {
//这个for循环是移动原来的元素
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
//如果当前元素不等于null,则将原数组中的位置值为null,方便gc
oldTab[j] = null;
//如果只是单个元素,后面没有链表或者红黑树,直接使用原来的hash值&新容量大小-1,,这样避免重读计算
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
//当前节点已经是红黑树了,则使用红黑树的方法扩容
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
//是一个普通链表,就是该节点没有超过8
else { // preserve order
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
//将旧链表中的数据放在新数组的合适位置上去,就是b站视频讲的那样移动
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;
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
//初始化
n = (tab = resize()).length;
//计算位置(n - 1) & hash,如果为null则直接放入数组中
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
//不为null,则进行尾插法插入位置
else {
Node<K,V> e; K k;
//一般不走这一步吧,因为p都还没初始化所以第一个就为false
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) {
//这里是尾插法,将链表的下一个值赋值给e,判断是否有值,如果没值则直接创建节点加入带加入节点即可
if ((e = p.next) == null) {
//p就是上面计算数组中的位置值Node
p.next = newNode(hash, key, value, null);
//binCount就是链表长度,这里判断是否需要进化成红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//如果存在相同的key,则直接将value覆盖即可。
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//判空与计算值位置,first为在数组中的位置,可能为链表头结点或者红黑树头结点
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;
//判断是否是红黑树,是的话直接使用红黑树的get方法返回
if ((e = first.next) != null) {
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
//遍历链表
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
这里的key是否存在是指key的值是否存在,不是位置上是否存在key
get(int key)方法代码实现流程如下:
1、根据key调用spread计算hash值;并根据计算出来的hash值计算出该key在table出现的位置i.
2、检查table是否为空;如果为空,返回null,否则进行3
3、检查table[i]处桶位不为空;如果为空,则返回null,否则进行4
4、先检查table[i]的头结点的key是否满足条件,是则返回头结点的value;否则分别根据树、链表查询。
8、ConCurrentHashMap的put方法和get方法
put
https://blog.csdn.net/qq_37659383/article/details/82924833
get
get(int key)方法代码实现流程如下:
1、根据key调用spread计算hash值;并根据计算出来的hash值计算出该key在table出现的位置i.
2、检查table是否为空;如果为空,返回null,否则进行3
3、检查table[i]处桶位不为空;如果为空,则返回null,否则进行4
4、先检查table[i]的头结点的key是否满足条件,是则返回头结点的value;否则分别根据树、链表查询。
get方法的思想是不是也很简单哈,与HashMap的get方法一模一样,分析到这里,ConcurrentHashMap类的源码的大概实现思路我们就基本清晰了哈,本着学习的精神,我们还是稍微看下其他的方法哈,例如:containsKey、remove、size等等
ConcurrentHashMap的读操作不需要加锁,因为结点Node的val和key用volatile修饰,保证了可见性,防止读到脏数据。对数组的volatile是保证了扩容的可见性
//ConcurrentHashMap
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;//这里使用了final来保证他不可改
final K key;//这里使用了final来保证他不可改
volatile V val;//这里使用了volatile保证可见性
volatile Node<K,V> next;//这里使用了volatile保证可见性
而HashMap的后面没有volatile
//HashMap
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
9、ConcurrentHashMap的扩容方法transfer
扩容时机
扩容时机:在put方法的最后添加完元素之后有个addCount方法,addCount中会去检查当前元素是否超过阈值0.75,超过则调用tranfer进行扩容。
总体上分为三种:和HashMap类似。
- 添加新元素后,元素个数达到扩容阈值触发扩容。
- 调用 putAll 方法,发现容量不足以容纳所有元素时候触发扩容。
- 某个槽内的链表长度达到 8,但是数组长度小于 64 时候触发扩容。
方法解析
简单来说就是使用多个线程来分配任务,默认情况一个线程处理16个桶,即node,多的先记录下来,创建新表,每个线程进行复制分配到的区间数据,处理完当前分配到的桶区间之后,去判断是否剩下的空间是否被别的线程处理完,没有则继续处理。
https://baijiahao.baidu.com/s?id=1709299654922153690&wfr=spider&for=pc 下面那短话的来源
-
通过计算 CPU 核心数和 Map 数组的长度得到每个线程(CPU)要帮助处理多少个桶,并且这里每个线程处理都是平均的。默认每个线程处理 16 个桶。因此,如果长度是 16 的时候,扩容的时候只会有一个线程扩容。
-
初始化临时变量 nextTable。将其在原有基础上扩容两倍。
-
死循环开始转移。多线程并发转移就是在这个死循环中,根据一个 finishing 变量来判断,该变量为 true 表示扩容结束,否则继续扩容。
3.1 进入一个 while 循环,分配数组中一个桶的区间给线程,默认是 16. 从大到小进行分配。当拿到分配值后,进行 i-- 递减。这个 i 就是数组下标。(
其中有一个 bound 参数,这个参数指的是该线程此次可以处理的区间的最小下标,超过这个下标,就需要重新领取区间或者结束扩容,还有一个 advance 参数,该参数指的是是否继续递减转移下一个桶,如果为 true,表示可以继续向后推进,反之,说明还没有处理好当前桶,不能推进
)3.2 出 while 循环,进 if 判断,判断扩容是否结束,如果扩容结束,清空临死变量,更新 table 变量,更新库容阈值。如果没完成,但已经无法领取区间(没了),该线程退出该方法,并将 sizeCtl 减一,表示扩容的线程少一个了。如果减完这个数以后,sizeCtl 回归了初始状态,表示没有线程再扩容了,该方法所有的线程扩容结束了。(
这里主要是判断扩容任务是否结束,如果结束了就让线程退出该方法,并更新相关变量
)。然后检查所有的桶,防止遗漏。3.3 如果没有完成任务,且 i 对应的槽位是空,尝试 CAS 插入占位符,让 putVal 方法的线程感知。
3.4 如果 i 对应的槽位不是空,且有了占位符,那么该线程跳过这个槽位,处理下一个槽位。
3.5 如果以上都是不是,说明这个槽位有一个实际的值。开始同步处理这个桶。
3.6 到这里,都还没有对桶内数据进行转移,只是计算了下标和处理区间,然后一些完成状态判断。同时,如果对应下标内没有数据或已经被占位了,就跳过了。
-
处理每个桶的行为都是同步的。防止 putVal 的时候向链表插入数据。
4.1 如果这个桶是链表,那么就将这个链表根据 length 取于拆成两份,取于结果是 0 的放在新表的低位,取于结果是 1 放在新表的高位。
4.2 如果这个桶是红黑数,那么也拆成 2 份,方式和链表的方式一样,然后,判断拆分过的树的节点数量,如果数量小于等于 6,改造成链表。反之,继续使用红黑树结构。
4.3 到这里,就完成了一个桶从旧表转移到新表的过程。
好,以上,就是 transfer 方法的总体逻辑。还是挺复杂的。再进行精简,分成 3 步骤:
- 计算每个线程可以处理的桶区间。默认 16.
- 初始化临时变量 nextTable,扩容 2 倍。
- 死循环,计算下标。完成总体判断。
- 1 如果桶内有数据,同步转移数据。通常会像链表拆成 2 份。
8、正则表达式
https://m.runoob.com/java/java-regular-expressions.html 语法
https://www.jianshu.com/p/462dfa11a517 练习
^字符:作用一:只匹配以字符开头的字符串,其他的不匹配直接返回,eg:^A,此时不会去匹配aA,会匹配Az
作用二:取反,eg:[^abx] 匹配除abx之外的所有字符
https://www.cnblogs.com/piaobodewu/p/9844667.html
():https://www.cnblogs.com/meowv/p/12895081.html 这个是js版的
主要用来分组,如(ab)+,表示ab整体出现一次或多次
分支:(a|b),选择a或者b都是能匹配上的
分组:
var regex = /(\d{4})-(\d{2})-(\d{2})/;
var string = "2017-06-12";
var result = string.replace(regex, "$2/$3/$1");//$2表示第二个括号所匹配的字符串
9、servlet
1、生命周期
Servlet的生命周期分为5个阶段:加载、创建、初始化、处理客户请求、卸载。
(1)加载:容器通过类加载器使用servlet类对应的文件加载servlet
(2)创建:通过调用servlet构造函数创建一个servlet对象
(3)初始化:调用init方法初始化
(4)处理客户请求:每当有一个客户请求,容器会创建一个线程来处理客户请求
份,方式和链表的方式一样,然后,判断拆分过的树的节点数量,如果数量小于等于 6,改造成链表。反之,继续使用红黑树结构。
4.3 到这里,就完成了一个桶从旧表转移到新表的过程。
好,以上,就是 transfer 方法的总体逻辑。还是挺复杂的。再进行精简,分成 3 步骤:
- 计算每个线程可以处理的桶区间。默认 16.
- 初始化临时变量 nextTable,扩容 2 倍。
- 死循环,计算下标。完成总体判断。
- 1 如果桶内有数据,同步转移数据。通常会像链表拆成 2 份。
8、正则表达式
https://m.runoob.com/java/java-regular-expressions.html 语法
https://www.jianshu.com/p/462dfa11a517 练习
^字符:作用一:只匹配以字符开头的字符串,其他的不匹配直接返回,eg:^A,此时不会去匹配aA,会匹配Az
作用二:取反,eg:[^abx] 匹配除abx之外的所有字符
https://www.cnblogs.com/piaobodewu/p/9844667.html
():https://www.cnblogs.com/meowv/p/12895081.html 这个是js版的
主要用来分组,如(ab)+,表示ab整体出现一次或多次
分支:(a|b),选择a或者b都是能匹配上的
分组:
var regex = /(\d{4})-(\d{2})-(\d{2})/;
var string = "2017-06-12";
var result = string.replace(regex, "$2/$3/$1");//$2表示第二个括号所匹配的字符串
9、servlet
1、生命周期
Servlet的生命周期分为5个阶段:加载、创建、初始化、处理客户请求、卸载。
(1)加载:容器通过类加载器使用servlet类对应的文件加载servlet
(2)创建:通过调用servlet构造函数创建一个servlet对象
(3)初始化:调用init方法初始化
(4)处理客户请求:每当有一个客户请求,容器会创建一个线程来处理客户请求
(5)卸载:调用destroy方法让servlet自己释放其占用的资源