一次次的挂于一面让我筋疲力竭…意识到自己存在眼高手低的状态…准备重新上路夯实基础
本片文章Fork from android-interview

Java

谈谈对Java多态的理解?

多态是指父类的某个方法被子类重写时,可以产生自己的功能行为,同一个操作作用于不同对象,可以有不同的解释,产生不同的执行结果。

多态的三个必要条件:

  1. 继承父类。
  2. 重写父类的方法。
  3. 父类的引用指向子类对象。

静态方法与静态成员变量可以被继承吗,为什么?

静态方法与静态成员变量可以被继承,但是不能被重写。它对子类隐藏,因此静态方法也不能实现多态。

为什么Java里的匿名内部类只能访问final修饰的外部变量?

匿名内部类用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class TryUsingAnonymousClass {
public void useMyInterface() {
final Integer number = 123;
System.out.println(number);
MyInterface myInterface = new MyInterface() {
@Override
public void doSomething() {
System.out.println(number);
}
};
myInterface.doSomething();
System.out.println(number);
}
}

编译后的结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class TryUsingAnonymousClass$1
implements MyInterface {
private final TryUsingAnonymousClass this$0;
private final Integer paramInteger;
TryUsingAnonymousClass$1(TryUsingAnonymousClass this$0, Integer paramInteger) {
this.this$0 = this$0;
this.paramInteger = paramInteger;
}
public void doSomething() {
System.out.println(this.paramInteger);
}
}

因为匿名内部类最终用会编译成一个单独的类,而被该类使用的变量会以构造函数参数的形式传递给该类,例如:Integer paramInteger,如果变量
不定义成final的,paramInteger在匿名内部类被可以被修改,进而造成和外部的paramInteger不一致的问题,为了避免这种不一致的情况,因为Java
规定匿名内部类只能访问final修饰的外部变量。

讲一下Java的编码方式?

为什么需要编码

计算机存储信息的最小单元是一个字节即8bit,所以能表示的范围是0~255,这个范围无法保存所有的字符,所以需要一个新的数据结构char来表示这些字符,从char到byte需要编码。

常见的编码方式有以下几种:

  • ASCII:总共有 128 个,用一个字节的低 7 位表示,0~31 是控制字符如换行回车删除等;32~126 是打印字符,可以通过键盘输入并且能够显示出来。
  • GBK:码范围是 8140~FEFE(去掉 XX7F)总共有 23940 个码位,它能表示 21003 个汉字,它的编码是和 GB2312 兼容的,也就是说用 GB2312 编码的汉字可以用 GBK 来解码,并且不会有乱码。
  • UTF-16:UTF-16 具体定义了 Unicode 字符在计算机中存取方法。UTF-16 用两个字节来表示 Unicode 转化格式,这个是定长的表示方法,不论什么字符都可以用两个字节表示,两个字节是 16 个 bit,所以叫 UTF-16。UTF-16 表示字符非常方便,每两个字节表示一个字符,这个在字符串操作时就大大简化了操作,这也是 Java 以 UTF-16 作为内存的字符存储格式的一个很重要的原因。
  • UTF-8:统一采用两个字节表示一个字符,虽然在表示上非常简单方便,但是也有其缺点,有很大一部分字符用一个字节就可以表示的现在要两个字节表示,存储空间放大了一倍,在现在的网络带宽还非常有限的今天,这样会增大网络传输的流量,而且也没必要。而 UTF-8 采用了一种变长技术,每个编码区域有不同的字码长度。不同类型的字符可以是由 1~6 个字节组成。

Java中需要编码的地方一般都在字符到字节的转换上,这个一般包括磁盘IO和网络IO。

Reader 类是 Java 的 I/O 中读字符的父类,而 InputStream 类是读字节的父类,InputStreamReader 类就是关联字节到字符的桥梁,它负责在 I/O 过程中处理读取字节到字符的转换,而具体字节到字符的解码实现它由 StreamDecoder 去实现,在 StreamDecoder 解码过程中必须由用户指定 Charset 编码格式。

静态代理与动态代理区别是什么,分别用在什么样的场景里?

静态代理与动态代理的区别在于代理类生成的时间不同,如果需要对多个类进行代理,并且代理的功能都是一样的,用静态代理重复编写代理类就非常的麻烦,可以用动态代理动态的生成代理类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 为目标对象生成代理对象
public Object getProxyInstance() {
return Proxy.newProxyInstance(target.getClass().getClassLoader(), target.getClass().getInterfaces(),
new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("开启事务");
// 执行目标对象方法
Object returnValue = method.invoke(target, args);
System.out.println("提交事务");
return null;
}
});
}

描述一下Java的异常体系?

  1. Error是程序无法处理的错误,比如OutOfMemoryError、ThreadDeath等。这些异常发生时, Java虚拟机(JVM)一般会选择线程终止。
  2. Exception是程序本身可以处理的异常,这种异常分两大类运行时异常和非运行时异常,程序中应当尽可能去处理这些异常。运行时异常都是RuntimeException类及其子类异常,如NullPointerException、IndexOutOfBoundsException等,
    这些异常是不检查异常,程序中可以选择捕获处理,也可以不处理。这些异常一般是由程序逻辑错误引起的, 程序应该从逻辑角度尽可能避免这类异常的发生。

描述一个类的加载过程?

Person person = new Person()

  1. 查找Person.class,并加载到内存中。
  2. 执行类里的静态代码块。
  3. 在堆内存里开辟内存空间,并分配内存地址。
  4. 在堆内存里建立对象的属性,并进行默认的初始化。
  5. 对属性进行显示初始化。
  6. 对对象进行构造代码块初始化。
  7. 调用对象的构造函数进行初始化。
  8. 将对象的地址赋值给person变量。

Java对象的生命周期是什么?

  1. 加载:将类的信息加载到JVM的方法区,然后在堆区中实例化一个java.lang.Class对象,作为方法去中这个类的信息入口。
  2. 连接:验证:验证类是否合法。准备:为静态变量分配内存并设置JVM默认值,非静态变量不会分配内存。解析:将常量池里的符号引用转换为直接引用。
  3. 初始化:初始化类的静态赋值语句和静态代码块,主动引用会被触发类的初始化,被动引用不会触发类的初始化。
  4. 使用:执行类的初始化,主动引用会被触发类的初始化,被动引用不会触发类的初始化。
  5. 卸载:卸载过程就是清楚堆里类的信息,以下情况会被卸载:① 类的所有实例都已经被回收。② 类的ClassLoader被回收。③ 类的CLass对象没有被任何地方引用,无法在任何地方通过
    反射访问该类。

描述一下类的加载机制?

类的加载就是虚拟机通过一个类的全限定名来获取描述此类的二进制字节流,而完成这个加载动作的就是类加载器。

类和类加载器息息相关,判定两个类是否相等,只有在这两个类被同一个类加载器加载的情况下才有意义,否则即便是两个类来自同一个Class文件,被不同类加载器加载,它们也是不相等的。

注:这里的相等性包含Class对象的equals()方法、isAssignableFrom()方法、isInstance()方法的返回结果以及Instance关键字对对象所属关系的判定结果等。

类加载器可以分为三类:

  • 启动类加载器(Bootstrap ClassLoader):负责加载\lib目录下或者被-Xbootclasspath参数所指定的路径的,并且是被虚拟机所识别的库到内存中。
  • 扩展类加载器(Extension ClassLoader):负责加载\lib\ext目录下或者被java.ext.dirs系统变量所指定的路径的所有类库到内存中。
  • 应用类加载器(Application ClassLoader):负责加载用户类路径上的指定类库,如果应用程序中没有实现自己的类加载器,一般就是这个类加载器去加载应用程序中的类库。

这么多类加载器,那么当类在加载的时候会使用哪个加载器呢?🤔

这个时候就要提到类加载器的双亲委派模型,流程图如下所示:

双亲委派模型的整个工作流程非常的简单,如下所示:

如果一个类加载器收到了加载类的请求,它不会自己立即去加载类,它会先去请求父类加载器,每个层次的类加载器都是如此。层层传递,直到传递到最高层的类加载器,只有当
父类加载器反馈自己无法加载这个类,才会有当前子类加载器去加载该类。

关于双亲委派机制,在ClassLoader源码里也可以看出,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public abstract class ClassLoader {
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
//首先,检查该类是否已经被加载
Class c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
//先调用父类加载器去加载
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) {
//如果父类加载器没有加载到该类,则自己去执行加载
long t1 = System.nanoTime();
c = findClass(name);
// this is the defining class loader; record the stats
}
}
return c;
}
}

为什么要这么做呢?🤔

这是为了要让越基础的类由越高层的类加载器加载,例如Object类,无论哪个类加载器去尝试加载这个类,最终都会传递给最高层的类加载器去加载,前面我们也说过,类的相等性是由
类与其类加载器共同判定的,这样Object类无论在何种类加载器环境下都是同一个类。

相反如果没有双亲委派模型,那么每个类加载器都会去加载Object,那么系统中就会出现多个不同的Object类了,如此一来系统的最基础的行为也就无法保证了。

描述一下GC的原理和回收策略?

提到垃圾回收,我们可以先思考一下,如果我们去做垃圾回收需要解决哪些问题? 🤔

一般说来,我们要解决以下三个问题:

  • 哪些内存回收?
  • 什么时候回收?
  • 如何回收?

这些问题分别对应着引用管理和回收策略等方案。

提到引用,我们都知道Java中有四种引用类型:

  • 强引用:代码中普遍存在的,只要强引用还存在,垃圾收集器就不会回收掉被引用的对象。
  • 软引用:SoftReference,用来描述还有用但是非必须的对象,当内存不足的时候回回收这类对象。
  • 弱引用:WeakReference,用来描述非必须对象,弱引用的对象只能生存到下一次GC发生时,当GC发生时,无论内存是否足够,都会回收该对象。
  • 虚引用:PhantomReference,一个对象是否有虚引用的存在,完全不会对其生存时间产生影响,也无法通过虚引用取得一个对象的引用,它存在的唯一目的是在这个对象被回收时可以收到一个系统通知。

不同的引用类型,在做GC时会区别对待,我们平时生成的Java对象,默认都是强引用,也就是说只要强引用还在,GC就不会回收,那么如何判断强引用是否存在呢?🤔

一个简单的思路就是:引用计数法,有对这个对象的引用就+1,不再引用就-1,但是这种方式看起来简单美好,但它却不嫩解决循环引用计数的问题。

因此可达性分析算法登上历史舞台😎,用它来判断对象的引用是否存在。

可达性分析算法通过一系列称为GC Roots的对象作为起始点,从这些节点从上向下搜索,搜索走过的路径称为引用链,当一个对象没有任何引用链
与GC Roots连接时就说明此对象不可用,也就是对象不可达。

GC Roots对象通常包括:

  • 虚拟机栈中引用的对象(栈帧中的本地变量表)
  • 方法去中类的静态属性引用的对象
  • 方法区中常量引用的对象
  • Native方法引用的对象

可达性分析算法整个流程如下所示:

  1. 第一次标记:对象在经过可达性分析后发现没有与GC Roots有引用链,则进行第一次标记并进行一次筛选,筛选条件是:该对象是否有必要执行finalize()方法。没有覆盖finalize()方法或者finalize()方法已经被执行过都会被
    认为没有必要执行
    • 如果有必要执行:则该对象会被放在一个F-Queue队列,并稍后在由虚拟机建立的低优先级Finalizer线程中触发该对象的finalize()方法,但不保证一定等待它执行结束,因为如果这个对象的finalize()方法发生了死循环或者执行
      时间较长的情况,会阻塞F-Queue队列里的其他对象,影响GC。
  2. 第二次标记:GC对F-Queue队列里的对象进行第二次标记,如果在第二次标记时该对象又成功被引用,则会被移除即将回收的集合,否则会被回收。

接口和抽象类有什么区别?

共同点

  1. 是上层的抽象层。
  2. 都不能被实例化
  3. 都能包含抽象的方法,这些抽象的方法用于描述类具备的功能,但是不会提供具体的实现。

区别

  1. 在抽象类中可以写非抽象的方法,从而避免在子类中重复书写他们,这样可以提高代码的复用性,这是抽象类的优势;接口中只能有抽象的方法。
  2. 一个类只能继承一个直接父类,这个父类可以是具体的类也可是抽象类;但是一个类可以实现多个接口。

问:抽象类是否可继承实体类 (concrete class)
答: 抽象类是可以继承实体类,但前提是实体类必须有明确的构造函数

答案很明确,可以继承。其实从Object就是个实体类,java的API文档里,每个抽象类的条目里都明确写着直接或间接继承自Object,所以这点是没有疑问的。
关键在于这答案里所说的“前提是实体类必须有明确的构造函数”一句,是什么意思。
一般学习者会写的简单试验代码:

1
2
3
class A{}
abstract class B extends A{}

结果完全正常,编译通过。似乎和“实体类必须有明确的构造函数”完全没有关系。

这个问题涉及到两个个基础知识:

  1. 所有的class都必须有一个构造方法,如果你没有在代码里声明构造方法,系统会自动给你生成一个公有无参的构造方法。而只要你自己声明了一个构造方法,无论有参无参,私有公有,系统就不再帮你生成默认无参构造器了。

  2. 所有的子类构造器都要求在第一行代码中调用父类构造器,如果不写,系统默认去调用父类的无参构造器。

所以,如果把系统默认配给的方法也算进去,class A{}的代码实际上是

1
2
3
4
5
class A{
public A(){}
}

B继承 A 的时候,则是

1
2
3
4
5
6
7
8
9
abstract class B extends A{
public B(){
super();
}
}

要试验出这继承规则的内部情况,也很简单,在最上面那个简单试验代码里,加上个私有构造器,有参无参都行。

1
2
3
4
5
class A{
private A(){}
}

这个时候,如基础知识(1) 中所说,系统不再给你默认无参构造器, B的构造器根据(2)中的规则去调用super(),却找不到A的无参构造器,所以导致abstract class B extends A{} 编译不能通过。(因为A中没有任何构造器可供子类调用,其实这个时候A只能够供内部类继承,我用的Eclipse的3.4版本会建议给B改名,但是这解决不了这个问题。)

现在,你应该了解了资料给的那句语焉不详的“实体类必须有明确的构造函数”的含义:

  1. 没写构造器的,那是拥有默认无参公有构造函数的,子类可以什么都不写,让默认构造器去调用它。这是最初那两行代码的情况。

  2. 写了子类可访问的无参构造器的,也是一样,子类里可以什么都不写,用默认机制调用。

  3. 写了 有参构造器却没写无参构造器的,父类里没有子类可访问的无参构造器,子类必须在子类构造器里的第一句写明,调用父类有参构造器,并把参数传进去。

  4. 声明为final的以及所有构造器都不在子类访问权限之内的类无法继承

其实只要是在类的继承中,无论抽象还是实体,都需要符合这个规则的。在这个继承试验中随时删掉或是加上abstract的前缀,结果都没有变化。个人觉得“实体类必须有明确的构造函数”一句实在是无法把这个情况表达清楚,所以广大求职者还是写得清楚些好。

我喜欢的写法是“可以继承,但是和实体类的继承一样,也要求父类可继承,并且拥有子类可访问到的构造器。”


内部类、静态内部类在业务中的应用场景是什么?

1
2
3
4
5
6
7
8
static class Outer {
class Inner {}
static class StaticInner {}
}
Outer outer = new Outer();
Outer.Inner inner = outer.new Inner();
Outer.StaticInner inner0 = new Outer.StaticInner();
  • 静态内部类:只是为了降低包的深度,方便类的使用,静态内部类适用于包含类当中,但又不依赖与外在的类,不用使用外在类的非静态属性和方法,只是为了方便管理类结构而定义。在创建静态内部类的时候,不需要外部类对象的引用。
  • 非静态内部类:持有外部类的引用,可以自由使用外部类的所有变量和方法

synchronized与ReentrantLock有什么区别?

synchronized是互斥同步的一种实现。

synchronized:当某个线程访问被synchronized标记的方法或代码块时,这个线程便获得了该对象的锁,其他线程暂时无法访问这个方法,只有等待这个方法执行完毕或者代码块执行完毕,这个
线程才会释放该对象的锁,其他线程才能执行这个方法或代码块。

前面我们已经说了volatile关键字,这里我们举个例子来综合分析volatile与synchronized关键字的使用。

:point_up: 举个栗子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Singleton {
//volatile保证了:1 instance在多线程并发的可见性
//2 禁止instance在操作时的指令重排序
private volatile static Singleton instance;
public static Singleton getInstance() {
//第一次判空,保证不必要的同步
if (instance == null) {
//synchronized对Singleton加全局锁,保证每次只要一个线程创建实例
synchronized (Singleton.class) {
//第二次判空时为了在null的情况下创建实例
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}

这是一个经典的DCL单例。

它的字节码如下:

可以看到被synchronized同步的代码块,会在前后分别加上monitorenter和monitorexit,这两个字节码都需要指定加锁和解锁的对象。

关于加锁和解锁的对象:

  • synchronized代码块 :同步代码块,作用范围是整个代码块,作用对象是调用这个代码块的对象。
  • synchronized方法 :同步方法,作用范围是整个方法,作用对象是调用这个方法的对象。
  • synchronized静态方法 :同步静态方法,作用范围是整个静态方法,作用对象是调用这个类的所有对象。
  • synchronized(this):作用范围是该对象中所有被synchronized标记的变量、方法或代码块,作用对象是对象本身。
  • synchronized(ClassName.class) :作用范围是静态的方法或者静态变量,作用对象是Class对象。

synchronized(this)添加的是对象锁,synchronized(ClassName.class)添加的是类锁,它们的区别如下:

对象锁:Java的所有对象都含有1个互斥锁,这个锁由JVM自动获取和释放。线程进入synchronized方法的时候获取该对象的锁,当然如果已经有线程获取了这个对象的锁,那么当前线程会等待;synchronized方法正常返回或者抛异常而终止,JVM会自动释放对象锁。这里也体现了用synchronized来加锁的好处,方法抛异常的时候,锁仍然可以由JVM来自动释放。

类锁:对象锁是用来控制实例方法之间的同步,类锁是用来控制静态方法(或静态变量互斥体)之间的同步。其实类锁只是一个概念上的东西,并不是真实存在的,它只是用来帮助我们理解锁定实例方法和静态方法的区别的。我们都知道,java类可能会有很多个对象,但是只有1个Class对象,也就是说类的不同实例之间共享该类的Class对象。Class对象其实也仅仅是1个java对象,只不过有点特殊而已。由于每个java对象都有1个互斥锁,而类的静态方法是需要Class对象。所以所谓的类锁,不过是Class对象的锁而已。获取类的Class对象有好几种,最简单的就是MyClass.class的方式。 类锁和对象锁不是同一个东西,一个是类的Class对象的锁,一个是类的实例的锁。也就是说:一个线程访问静态synchronized的时候,允许另一个线程访问对象的实例synchronized方法。反过来也是成立的,因为他们需要的锁是不同的。

volatile的原理是什么?

volatile也是互斥同步的一种实现,不过它非常的轻量级。

volatile有两条关键的语义:

  • 保证被volatile修饰的变量对所有线程都是可见的
  • 禁止进行指令重排序

要理解volatile关键字,我们得先从Java的线程模型开始说起。如图所示:

Java内存模型规定了所有字段(这些字段包括实例字段、静态字段等,不包括局部变量、方法参数等,因为这些是线程私有的,并不存在竞争)都存在主内存中,每个线程会
有自己的工作内存,工作内存里保存了线程所使用到的变量在主内存里的副本拷贝,线程对变量的操作只能在工作内存里进行,而不能直接读写主内存,当然不同内存之间也
无法直接访问对方的工作内存,也就是说主内存是线程传值的媒介。

我们来理解第一句话:

保证被volatile修饰的变量对所有线程都是可见的

如何保证可见性?🤔

被volatile修饰的变量在工作内存修改后会被强制写回主内存,其他线程在使用时也会强制从主内存刷新,这样就保证了一致性。

关于“保证被volatile修饰的变量对所有线程都是可见的”,有种常见的错误理解:

错误理解:由于volatile修饰的变量在各个线程里都是一致的,所以基于volatile变量的运算在多线程并发的情况下是安全的。

这句话的前半部分是对的,后半部分却错了,因此它忘记考虑变量的操作是否具有原子性这一问题。

举个栗子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private volatile int start = 0;
private void volatileKeyword() {
Runnable runnable = new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10; i++) {
start++;
}
}
};
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(runnable);
thread.start();
}
Log.d(TAG, "start = " + start);
}

这段代码启动了10个线程,每次10次自增,按道理最终结果应该是100,但是结果并非如此。

为什么会这样?🤔

仔细看一下start++,它其实并非一个原子操作,简单来看,它有两步:

  1. 取出start的值,因为有volatile的修饰,这时候的值是正确的。
  2. 自增,但是自增的时候,别的线程可能已经把start加大了,这种情况下就有可能把较小的start写回主内存中。

所以volatile只能保证可见性,在不符合以下场景下我们依然需要通过加锁来保证原子性:

  • 运算结果并不依赖变量当前的值,或者只有单一线程修改变量的值。(要么结果不依赖当前值,要么操作是原子性的,要么只要一个线程修改变量的值)
  • 变量不需要与其他状态变量共同参与不变约束

比方说我们会在线程里加个boolean变量,来判断线程是否停止,这种情况就非常适合使用volatile。

我们再来理解第二句话。

  • 禁止进行指令重排序

什么是指令重排序?🤔

指令重排序是值指令乱序执行,即在条件允许的情况下,直接运行当前有能力立即执行的后续指令,避开为获取下一条指令所需数据而造成的等待,通过乱序执行的技术,提供执行效率。

指令重排序绘制被volatile修饰的变量的赋值操作前,添加一个内存屏障,指令重排序时不能把后面的指令重排序到内存屏障之前的位置。

关于指令重排序不是本篇文章重点讨论的内容,更多细节可以参考指令重排序

如何防止反射、序列化攻击单例?

枚举单例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public enum Singleton {
INSTANCE {
@Override
protected void read() {
System.out.println("read");
}
@Override
protected void write() {
System.out.println("write");
}
};
protected abstract void read();
protected abstract void write();
}

class文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
public abstract class Singleton extends Enum
{
private Singleton(String s, int i)
{
super(s, i);
}
protected abstract void read();
protected abstract void write();
public static Singleton[] values()
{
Singleton asingleton[];
int i;
Singleton asingleton1[];
System.arraycopy(asingleton = ENUM$VALUES, 0, asingleton1 = new Singleton[i = asingleton.length], 0, i);
return asingleton1;
}
public static Singleton valueOf(String s)
{
return (Singleton)Enum.valueOf(singleton/Singleton, s);
}
Singleton(String s, int i, Singleton singleton)
{
this(s, i);
}
public static final Singleton INSTANCE;
private static final Singleton ENUM$VALUES[];
static
{
INSTANCE = new Singleton("INSTANCE", 0) {
protected void read()
{
System.out.println("read");
}
protected void write()
{
System.out.println("write");
}
};
ENUM$VALUES = (new Singleton[] {
INSTANCE
});
}
}
  • 类的修饰abstract,所以没法实例化,反射也无能为力。
  • 关于线程安全的保证,其实是通过类加载机制来保证的,我们看看INSTANCE的实例化时机,是在static块中,JVM加载类的过程显然是线程安全的。
  • 对于防止反序列化生成新实例的问题还不是很明白,一般的方法我们会在该类中添加上如下方法,不过枚举中也没有显示的写明该方法。
1
2
3
4
//readResolve to prevent another instance of Singleton
private Object readResolve(){
return INSTANCE;
}

线程为什么阻塞,为和要使用多线程?

使用多线程更多的是为了提高CPU的并发,可以让CPU同事处理多个事情,多线程场景的使用场景:

  1. 为了不让耗时操作阻塞主线程,开启新线程执行耗时操作。
  2. 某种任务虽然耗时但是不消耗CPU,例如:磁盘IO,可以开启新线程来做,可以显著的提高效率。
  3. 优先级比较低的任务,但是需要经常去做,例如:GC,可以开启新线程来做。

了解线程的生命周期吗,描述一下?

线程状态流程图图

  • NEW:创建状态,线程创建之后,但是还未启动。
  • RUNNABLE:运行状态,处于运行状态的线程,但有可能处于等待状态,例如等待CPU、IO等。
  • WAITING:等待状态,一般是调用了wait()、join()、LockSupport.spark()等方法。
  • TIMED_WAITING:超时等待状态,也就是带时间的等待状态。一般是调用了wait(time)、join(time)、LockSupport.sparkNanos()、LockSupport.sparkUnit()等方法。
  • BLOCKED:阻塞状态,等待锁的释放,例如调用了synchronized增加了锁。
  • TERMINATED:终止状态,一般是线程完成任务后退出或者异常终止。

NEW、WAITING、TIMED_WAITING都比较好理解,我们重点说一说RUNNABLE运行态和BLOCKED阻塞态。

线程进入RUNNABLE运行态一般分为五种情况:

  • 线程调用sleep(time)后查出了休眠时间
  • 线程调用的阻塞IO已经返回,阻塞方法执行完毕
  • 线程成功的获取了资源锁
  • 线程正在等待某个通知,成功的获得了其他线程发出的通知
  • 线程处于挂起状态,然后调用了resume()恢复方法,解除了挂起。

线程进入BLOCKED阻塞态一般也分为五种情况:

  • 线程调用sleep()方法主动放弃占有的资源
  • 线程调用了阻塞式IO的方法,在该方法返回前,该线程被阻塞。
  • 线程视图获得一个资源锁,但是该资源锁正被其他线程锁持有。
  • 线程正在等待某个通知
  • 线程调度器调用suspend()方法将该线程挂起

我们再来看看和线程状态相关的一些方法。

  • sleep()方法让当前正在执行的线程在指定时间内暂停执行,正在执行的线程可以通过Thread.currentThread()方法获取。
  • yield()方法放弃线程持有的CPU资源,将其让给其他任务去占用CPU执行时间。但放弃的时间不确定,有可能刚刚放弃,马上又获得CPU时间片。
  • wait()方法是当前执行代码的线程进行等待,将当前线程放入预执行队列,并在wait()所在的代码处停止执行,知道接到通知或者被中断为止。该方法可以使得调用该方法的线程释放共享资源的锁,然后从运行状态退出,进入等待队列,直到再次被唤醒。该方法只能在同步代码块里调用,否则会抛出IllegalMonitorStateException异常。
  • wait(long millis)方法等待某一段时间内是否有线程对锁进行唤醒,如果超过了这个时间则自动唤醒。
  • notify()方法用来通知那些可能等待该对象的对象锁的其他线程,该方法可以随机唤醒等待队列中等同一共享资源的一个线程,并使该线程退出等待队列,进入可运行状态。
  • notifyAll()方法可以是所有正在等待队列中等待同一共享资源的全部线程从等待状态退出,进入可运行状态,一般会是优先级高的线程先执行,但是根据虚拟机的实现不同,也有可能是随机执行。
  • join()方法可以让调用它的线程正常执行完成后,再去执行该线程后面的代码,它具有让线程排队的作用。

线程池了解吗,有几种线程池,应用场景是什么?

Executors类提供了一系列工厂方法用来创建线程池。这些线程是适用于不同的场景。

  • newCachedThreadPool():无界可自动回收线程池,查看线程池中有没有以前建立的线程,如果有则复用,如果没有则建立一个新的线程加入池中,池中的线程超过60s不活动则自动终止。适用于生命
    周期比较短的异步任务。
  • newFixedThreadPool(int nThreads):固定大小线程池,与newCachedThreadPool()类似,但是池中持有固定数目的线程,不能随时创建线程,如果创建新线程时,超过了固定
    线程数,则放在队列里等待,直到池中的某个线程被移除时,才加入池中。适用于很稳定、很正规的并发线程,多用于服务器。
  • newScheduledThreadPool(int corePoolSize):周期任务线程池,该线程池的线程可以按照delay依次执行线程,也可以周期执行。
  • newSingleThreadExecutor():单例线程池,任意时间内池中只有一个线程。

ThreadLocal的原理了解吗?

ThreadLocal是一个关于创建线程局部变量的类。使用场景如下所示:

  1. 实现单个线程单例以及单个线程上下文信息存储,比如交易id等。
  2. 实现线程安全,非线程安全的对象使用ThreadLocal之后就会变得线程安全,因为每个线程都会有一个对应的实例。
  3. 承载一些线程相关的数据,避免在方法中来回传递参数。

wait和notify机制,手写一下生产者和消费者模型?

生成者消费者模型

生产者和消费者在同一时间段内共用同一个存储空间,生产者往存储空间中添加产品,消费者从存储空间中取走产品,当存储空间为空时,消费者阻塞,当存储空间满时,生产者阻塞。

wait()和notify()方法的实现生成者消费者模型,缓冲区满和为空时都调用wait()方法等待,当生产者生产了一个产品或者消费者消费了一个产品之后会唤醒所有线程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
public class ProducerAndCustomerModel {
private static Integer count = 0;
private static final Integer FULL = 10;
private static String LOCK = "lock";
public static void main(String[] args) {
ProducerAndCustomerModel PACM = new ProducerAndCustomerModel();
new Thread(PACM.new Producer()).start();
new Thread(PACM.new Consumer()).start();
new Thread(PACM.new Producer()).start();
new Thread(PACM.new Consumer()).start();
new Thread(PACM.new Producer()).start();
new Thread(PACM.new Consumer()).start();
new Thread(PACM.new Producer()).start();
new Thread(PACM.new Consumer()).start();
}
class Producer implements Runnable {
@Override
public void run() {
for (int i = 0; i < 10; i++) {
try {
Thread.sleep(3000);
} catch (Exception e) {
e.printStackTrace();
}
synchronized (LOCK) {
while (count == FULL) {
try {
LOCK.wait();
} catch (Exception e) {
e.printStackTrace();
}
}
count++;
System.out.println(Thread.currentThread().getName() + "生产者生产,目前总共有" + count);
LOCK.notifyAll();
}
}
}
}
class Consumer implements Runnable {
@Override
public void run() {
for (int i = 0; i < 10; i++) {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (LOCK) {
while (count == 0) {
try {
LOCK.wait();
} catch (Exception e) {
}
}
count--;
System.out.println(Thread.currentThread().getName() + "消费者消费,目前总共有" + count);
LOCK.notifyAll();
}
}
}
}
}

死锁是如何发生的,如何避免死锁?

当线程A持有独占锁a,并尝试去获取独占锁b的同时,线程B持有独占锁b,并尝试获取独占锁a的情况下,就会发生AB两个线程由于互相持有对方需要的锁,而发生的阻塞现象,我们称为死锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
public class DeadLockDemo {
public static void main(String[] args) {
// 线程a
Thread td1 = new Thread(new Runnable() {
public void run() {
DeadLockDemo.method1();
}
});
// 线程b
Thread td2 = new Thread(new Runnable() {
public void run() {
DeadLockDemo.method2();
}
});
td1.start();
td2.start();
}
public static void method1() {
synchronized (String.class) {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程a尝试获取integer.class");
synchronized (Integer.class) {
}
}
}
public static void method2() {
synchronized (Integer.class) {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程b尝试获取String.class");
synchronized (String.class) {
}
}
}
}

造成死锁的四个条件:

  1. 互斥条件:一个资源每次只能被一个线程使用。
  2. 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放。
  3. 不剥夺条件:线程已获得的资源,在未使用完之前,不能强行剥夺。
  4. 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。

在并发程序中,避免了逻辑中出现复数个线程互相持有对方线程所需要的独占锁的的情况,就可以避免死锁,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
public class BreakDeadLockDemo {
public static void main(String[] args) {
// 线程a
Thread td1 = new Thread(new Runnable() {
public void run() {
BreakDeadLockDemo.method1();
}
});
// 线程b
Thread td2 = new Thread(new Runnable() {
public void run() {
BreakDeadLockDemo.method2();
}
});
td1.start();
td2.start();
}
public static void method1() {
synchronized (String.class) {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程a尝试获取integer.class");
synchronized (Integer.class) {
System.out.println("线程a获取到integer.class");
}
}
}
public static void method2() {
// 不再获取线程a需要的Integer.class锁。
synchronized (String.class) {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程b尝试获取Integer.class");
synchronized (Integer.class) {
System.out.println("线程b获取到Integer.class");
}
}
}
}

了解Java注解的原理吗,注解如何获取?

注解相当于一种标记,在程序中加了注解就等于为程序打上了某种标记。程序可以利用Java的反射机制来了解你的类及各种元素上有无何种标记,针对不同的标记,就去做相应的事件。标记可以加在包,类,字段,方法,方法的参数以及局部变量上。

String为什么要设计成不可变,StringBuffer与StringBuilder有什么区别?

  1. String是不可变的(修改String时,不会在原有的内存地址修改,而是重新指向一个新对象),String用final修饰,不可继承,String本质上是个final的char[]数组,所以char[]数组的内存地址不会被修改,而且String也没有对外暴露修改char[]数组的方法。不可变性可以保证线程安全以及字符串串常量池的实现。
  2. StringBuffer是线程安全的。
  3. StringBuilder是非线程安全的。

Java里的幂等性了解吗?

幂等性原本是数学上的一个概念,即:f(x) = f(f(x)),对同一个系统,使用同样的条件,一次请求和重复的多次请求对系统资源的影响是一致的。

幂等性最为常见的应用就是电商的客户付款,试想一下,如果你在付款的时候因为网络等各种问题失败了,然后又去重复的付了一次,是一种多么糟糕的体验。幂等性就是为了解决这样的问题。

实现幂等性可可以使用Token机制。

核心思想是为每一次操作生成一个唯一性的凭证,也就是token。一个token在操作的每一个阶段只有一次执行权,一旦执行成功则保存执行结果。对重复的请求,返回同一个结果。

例如:电商平台上的订单id就是最适合的token。当用户下单时,会经历多个环节,比如生成订单,减库存,减优惠券等等。每一个环节执行时都先检测一下该订单id是否已经执行过这一步骤,对未执行的请求,执行操作并缓存结果,而对已经执行过的id,则直接返回之前的执行结果,不做任何操作。这样可以在最大程度上避免操作的重复执行问题,缓存起来的执行结果也能用于事务的控制等。

Java泛型了解吗,知道它的运行机制吗?

泛型是为了参数化类型。

为什么使用泛型?

  1. 相对于使用Object这种简单粗暴的方式,泛型提供了一种参数化的能力,使得数据的类型可以像参数一样被传递进来,这提供了一种扩展能力。
  2. 当数据类型确定以后,提供了一种类型检测机制,只有相匹配的数据才可以正常赋值,否则编译错误,增强了安全性。
  3. 泛型提高了代码的可读性,不必等到运行时采取执行类型转换,在编写代码阶段,程序员就可以通过参数书写正确的数据类型。

除了用 表示泛型外,还有 <?> 这种形式。? 被称为通配符。

  • <?> 被称作无限定的通配符。
  • <? extends T> 被称作有上限的通配符。
  • <? super T> 被称作有下限的通配符。

Java里的反射为何会消耗性能?

反射慢主要因为反射是动态类型,这样导致把在zhuang

Java的类型擦除,知道它的原理吗?

泛型信息只存在代码编译阶段,在进入JVM之前,与泛型相关的信息都会被擦除掉。

在类型擦除的时候,如果泛型类里的类型参数没有指定上限,例如:,则会被转成Object类型,如果指定了上限,例如:,则会被传换成对应的类型上限。

闭包了解吗,Java里有闭包吗?

「函数」和「函数内部能访问到的变量」(也叫环境)的总和,就是一个闭包。

1
2
3
4
5
6
7
8
fun main(args: Array<String>) {
test
}
val test = if (5 > 3) {
print("yes")
} else {
print("no")
}

Lambda表达式了解吗?

Lambda 表达式俗称匿名函数。Kotlin 的 Lambda表达式更“纯粹”一点, 因为它是真正把Lambda抽象为了一种类型,而 Java 8 的 Lambda 只是单方法匿名接口实现的语法糖罢了。

1
2
3
4
5
6
7
val printMsg = { msg: String ->
println(msg)
}
fun main(args: Array<String>) {
printMsg("hello")
}

高阶函数了解吗?

当定义一个闭包作为参数的函数,称这个函数为高阶函数。

1
2
3
4
5
6
7
8
9
10
11
fun main(args: Array<String>) {
log("world", printMsg)
}
val printMsg = { str: String ->
println(str)
}
val log = { str: String, printLog: (String) -> Unit ->
printLog(str)
}