Java
1.接口和抽象类有什么区别?
-
从设计和逻辑的角度上讲 接口是对行为的抽象,而抽象类是对类整体的抽象,所以接口只能含有抽象方法不能含有非常量成员,而抽象类除了可以含有抽象方法之外与普通类并无而至,并且他们既然都是某种抽象那自然都不能实例化。
-
一个类是否实现一个接口代表这个类是否具备某个行为,而一个类是否继承一个抽象类则代表这个类是否属于某个种类,理解了这样的设计我们就不难理解为什么接口可以多继承而类不能多继承了。
-
以上这些是从设计的层面来讲他们的区别,那剩下的无非就是一些实际使用上的细节:
- 类名后implements实现接口,抽象类用extends关键字继承,当前类如果不是抽象类那就必须实现抽象方法。
- 接口里的方法都是public abstract修饰的,抽象方法没有方法体,但是在1.8中,接口中允许有静态方法,私有方法和带有缺省实现的抽象方法,但是接口仍然不能有自己的成员变量。(default修饰的方法可以拥有缺省实现)
public interface Int1 extends INT2,INT3{
void m1();
void m2();
void m3();
}
2.面向对象特性(Object Oriented Programming)
面向对象和面向过程是一种软件开发思想。
- 面向过程就是分析出解决问题所需要的步骤,然后用函数按这些步骤实现,使用的时候依次调用就可以了。
- 面向对象是把构成问题事务分解成各个对象,分别设计这些对象,然后将他们组装成系统。
- 简而言之面向过程只用函数实现,面向对象是用类实现各个功能模块。
面向对象的基本要素:封装、继承、多态。
封装的目的是隐藏类的实现细节,对外只公布需要公开的属性和行为。在java中能使用private、default、protected、public四种访问修饰符来对外部的访问做限制。
继承是子类可以继承父类的属性和行为。
多态是指父类对象中的同一个行为能在其多个子类对象中有不同的表现。继承、重写、父类引用指向子类对象是多态存在的必要条件。多态有两种机制:编译时多态、运行时多态,对应重载(overload)和重写(override)。简单说,重写是父子类中相同名字和参数的方法,但是拥有不同的实现;重载是指同一类中有多个同名的方法,但这些方法有着不同的参数(返回值随便相不相同),本质上这些方法签名是不一样的。
3.Override和Overload的区别
重写:发生在父子类中,同一种方法的不同实现,方法签名(方法名+参数)相同,返回值小于等于父类,访问修饰符大于等于父类,抛出异常小于等于父类。重写是运行时多态。
重载:发生在同一个类当中,相同名字方法,参数列表不同,返回值可以不同,访问修饰符可以不同,抛出异常可以不同。重载是编译时多态。
| 重写重载区别 | Override | Overload |
|---|---|---|
| 参数个数/类型/顺序 | 必须相同 | 必须不同 |
| 返回值 | 子类<=父类 | 可以不同 |
| 访问修饰符 | 子类>=父类 | 可以不同 |
| 异常 | 子类<=父类 | 可以不同 |
| 多态 | 运行时多态 | 编译时多态 |
4.反射
定义:Java反射机制是一种动态获取信息、动态调用对象方法的功能。比如说在运行时判断对象所属的类、调用对象所在类的方法、构造任意一个类的对象、判断任意一个类所具有的属性和方法。
反射的应用场合:程序有时会接收到外部传入的对象,该对象的编译时类型和运行时类型不同,但是程序有要调用对象的运行时类型的方法,那么就只好用反射在运行时发现对象和类的真实信息("对象.getclass()")。
反射不破坏封装:反射确实可以用setAccessible()解除私有限定,对私有属性和方法进行操作。虽然如此,但反射还是不破坏封装性的,主观上来说开发人员没有必要使用私有方法故意去破坏封装好的类,客观上反射也无法使子类操作父类的私有变量,从这两点看,封装性并没有被破坏。
编译时类型和运行时类型:在Java程序中许多对象在运行时有两种类型,编译时类型和运行时类型,编译时类型由声明对象时所使用的类型决定,运行时类型由实际赋值给对象的类型决定如: Person p=new Student();其中编译时类型为 Person,运行时类型为 Student。
Java反射API(有Class类、Field类、Method类、Constructor类)
-
获取 class 对象的三种方法:
Class.forName("全类名")类名.class对象.getClass()创建对象的两种方法:
Class对象.newInstance()Constructor对象.newInstance() -
Field[] getFields():获取所有的"公有字段Field getField(String fieldName):获取某个"公有的"字段;Field getDeclaredField(String fieldName):获取某个字段(可以是私有的)Field[] getDeclaredFields():获取所有的字段(包括私有的) -
void set(Object obj,Object value): 参数说明: obj:要设置的字段所在的对象; value:要为字段设置的值; -
setAccessible(true);:解除私有限定 -
Constructor getConstructor(Class[] params)-- 获得使用特殊的参数类型的公共构造函数,Constructor[] getConstructors()-- 获得类的所有公共构造函数Constructor getDeclaredConstructor(Class[] params)-- 获得使用特定参数类型的构造函数(可以是私有的)Constructor[] getDeclaredConstructors()-- 获得类的所有构造函数(包括私有的) -
Method getMethod(String name, Class[] params)-- 使用特定的参数类型,获得命名的公共方法Method[] getMethods()-- 获得类的所有公共方法Method getDeclaredMethod(String name, Class[] params)获得类声明的特定签名的方法(可以是私有的)Method[] getDeclaredMethods()获得类声明的所有方法(包括私有的) -
Object invoke(Object obj,Object... args)obj : 要调用方法的对象 args:传递的实参;
5.构造方法的意义
构造方法是一个与类同名的方法,其功能是完成对象的初始化。当类实例化一个对象时会自动调用构造方法,自定义类中如果自己不写构造方法那么Java系统会默认添加一个无参的构造方法。构造方法可以重载(编译时多态)。
6.值传递和引用传递?
**值传递:**是指在调用函数时将实际参数复制一份传递到方法中,这样在方法中如果对参数进行修改,将不会影响到实际参数。
**引用传递:**是指在调用函数时将实际参数的地址直接传递到方法中,那么在方法中对参数所进行的修改,将影响到实际参数。
7.Lambda表达式
lambda 表达式可以理解为匿名内部类的作用,但是写起来更加简洁美观。(匿名内部类)也就是能够快速的创建出一个函数式接口的实现。
8.Java的基本数据类型、包装类及自动装箱拆箱
-
byte,8位
-
char,16位
-
short,16位
-
int,32位
简单 类型 boolean byte char short Int long float double 二进 制位 数 1 8 16 16 32 64 32 64 包装 类 Boolean Byte Character Short Integer Long Float Double
自动装箱和拆箱
-
装箱:将基础类型转化为包装类型。
-
拆箱:将包装类型转化为基础类型。
-
当基础类型与它们的包装类有如下几种情况时,编译器会自动帮我们进行装箱或拆箱:
- 赋值操作(装箱或拆箱)
- 进行加减乘除混合运算 (拆箱)
- 进行>,<,==比较运算(拆箱)
- 调用equals进行比较(装箱)
- ArrayList、HashMap等集合类添加基础类型数据时(装箱)
示例代码:
Integer x = 1; // 装箱 调⽤ Integer.valueOf(1)
int y = x; // 拆箱 调⽤了 X.intValue()
9.static关键字
static 修饰的资源是属于类级别的,可以在没有对象的时候调用,只有一个副本,全局共享
static 修饰方法:静态方法。不依赖于对象就可以访问,可以直接类名.静态方法访问。静态方法不可以访问对象的非静态方法和非静态变量。
static 变量:静态变量被所有的对象所共享,在内存中只有一个副本,存在方法区中,当且仅当 在类初次加载时会被初始化。
static 静态代码块:在类被初次加载时执行,比如某个方法会new一个固定内容的对象,那么每次调用该方法就会new一次对象,于是可以将该对象放到 static 代码块里面提前初始化好,让该方法直接使用,这就提升了程序的性能了。
static 静态内部类:静态内部类和一般的类一致可以定义静态变量、方法、构造方法。静态内部类可以访问外部类所有的静态变量和方法。外部类可以用“this.静态内部类”方式可以在外部类访问静态内部类。
10.final关键字
final可以修饰变量、方法、类。
final修饰变量:如果是基础数据类型的变量那么初始化后值不可改变,如果是引用类型则该变量不能在初始化后不可再指向其他对象。
final修饰方法:final修饰方法表示方法不可以被重写。private方法也是final的,final方法在编译时静态绑定,所以比非final方法快。
final修饰类:表示类不可被继承。(我们也可以将类的构造方法声明为private使得类不可被继承,若被继承,子类没法调用父类的构造方法出错)
11.内部类
Java 类中除了变量和方法,还可以定义类。根据定义的方式不同,内部类分为静态内部类,成员内部类,局部内部类,匿名内部类四种。
静态内部类
public class Out {
private static int a;
private int b;
public static class Inner {
public void print() {
System.out.println(a);
}
}
}
- 静态内部类和一般的类一致可以定义静态变量、方法、构造方法。
- 静态内部类可以访问外部类所有的静态变量和方法。
- 外部类可以用“this.静态内部类”方式可以在外部类访问静态内部类,其他类可以用"类名.静态内部类名"来访问。如下所示:
Out.Inner inner = new Out.Inner();inner.print();
成员内部类
public class Out {
private static int a;
private int b;
public class Inner {
public void print() {
System.out.println(a);
System.out.println(b);
}
}
}
- 定义在类内部的非静态类,就是成员内部类。成员内部类不能定义静态方法和静态变量(final 修饰的除外)。这是因为成员内部类是非静态的,类初始化的时候只会初始化静态成员。
- 成员内部类可以访问外部类的所有属性和方法(不管是否静态)。
局部内部类(定义在方法中的类)
public class Out {
private static int a;
private int b;
public void test(final int c)
{
final int d = 1;
class Inner
{
public void print()
{
System.out.println(c);
}
}
}
}
定义在方法中的类,就是局部类。如果一个类只在某个方法中使用,则可以考虑使用局部类
匿名内部类(要继承一个父类或者实现一个接口时,直接使用new 一个对象的引用)
我们继承一个父类或者实现一个接口时,直接定义并使用的类,它也没有class关键字,因为匿名内部类是直接new一个对象的。
public abstract class Bird {
private String name;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public abstract int fly();
}
public class Test {
public void test(Bird bird){
System.out.println(bird.getName() + "能够飞 " + bird.fly() + "米");
}
public static void main(String[] args) {
Test test = new Test();
test.test(new Bird() {
public int fly() {
return 10000;
}
public String getName() {
return "大雁";
}
});//大雁能够飞 10000米
}
}
12.泛型
泛型的本质是参数化类型,也就是说所操作的数据类型被指定为一个参数。比如创建集合时就指定集合元素的类型,那么这个集合只能保存其指定类型的元素。
泛型方法()
你可以写一个泛型方法,该方法在调用时可以接收不同类型的参数(项目中经常使用到) 。根据传递给泛型方法的参数类型,编译器适当地处理每一个方法调用。 比如一个用来打印的方法,我们用泛型作为传入的参数类型,显然就比指定参数类型要更加适用实际情况。
// 泛型方法 printArray
public static < E > void printArray( E[] inputArray ) {
for ( E element : inputArray ){
System.out.printf( "%s ", element );
}
}
泛型类<A,B>
定义类的时候在类名后指定类型形参<A,B>,在类里面A和B就可以当成类型使用。当然也可以声明更多类型形参。
public class Test<A,B> { // 泛型类:定义类的时候指定类型形参T,在类里面T就可以当成类型使用
private A a;
private B b;
public A getA() {return a;}
public B getB() {return b;}
public void setB(B b) {this.b = b;}
public void setA(A a) {this.a = a;}
}
类型通配符?
可以用类型通配符?代替具体的类型,<? extends T>表示该类型是 T 类型的子类、<? super T>表示该类型是T的父类。
类型擦除
Java中的泛型是在编译器层次完成的,生成的Java字节码中是不包含泛型信息的,也就是说使用泛型时加上的类型参数在编译时被会替换成具体的类,这个类一般是Object,这就是类型擦除。类型擦除的过程是:首先将泛型参数用其最顶级的父类型替换,然后移除代码中所有的类型参数。
13.String,StringBuilder和StringBuffer的区别
-
可变性
String类中使用final修饰字符数组,所以 String 对象是不可变的。StringBuilder与StringBuffer继承自同一个类,没有用final来修饰字符出租,所以StringBuilder和StringBuffer是可变的。
-
线程安全
- String是不可变的那当然是线程安全的。
StringBuilder并没有对方法进行加同步锁,所以是非线程安全的。StringBuffer对方法加了锁,所以是线程安全的。
14.说一下Java中的==与equals()的区别
- **==**对于基础数据类型比较的是值是否相同,引用类型比较的是地址是否相同。
- equals()在没有重写时与==是等价的,但是像Integer、String、Double这些包装类则重写了equals()方法,将equals()实现为比较两个对象的内容是否相等。一般我们重写了equals()方法也要重写hashCode(),不然hashCode()仍然返回内存地址,那么在使用hashmap这类依靠hashCode决定索引位置的集合类时就会发生错误。
15.Java中的异常体系说一下?
问到异常时引申到JVM中来装逼
-
概念:Java中的异常分为Exception和Error,他们都是继承了Throwable类
-
Exception是可以预料的意外情况,应该被捕获并进行相应处理。Exception又分为检查异常和不检查异常
- 在编译阶段Java编译器强制程序在可能出现检查异常的地方进行捕获、处理或抛出异常,常见的有IO异常、SQL异常、ClassNotFoundException类不存在异常
- 不检查异常就是运行时异常,通常是写代码时可以避免的逻辑错误,编译器不强制要求处理,常见的有NullPointerException空指针异常、ClassCastException类型转换异常
-
Error是程序无法处理的严重错误,会导致程序处于不可恢复状态,它不方便也不需要捕获,常见的有OOM内存溢出,StackOverflowError栈溢出、VirtualMachineError虚拟机错误。
-
-
异常的处理方式:可以抛出异常或使用try catch语句块捕获异常
- 抛出异常就是遇到异常时不进行具体的处理,直接将异常抛给调用者,让调用者处理;抛出异常的三种方式:throws、throw和系统自动抛出异常,其中throws作用在方法上,表示方法可能抛出的异常;throw作用在方法内,表示明确抛出一个异常
- 使用try catch代码块捕获异常能够针对不同异常进行处理
-
JVM如何处理异常:
- JVM如何抛出异常:在Java代码层面显式抛出异常的操作在JVM层面都是依靠athrow字节码指令实现的,而运行时异常会在JVM检测到异常状况时自动抛出
- 异常实例的构造为何如此昂贵:JVM在构造异常实例生成异常的栈轨迹,这个操作需要逐一访问当前线程的 虚拟机栈帧,并且记录下各种调试信息,比如栈帧指向的方法名、方法所在类、触发异常的行号
- JVM如何捕获异常:Java代码层面的try-catch-finally语句在JVM层面不是由专门的字节码指令实现的,而是采用异常表机制实现的,代码编译成字节码后,每个方法对应一个异常表,catch代码块 和 finally代码块 都会生成异常表条目,方法触发异常时,JVM查找方法对应的异常表,使用无条件控制转移指令goto把控制流转移到对应的异常处理器当中
-
Suppressed异常:在jdk1.7以前,catch语句捕获一个异常后catch代码块也触发了异常时,JVM抛出的异常只有最新的那个异常,这使开发人员难以真正发现问题,为此jdk1.7引入Suppressed异常,使得抛出的异常可以附带多个异常的信息
-
try-with-resources语法糖:本身关闭资源的操作很容易触发异常,在使用多个资源的时候代码非常啰嗦,一堆try-finally块,为此jdk1.7引入try-with-resources语法糖,在try后声明实现了AutoCloseable接口的类实例,编译器会自动调用 实例的close()方法,而且这个语法糖就应用了Suppressed异常,close()操作出异常时不会把原来的异常丢失
16.Vector
底层是Object 数组实现的,Vector是线程安全的,支持随机访问,默认长度是10,每次扩容默认扩容为2倍,由于是线程安全的,同步时需要开销的,所以如果不需要线程安全就不建议选择Vector,而应该用ArrayList。
17.ArrayList
底层实现是 Object 数组(transient Object[] elementData);
默认长度是10 private static final int DEFAULT_CAPACITY=10;
扩容时变成1.5倍 int newCapacity = oldCapacity + (oldCapacity >> 1);
扩容是创建新的数组,将原有数组拷贝到新地址elementData = Arrays.copyOf(elementData, newCapacity)
支持随机访问。
可以通过ArrayList 构造函数合理指定ArrayList的初始化大小,避免多次扩容造成开销。ArrayList非常适合随机索引读写较多的场景。
非尾节点的增、删O(n) 随机位置读写O(1) 尾节点的增、删是O(1)的。ArrayList越靠近尾部的增删越快是接近O(1)的,所以不能简单地说ArrayList不适合增删较多的场景。
18.LinkedList
底层实现是双向链表。
LinkedList也可以当作堆、栈、队列、双端队列来进行操作。
不支持随机访问,适合头部插入和删除较多的场景。
19.ArrayList和LinkedList的区别?
概述:ArrayList和LinkedList都是实现了List接口的类,他们都是元素的容器,用于存放对象的引用,可以对存放的元素进行增删改查的操作,还可以进行排序。
实现机制、获取元素、增删元素、扩容机制、使用场景:
-
- 实现机制:ArrayList是基于Object数组实现的。
- 获取元素操作:ArrayList实现了RandomAccess接口,对元素的随机访问速度很快是O(1)的。
- 增删元素操作:在进行增删的时候需要排序增删位置后面的所有元素,元素位置越快后消耗时间越接近O(1)。所以ArrayList在靠近尾部的增删操作效率是要高于LinkedList的
- 扩容机制:新建一个原数组空间1.5倍大小的Object数组,将原有数组复制到新数组的内存地址去。
- 使用场景:程序要对各个索引位置的元素都要做大量增删改查操作那就应该选择ArrayList,包括在列表后半段有大量增删元素的操作也适合使用ArrayList。
-
- 实现机制:LinkedList是基于双向链表实现的,元素并不连续存放,于是其元素的随机访问速度非常慢,使用双向链表也是基于时间换空间的原则将极端情况下遍历全表优化为极端情况下遍历半表。
- 获取元素操作:从前往后 或 从后往前循环查找,最坏要循环半张表。
- 增删元素操作:首先需要获取元素所在位置再进行增删,所以在接近 头部 / 尾部 的增删很快是接近 O(1) 的,而靠近中间位置的增删效率就很差了,还不如ArrayList。
- 扩容机制:LinkedList每个元素存放节点的空间大小大于元素本身,空间开销较大,但新增节点时不需要复制数组,没有扩容方面的开销。
- 使用场景:如果程序主要是对列表进行循环,并在循环当中动态的在当前位置增加、删除元素,那就应该选择LinkedList。再加一个需要在头部频繁增删元素的时候可以选择LinkedList,或者说要当队列和双端队列的时候用LInkedList较好,而Java里LInkedList也确实有Queue和Deque的接口可以实现。并不能简单地说大量增删元素就应该用LinkedList,经过实验证明在列表后半段的元素增删上LinkedList是不如ArrayList的,所以LinkedList的使用场景可以说是极其苛刻的,但是具体到底要不要用LinkedList还得看实际情况。
-
测试 测试结果 从集合头部位置删除元素 ArrayList>LinkedList 从集合中间位置删除元素 ArrayList<LinkedList 从集合尾部位置删除元素 ArrayList<LinkedList 从集合头部位置新增元素 ArrayList>LinkedList 从集合中间位置新增元素 ArrayList<LinkedList 从集合尾部位置新增元素 ArrayList<LinkedList for(;;) 循环遍历元素 ArrayList<LinkedList 迭代器迭代循环遍历元素 ArrayList≈LinkedList 数据量\插入位置 头部 中间 尾部 随机 百 效率持平 效率持平 效率持平 效率持平 千 LinkedList插入快 效率持平 效率持平 ArrayList插入快 万 LinkedList插入快 ArrayList插入快 效率持平 ArrayList插入快 十万 LinkedList插入快 ArrayList插入快 ArrayList插入快 ArrayList插入快 百万 LinkedList插入快 ArrayList插入快 ArrayList插入快 ArrayList插入快
20.SkipList(跳表)
定义:链表加多级索引的结构,就是跳表。
查询时间复杂度:O(logn)
增加元素:时间复杂度O(logn),要根据随机函数的返回值k将该节点加到1~k层(1是底层)当中,从k层到1层中在插入位置插入该节点。随机函数的选择非常考究,他能使跳表的性能不退回到链表。
删除元素:时间复杂度O(logn),从高层到底层找到包含指定节点值的节点,然后删除该节点,如果索引层删除后只剩下两个节点那么删除这一层。
空间复杂度:O(n)但由于要建立额外的多层索引层,还需要额外O(n)空间来存放索引,又因为索引不需要存放具体对象,只有前后指针和关键值,所以在对象比。
redis当中的有序集合zset在数据量较小的情况下用压缩列表来实现,数据量大的时候用跳表实现。
21.CopyOnWriteArrayList写时复制ArrayList(线程安全的ArrayList)
读操作:读操作是不加锁的,读写是可以并行的,读操作性能非常高。
增删改操作:需要获得互斥锁,然后将原内存拷贝一份到新内存中,在新内存中进行写操作,再将原内存的指针指向新内存,然后释放锁。原内存将会被GC回收。
使用场景:适合读多写少,数据量不大的高并发场景。
CopyOnWriteArraySet 和ConcurrentSkipListMap/Set同CopyOnWriteArrayList
22.LinkedHashMap有序哈希表
应用场景:HashMap本身是无序的,LinkedHashMap可以按照我们的插入顺序存储键值对同时还保留有哈希表的高效增删改,当我们希望有顺序地去存储键值对时,就需要使用LinkedHashMap了,并且LinkedHashMap自带实现了LRU缓存淘汰策略,可以按照访问时间排序键值对。
实现:LinkedHashMap底层使用哈希表加双向链表组合实现的,Linked指的不是拉链而是双链表的意思,它是每一个键值对对应一个节点,包含prev、next、hnext指针、value值这四个信息,hnext就是在散列表上的具体位置开的拉链,前后指针将整个双链表串联起来,它可以很直观的实现LRU缓存淘汰策略(按照访问时间排序)。
读取元素时间复杂度:O(1),根据key找到对应节点。
增删元素时间复杂度:O(1),如果找到key对应的节点,然后增删就可以,将其加入到双链表尾节点。
LinkedHashSet就是LinkedHashMap套层皮实现的,像是HashMap和HashSet那样,实际存储的还是键值对只不过HashSet中存储的键值对的值都是同一个Object
23.HashMap哈希表
-
底层:HashMap的底层是数组+链表+红黑树,table数组中每个桶存放链表,链表中每个节点存放键值对,HashMap的初始容量为16,默认负载因子是0.75,当饱和度达到0.75时进行扩容,扩容为2倍并进行Rehash,以拉链法解决哈希冲突可能会导致链表过长,于是在Java8引入优化,当链表长度达到树化阈值8时,若数组长度小于等于64则扩容,大于64则将链表转换为红黑树,小于等于6时再次退化为链表,HashMap支持null键和null值。
-
为什么HashMap的初始容量是16:首先下标的计算公式是int index = key.hashCode()&(length-1) ,这里HashMap用&运算代替%运算提高了运算效率,而只有当HashMap的容量(桶的数量)必须得是2的幂次时才能满足 **X % 2^n = X & (2^n – 1)**X模2的幂次等于X与上 2的幂次减一。而之所以是16而不是其他2的幂次,是考虑到这个值太小会导致多次扩容影响性能,设置的太大又浪费内存,所以选择16。
-
HashMap的缺点:
-
为什么树化阈值是8:根据泊松分布,当负载因子默认为0.75时,单个hash槽内元素个数为8的概率小于百万分之一,所以将7作为一个分水岭,等于7的时候不转换,大于等于8的时候才进行转换,小于等于6的时候就化为链表。
-
扩容机制:当HashMap的饱和度达到负载因子时就会扩容,用代码表示就是
HashMap.Size>=Capacity * LoadFactor;此时容量扩容为两倍,并进行ReHash把所有的键值对迁移到新数组里。还有就是当链表长度达到8且此时数组长度小于等于64时不会树化而是进行扩容。 -
put()方法:
-
get()方法
-
遍历HashMap的方式
-
通过ForEach循环进行遍历
for (Map.Entry<Integer, Integer> entry : map.entrySet()) { System.out.println("Key = " + entry.getKey() + ", Value = " + entry.getValue()); } -
使用带泛型的迭代器进行遍历
Iterator<Map.Entry<Integer, Integer>> entries = map.entrySet().iterator(); while (entries.hasNext()) { Map.Entry<Integer, Integer> entry = entries.next(); System.out.println("Key = " + entry.getKey() + ", Value = " + entry.getValue()); } -
通过Java8 Lambda表达式遍历
map.forEach((k, v) -> System.out.println("key: " + k + " value:" + v));
-
HashSet
HashSet是用HashMap实现的,只不过所有键值对的value都是同一个Object对象罢了。HashSet仅存储成员对象,而HashMap是存储键值对的,HashSet能保证成员互不相同,它的add()方法返回是否添加元素成功。
默认初始容量16,加载因子为0.75,扩容翻倍。
| HashMap | HashSet |
|---|---|
| 使用key来计算hashcode值 | 使用成员对象来计算hashcode值 |
| 储存键值对 | 仅存储成员对象 |
| 使用put()方法将元素放入map中 | 使用add()方法将元素放入set中 |
24.ConcurrentHashMap
-
底层实现:
-
JDK8之前的ConcurrentHashMap是采用分段锁机制,将整个数组分成一个个segment,segment继承了ReentranLock所以是可重入锁,当要进行写操作时,必须首先获得与它对应的Segment锁,相比于hashtable锁住全表提升了性能。
这种并发控制方案还是比较粗粒度的,当两个写操作对应的位于不同的两个段时可以不受影响,但是位于同一个段的不同数组槽位就会存在资源竞争了,所以吞吐量并没有提高太多,但是任何读操作不存在竞争也就是读写分离的。
-
JDK8的ConcurrentHashMap抛弃了Segment的概念,他的实现类似于HashMap在jdk8中的也就是 数组+链表+红黑树,用Synchronized+CAS的方式代替可重入锁,将锁的粒度细化到单个数组槽位或者说桶上,因此只有写操作具有相同hash值的线程之间才存在竞争,大大提高了性能。而且新的锁机制使并发的线程可以在一定次数的自旋内拿到锁那么synchronized就不会升级为重量级锁,等待的线程也不会挂起,而可重入锁是不会自旋直接挂起的,所以synchronized替换ReentranLock减少了线程的挂起和唤醒的额外开销。ConcurrentHashMap的初始容量16,树化阈值8,负载因子0.75、扩容2倍等都基本跟JDK8中的HashMap一致。有一点不一样的是,在扩容时为了提高查询搜索效率会将一些链表和红黑树拆成两个链表和两棵树,还有ConcurrentHashMap不支持null键和null值。
-
25.HashTable 与 hashMap 的不同
- HashTable 线程安全,方法都是 sychronized 修饰;hashMap 线程不安全;
- HashTable键和值不可以为null;而 hashMap 支持null键null值
- HashTable 初始容量 11,扩展 2*n+1,HashMap,初始 16,扩容加倍;
- HashMap 当链表长度达到8时,转化成红黑树,而 HashTable 没有
26.TreeMap(有序)
- 底层实现:底层是红黑树,TreeMap会对传入的key进行排序,可以使用元素的自然顺序,也可以使用集合中自定义的比较器来排序。支持null键null值。它的get、put、remove 之类操作都是 O(logn)的时间复杂度。
- 自然比较和自定义比较器:若要使用自然比较,注意被比较的元素要在其类中实现Comparable接口,里面有个compareTo方法,如果使用自定义的比较器需要在创建TreeMap对象时,将自定义比较器的对象传入到TreeMap构造方法中,自定义比较器要实现Comparatar接口,并实现比较方法compare(To1,To2),使用比较器那么被比较的元素就不用还是先Comparable接口了。
TreeSet(有序)
- 就是TreeMap套层皮,能保证排序的同时还能保证元素唯一,实际上存储的还是键值对,只不过值都是同一个Object。比较方式跟reeMap没变还是自然排序和比较器排序两种方式。TreeSet的add、remove、contains 都是O(logn)的时间复杂度。
27.Queue
- 阻塞与非阻塞:阻塞指的是当队列已满时,入队操作阻塞;当队列已空时,出队操作阻塞。
- 单端与双端:单端指的是只能队尾入队,队首出队;而双端指的是队首队尾皆可入队出队。
Java 并发包里阻塞队列都用 Blocking 关键字标识,单端队列使用 Queue 标识,双端队列使用 Deque 标识
-
单端阻塞队列:
- ArrayBlockingQueue:内部持有队列为数组。
- LinkedBlockingQueue:内部持有队列为链表。
- SynchronousQueue:内部不持有队列,此时生产者线程的入队操作必须等待消费者线程的出队操作。
- LinkedTransferQueue:融合 LinkedBlockingQueue 和 SynchronousQueue 的功能,性能比 2 更好。
- PriorityBlockingQueue:支持按照优先级出队。
- DelayQueue:支持延时出队。
-
双端阻塞队列:LinkedBlockingDeque
-
单端非阻塞队列:ConcurrentLinkedQueue
-
双端非阻塞队列:ConcurrentLinkedDeque
- 实际工作中,一般都不建议使用无界的队列,因为数据量大了之后很容易导致 OOM。上面我们提到的这些 Queue 中,只有 ArrayBlockingQueue 和 LinkedBlockingQueue 是支持有界的,所以在使用其他无界队列时,一定要充分考虑是否存在导致 OOM 的隐患。
28.Java提供的IO方式
同步异步、阻塞非阻塞
-
同步或异步(针对任务而言,是一种宏观的机制)
- 同步(synchronous):同步是一种可靠的有序运行机制,当我们进行同步操作时,后续的任务是等待当前调用返回,才会进行下一步(后续要等待当前的任务完成才能继续)。而且同步一次和synchronized(并发编程的同步原语)很像,实际上语义也是类似的,同步就是针对共享而言的,如果不是共享资源就没有必要进行同步
- 异步(asynchronous):异步则相反,其他任务不需要等待当前调用返回,通常依靠事件、回调等机制来实现任务间次序关系,异步就是独立,相互之间不受到任何制约(无需等待)
-
阻塞与非阻塞(阻塞的概念是针对线程(调用者)而言)
- 阻塞(blocking):在进行阻塞操作时,当前线程会处于阻塞状态,无法从事其他任务,只有当条件就绪才能继续,比如 ServerSocket 新连接建立完毕,或数据读取、写入操作完成
- 非阻塞(non-blocking):非阻塞则是不管 IO 操作是否结束,直接返回,相应操作在后台继续处理。
IO的概念
宏观含义就是输入输出,所以IO其实包含的内容和目标很多,比如:网络编程中的Socket 通信、本地的文件等等都是IO的操作目标。
- 输入流、输出流(InputStream/OutputStream)是用于读取或写入字节的,例如操作图片文件。
- Reader/Writer(Java中的读写器) 则是用于操作字符,适用于类似从文件中读取或者写入文本信息。因为计算机本质上操作的都是字节,所以Reader/Writer 相当于构建了应用逻辑和原始数据之间的桥梁。
- BufferedOutputStream 等带缓冲区的实现可以将批量数据进行一次操作,可以避免频繁的磁盘读写,进而提高 IO 处理效率。
BIO、NIO、AIO
-
BIO:指的是传统Java.io包,交互方式是同步阻塞的方式,线程会阻塞在 输入流的读或输出流的写 上。Java.IO包基于Stream流模型实现,有字符流和字节流,提供了很熟知的一些IO功能,并且运用了装饰器的设计模式,以组合代替继承,不需要复杂的继承结构就能使类增强各种功能。
-
字符流
-
抽象类Reader的实现类:
- 原始类:CharArrayReader、StringReader、FileReader、PipedReader
- 装饰器:BufferedReader、FilterReader(抽象类,需要自己实现新增的功能)
-
抽象类Writer的实现类:
- 原始类:CharArrayWriter、StringWriter、FileWriter
-
装饰器:BufferedWriter、FilterWriter(抽象类,需要自己实现新增的功能)
-
-
字节流
-
抽象类InputStream的实现类:
- 原始类:ByteArrayInputStream、FileInputStream、PipedInputStream
- 装饰器:BufferedInputStream、DataInputStream、ObjectInputStream
-
抽象类OutputStream的实现类:
- 原始类:ByteArrayOutputStream、FileOutputStream、PipedOutputStream
- 装饰器:BufferedOutputStream、DataOutputStream、ObjectOutputStream
-
-
-
NIO:指的是Java.nio包,交互方式是同步非阻塞的方式。Java.nio包不再基于Stream流模型实现,而是基于Channel通道模型实现,Channel是一个对象,既可以读取也可以写入数据,Channel通道与Stream流相比 是双向的所以更能反映底层操作系统的真实情况,特别是在Unix模型中底层操作系统通道是双向的;nio包中提供的Selector类可以在单线程中利用轮询事件的机制监控注册在 Selector 上的多个 Channel 中,是否有 Channel 处于就绪状态(或者说触发了事件),进而实现了单线程对多 Channel 的高效管理,Selector类是nio实现多路复用的重要基础;nio包中的Buffer缓存是nio包中提供的一个固定数据量的指定基本类型的数据容器。使用nio可以构建多路复用的、同步非阻塞 IO 程序。
-
Buffer:是nio包中提供的一个固定数据量的指定基本类型的数据容器。除了布尔之外所有基本类型都有对应的Buffer实现,我们可以用allocate()方法指定Buffer的大小,还有flip()用来将buffer的写模式转换到读模式、clear()清空buffer并转换为写模式、compact()将未读完的数据移动到缓存头部然后转换为写模式,等常用的对buffer的操作。
ByteBuffer buffer=ByteBuffer.allocate(1024);//缓冲区被指定为1024字节大小 -
Channel:Channel是一个对象,既可以读取也可以写入数据,Channel通道与Stream流相比 是双向的所以更能反映底层操作系统的真实情况,特别是在Unix模型中底层操作系统通道是双向的。
fin.transferTo(0,fin.size(),fout); fout.transferFrom(fin,0,fin.size());//Channel间相互通信,两者作用相同File source=null; File target=null; FileChannel fin=new FileInputStream(source).getChannel(); FileChannel fout=new FileOutputStream(target).getChannel();//获得Channel是通过其对应的流来get的 -
Selector:Selector类可以在单线程中利用轮询事件的机制监控注册在 Selector 上的多个 Channel 中,是否有 Channel 处于就绪状态(或者说触发了事件),进而实现了单线程对多 Channel 的高效管理,Selector类是nio实现多路复用的重要基础。
-
-
AIO:可以用Future对象来对Future对象进行操作,或可以通过实现CompletionHandler接口中自定义的回调函数call-back方式来实现的异步调用机制。
服务器项目举例说明
我也曾经做过一个服务器项目来帮助我更深理解io的特性。这个服务器需要实现同时服务多个客户端的请求。
-
首先使用BIO来实现,那就很直观:服务端启动ServerSocket绑定到某个端口(比如说8888),然后在while循环中调用accept()方法阻塞等待客户端连接,连接建立后就启动一个单线程负责回复客户端的请求,客户端的话就用一个Socket模拟一个简单的客户端,只进行连接、读取、打印。很明显这个方案有明显的问题,就是启动或者销毁线程是开销很大的,那么很显然可以想象到用固定大小的线程池来管理工作线程避免频繁创建、销毁线程。但这种改良后的方案再高并发场景下线程上下文切换的开销就变得很大。
public class DemoServer extends Thread { private ServerSocket serverSocket; public int getPort() { return serverSocket.getLocalPort(); } public void run() { try { serverSocket = new ServerSocket(8888); while (true) { Socket socket = serverSocket.accept(); RequestHandler requestHandler = new RequestHandler(socket); requestHandler.start(); } } catch (IOException e) { e.printStackTrace(); } finally { if (serverSocket != null) { try { serverSocket.close(); } catch (IOException e) { e.printStackTrace(); } } } } public static void main(String[] args) throws IOException { DemoServer server = new DemoServer(); server.start(); } } // 简化实现,不做读取,直接发送字符串 class RequestHandler extends Thread { private Socket socket; RequestHandler(Socket socket) { this.socket = socket; } @Override public void run() { try (PrintWriter out = new PrintWriter(socket.getOutputStream());) { out.println("Hello world!"); out.flush(); } catch (Exception e) { e.printStackTrace(); } } } -
这时我们可以利用NIO的多路复用机制进一步优化:通过Selector.open()创建一个Selector,作为类似调度员的角色,然后,通过ServerSocketChannel.open()创建一个 ServerSocketChannel,绑定到8888端口,明确设置非阻塞模式,然后才能将ServerSocketChannel注册到Selector,通过SelectionKey.OP_ACCEPT告诉Selector我们要关注的是连接请求。然后我们再while循环中轮询selector.accept()查看是否有就绪的Channel,如果有那我们做个简单的回应比如说通过SocketChannel和Buffer发送回"HelloWorld"。显然NIO的多路复用机制避免了高并发场景下线程切换的开销问题。
public class NIOServer extends Thread { public void run() { try (Selector selector = Selector.open(); ServerSocketChannel serverSocket = ServerSocketChannel.open();) {// 创建 Selector 和 Channel serverSocket.bind(new InetSocketAddress(InetAddress.getLocalHost(), 8888)); serverSocket.configureBlocking(false); // 注册到 Selector,并说明关注点 serverSocket.register(selector, SelectionKey.OP_ACCEPT); while (true) { selector.select();// select不会阻塞,但是我们轮询会阻塞等待就绪的 Channel,这是关键点之一 Set<SelectionKey> selectedKeys = selector.selectedKeys(); Iterator<SelectionKey> iter = selectedKeys.iterator(); while (iter.hasNext()) { SelectionKey key = iter.next(); sayHelloWorld((ServerSocketChannel) key.channel()); iter.remove(); } } } catch (IOException e) { e.printStackTrace(); } } private void sayHelloWorld(ServerSocketChannel server) throws IOException { try (SocketChannel client = server.accept();) { client.write(Charset.defaultCharset().encode("Hello world!")); } } // 省略了与前面类似的 main } -
Java7中引入的AIO还可以利用事件和回调,异步处理Accept、Read等操作,为此我们要实现CompletionHandler接口中的Comleted()和Failed()方法,当触发事件的时候自动的执行对应的方法。
AsynchronousServerSocketChannel serverSock = AsynchronousServerSocketChannel.open().bind(sockAddr); serverSock.accept(serverSock, new CompletionHandler<>() { // 为异步操作指定 CompletionHandler 回调函数 @Override public void completed(AsynchronousSocketChannel sockChannel, AsynchronousServerSocketChannel serverSock) { serverSock.accept(serverSock, this); // 另外一个 write(sock,CompletionHandler{}) sayHelloWorld(sockChannel, Charset.defaultCharset().encode("Hello World!")); } // 省略其他路径处理方法... });
29.强引用、软引用、弱引用、虚引用是什么,有什么区别?
强引用:在程序中普遍存在的引用赋值,类似Object obj = new Object()这种引用关系。只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
软引用:如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。
//软引用
SoftReference<String> softRef = new SoftReference<String>(str);
弱引用:在进行垃圾回收时,不管当前内存空间足够与否,都会回收只具有弱引用的对象。
//弱引用
WeakReference<String> weakRef = new WeakReference<String>(str);
虚引用:虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。虚引用主要是为了能在对象被收集器回收时收到一个系统通知。
30.直接赋值、浅拷贝、深拷贝区别
=直接赋值:对于基本数据类型,实际上是拷贝它的值;对于引用类型而言,其实赋值的只是这个对象的引用,将原对象的引用传递过去,原对象和新的对象实际上还是指向同一个对象。
Person中有int n和Address address,Person a指向一个person的实例对象,b=a,那么b和a的n和address是互相影响的。
浅拷贝:创建一个新对象,然后将当前对象的非静态字段复制到该新对象,如果字段是基础数据类型的就执行复制;如果字段是引用类型就复制引用但不复制的对象。要实现浅拷贝的类,就要实现Cloneable接口和clone()方法。
Person中有int n和Address address,Person a指向一个person的实例对象,b=a,那么b和a的n互不影响,但是address是互相影响的。
@Override
protected Object clone() throws CloneNotSupportedException {
return super.clone();
}
深拷贝:会拷贝所有的属性,相比于浅拷贝,他会像dfs一样把这个对象有关联的所有对象也复制了。要实现深拷贝的话,第一种方式需要把相关的每个类的Cloneable接口和clone()方法都实现了,这种方式对于层次比较深的类代码量较大,第二种方式就是通过将对象序列化为字节序列后再通过反序列化即可进行深拷贝。
31.权限修饰符
private < default < protected < public
| 权限 | 类内 | 同包 | 不同包子类 | 不同包非子类 |
|---|---|---|---|---|
| private | √ | × | × | × |
| default | √ | √ | × | × |
| protected | √ | √ | √ | × |
| public | √ | √ | √ | √ |
32.volatile
volatile自身的特性
- 可见性:对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。volatile修饰变量对可见性的影响所产生的价值远远高于变量本身,线程A写volatile变量后,线程B读该变量,那么所有A执行写操作执行可见的共享变量的值,在B读取volatile变量后也成为对B可见的了。
- 有序性:volatile会通过禁止指令重排序来保证有序性
- 原子性:对任意单个volatile变量的读/写具有原子性(哪怕它是64位的long或者double),但像volatile++这种复合操作不具有原子性
volatile的内存可见性实现原理
导致内存不可见的原因是 线程的本地缓存和内存之间的值不一致导致的,而volatile的读写实现了 缓存一致性(MESI协议) ,其底层原因是因为volatile变量进行写操作时会多一行Lock前缀的汇编代码,使得:
-
当前CPU缓存行的数据写回到系统内存
-
并通过总线让其他CPU里缓存了该内存地址的本地缓存无效(I)
-
追问:如何通过总线让其他缓存了该内存的CPU本地缓存无效(I)?
答:每个CPU会通过嗅探总线上的数据来查看本地缓存的数据是否过期,一旦CPU发现本地缓存对应的内存被修改,就会将本地缓存设为 无效(I)状态,此后CPU要再想获取这个数据就必须重新填充本地缓存,彼时会将缓存行标记为 共享(S)状态。
volatile的有序性实现原理
导致有序性问题的原因是 指令重排序,而volatile变量会使编译器再生成字节码时插入内存屏障来禁止指令重排序。
-
内存屏障的作用是保证特定操作的执行顺序:
- 对于Volatile变量进行写操作时,会在写操作后加上一个store屏障指令,将本地缓存中的共享变量值立刻刷新到内存中,并且不会将store屏障之前的代码排在store屏障之后
- 对于Volatile变量进行读操作时,会在读操作前面加上一个load屏障指令,马上读取主内存中的数据,并且不会将load屏障之后的代码排在load屏障之前
单例模式中如何使用Volatile?
追问:工作中哪里用到Volatile了?
答:在多线程下保证单例模式,volatile关键字必不可少,否则即使使用DCL双检锁也会由于指令重排序导致有序性问题,可能引发空指针异常。
手写一个volatile的单例模式
把这个 volatile+DCL的单例模式 更新到手写Spring的项目里面去,更新简历
volatile+DCL双检锁可以实现线程安全的单例模式,但是不代表单例是安全的。
追问:那Spring容器的bean是线程安全的吗?
答:Spring容器本身并没有为bean提供线程安全的策略。
-
默认情况下,bean的Scope是单例的,
- 如果单例bean是一个无状态的bean,线程只能对它做查询操作,那这个bean是安全的,例如SpringMVC中的Controller、Service和Dao;
- 如果是有状态的bean,那在并发环境下就会导致竞态条件(原子性问题)和数据竞争(可见性问题) ,就不是线程安全的。
-
原型bean不会产生竞争,所以是线程安全的。
public class VolatileSingleton {
/**
* 私有化构造方法、只会构造一次
*/
private VolatileSingleton(){
System.out.println("构造方法");
}
private static volatile VolatileSingleton instance = null;
public static VolatileSingleton getInstance(){
if(instance == null){
synchronized (VolatileSingleton.class){
if(instance == null){
instance = new VolatileSingleton();
}
}
}
return instance;
}
public static void main(String[] args) {
// new 30个线程,观察构造方法一共被调用几次
for (int i = 0; i < 30; i++) {
new Thread(()->{
VolatileSingleton.getInstance();
}).start();
}
// 输出:构造方法
}
}