JAVA 核心技术36问

By timebusker on February 25, 2020
谈谈你对 Java 平台的理解?“Java 是解释执行”,这句话正确吗?

ava 本身是一种面向对象的语言,最显著的特性有两个方面,一是所谓的“书写一次,到处运 行”(Write once, run anywhere),能够非常容易地获得跨平台能力;另外就是垃圾收集 (GC, Garbage Collection),Java 通过垃圾收集器(Garbage Collector)回收分配内存, 大部分情况下,程序员不需要自己操心内存的分配和回收。

我们日常会接触到 JRE(Java Runtime Environment)或者 JDK(Java Development Kit)。 JRE,也就是 Java 运行环境,包含了 JVM 和 Java 类库,以及一些模块等。而 JDK 可以看作是 JRE 的一个超集,提供了更多工具,比如编译器、各种诊断工具等。

对于“Java 是解释执行”这句话,这个说法不太准确。我们开发的 Java 的源代码,首先通过 Javac 编译成为字节码(bytecode),然后,在运行时,通过 Java 虚拟机(JVM)内嵌的解释 器将字节码转换成为最终的机器码。但是常见的 JVM,比如我们大多数情况使用的 Oracle JDK 提供的 Hotspot JVM,都提供了 JIT(Just-In-Time)编译器,也就是通常所说的动态编译器, JIT 能够在运行时将热点代码编译成机器码,这种情况下部分热点代码就属于编译执行,而不是 解释执行了。

请对比 Exception 和 Error,另外,运行时异常与一般异常有什么区别?

Exception 和 Error 都是继承了 Throwable 类,在 Java 中只有 Throwable 类型的实例才可以 被抛出(throw)或者捕获(catch),它是异常处理机制的基本组成类型。

Exception 和 Error 体现了 Java 平台设计者对不同异常情况的分类。Exception 是程序正常运 行中,可以预料的意外情况,可能并且应该被捕获,进行相应处理。

Error 是指在正常情况下,不大可能出现的情况,绝大部分的 Error 都会导致程序(比如 JVM 自身)处于非正常的、不可恢复状态。既然是非正常情况,所以不便于也不需要捕获,常见的比 如 OutOfMemoryError 之类,都是 Error 的子类。

Exception 又分为可检查(checked)异常和不检查(unchecked)异常,可检查异常在源代码 里必须显式地进行捕获处理,这是编译期检查的一部分。前面我介绍的不可查的 Error,是 Throwable 不是 Exception。

不检查异常就是所谓的运行时异常,类似 NullPointerException、 ArrayIndexOutOfBoundsException 之类,通常是可以编码避免的逻辑错误,具体根据需要来 判断是否需要捕获,并不会在编译期强制要求。

谈谈final、finally、 finalize有什么不同?

final 可以用来修饰类、方法、变量,分别有不同的意义,final 修饰的 class 代表不可以继承扩 展,final 的变量是不可以修改的,而 final 的方法也是不可以重写的(override)。

finally 则是 Java 保证重点代码一定要被执行的一种机制。我们可以使用 try-finally 或者 try- catch-finally 来进行类似关闭 JDBC 连接、保证 unlock 锁等动作。

finalize 是基础类 java.lang.Object 的一个方法,它的设计目的是保证对象在被垃圾收集前完成 特定资源的回收。finalize 机制现在已经不推荐使用,并且在 JDK 9 开始被标记为 deprecated。

强引用、软引用、弱引用、幻象引用有什么区别?具体使用场景是什么?

不同的引用类型,主要体现的是对象不同的可达性(reachable)状态和对垃圾收集的影响。

所谓强引用(”Strong” Reference),就是我们最常见的普通对象引用,只要还有强引用指向 一个对象,就能表明对象还“活着”,垃圾收集器不会碰这种对象。对于一个普通的对象,如果 没有其他的引用关系,只要超过了引用的作用域或者显式地将相应(强)引用赋值为 null,就是 可以被垃圾收集的了,当然具体回收时机还是要看垃圾收集策略。

软引用(SoftReference),是一种相对强引用弱化一些的引用,可以让对象豁免一些垃圾收 集,只有当 JVM 认为内存不足时,才会去试图回收软引用指向的对象。JVM 会确保在抛出 OutOfMemoryError 之前,清理软引用指向的对象。软引用通常用来实现内存敏感的缓存,如 果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时, 不会耗尽内存。

弱引用(WeakReference)并不能使对象豁免垃圾收集,仅仅是提供一种访问在弱引用状态下 对象的途径。这就可以用来构建一种没有特定约束的关系,比如,维护一种非强制性的映射关 系,如果试图获取时对象还在,就使用它,否则重现实例化。它同样是很多缓存实现的选择。

对于幻象引用,有时候也翻译成虚引用,你不能通过它访问对象。幻象引用仅仅是提供了一种确 保对象被 finalize 以后,做某些事情的机制,比如,通常用来做所谓的 Post-Mortem 清理机 制,我在专栏上一讲中介绍的 Java 平台自身 Cleaner 机制等,也有人利用幻象引用监控对象的 创建和销毁。

理解 Java 的字符串,String、StringBuffer、StringBuilder 有什么区别?

String 是 Java 语言非常基础和重要的类,提供了构造和管理字符串的各种基本逻辑。它是典型 的 final 类,被声明成为 final class,所有属性也都是 final 的。也由于它的不可变性, 类似拼接、裁剪字符串等动作,都会产生新的 String 对象。由于字符串操作的普遍性,所以相 关操作的效率往往对应用性能有明显影响。

StringBuffer 是为解决上面提到拼接产生太多中间对象的问题而提供的一个类,它是 Java 1.5 中新增的,我们可以用 append 或者 add 方法,把字符串添加到已有序列的末尾或者指定位 置。StringBuffer 本质是一个线程安全的可修改字符序列,线程安全是通过把各种修改数据的方法都加上 synchronized 关键字实现的,非常直白, 它保证了线程安全,也随之带来了额外的性能开销,所以除非有线程安全的需要,不然还是推荐使用它的后继者,也就是StringBuilder。

为了实现修改字符序列的目的,StringBuffer 和 StringBuilder 底层都是利用可修改的(char, JDK 9 以后是 byte)数组,二者都继承了 AbstractStringBuilder,里面包含了基本操作,区别 仅在于最终的方法是否加了 synchronized。

StringBuilder 在能力上和 StringBuffer 没有本质区别,但是它去掉了线程安全的部分,有效减 小了开销,是绝大部分情况下进行字符串拼接的首选。

(反射机制)动态代理是基于什么原理?

反射机制是 Java 语言提供的一种基础功能,赋予程序在运行时自省(introspect,官方用语) 的能力。通过反射我们可以直接操作类或者对象,比如获取某个对象的类定义,获取类声明的属 性和方法,调用方法或者构造对象,甚至可以运行时修改类定义。

动态代理是一种方便运行时动态构建代理、动态处理代理方法调用的机制,很多场景都是利用类 似机制做到的,比如用来包装 RPC 调用、面向切面的编程(AOP)。

实现动态代理的方式很多,比如 JDK 自身提供的动态代理,就是主要利用了上面提到的反射机 制。还有其他的实现方式,比如利用传说中更高性能的字节码操作机制,类似 ASM、cglib(基 于 ASM)、Javassist 等。

Class、Field、Method、Constructor 等,这些完全就是我 们去操作类和对象的元数据对应。反射提供的 AccessibleObject.setAccessible (boolean flag)。可以理解成修饰成员的 public、protected、private,这意味着我们可以在运行时修改成员访问限制!

int 和 Integer 有什么区别?谈谈 Integer 的值缓存范围。

int 是我们常说的整形数字,是 Java 的 8 个原始数据类型(Primitive Types,boolean、byte 、short、char、int、float、double、long)之一。Java 语言虽然号称一切都是对象,但原始 数据类型是例外。

Integer 是 int 对应的包装类,它有一个 int 类型的字段存储数据,并且提供了基本操作,比如 数学运算、int 和字符串之间转换等。在 Java 5 中,引入了自动装箱和自动拆箱功能 (boxing/unboxing),Java 可以根据上下文,自动进行转换,极大地简化了相关编程。

关于 Integer 的值缓存,这涉及 Java 5 中另一个改进。构建 Integer 对象的传统方式是直接调 用构造器,直接 new 一个对象。但是根据实践,我们发现大部分数据操作都是集中在有限的、 较小的数值范围,因而,在 Java 5 中新增了静态工厂方法 valueOf,在调用它的时候会利用一 个缓存机制,带来了明显的性能改进。按照 Javadoc,这个值默认缓存是 -128 到 127 之间

自动装箱实际上算是一种语法糖。什么是语法糖?可以简单理解为 Java 平台为我们自动进行了 一些转换,保证不同的写法在运行时等价,它们发生在编译阶段,也就是生成的字节码是一致 的。

对比Vector、ArrayList、LinkedList有何区别?

三者都是实现集合框架中的 List,也就是所谓的有序集合,因此具体功能也比较近似,比如都 提供按照位置进行定位、添加或者删除的操作,都提供迭代器以遍历其内容等。但因为具体的设 计区别,在行为、性能、线程安全等方面,表现又有很大不同。

Verctor 是 Java 早期提供的线程安全的动态数组,如果不需要线程安全,并不建议选择,毕竟 同步是有额外开销的。Vector 内部是使用对象数组来保存数据,可以根据需要自动的增加容 量,当数组已满时,会创建新的数组,并拷贝原有数组数据。

ArrayList 是应用更加广泛的动态数组实现,它本身不是线程安全的,所以性能要好很多。与 Vector 近似,ArrayList 也是可以根据需要调整容量,不过两者的调整逻辑有所区别,Vector 在扩容时会提高 1 倍,而 ArrayList 则是增加 50%

LinkedList 顾名思义是 Java 提供的双向链表,所以它不需要像上面两种那样调整容量,它也不 是线程安全的

对比 Hashtable、HashMap、TreeMap 有什么不同?

Hashtable、HashMap、TreeMap 都是最常见的一些 Map 实现,是以键值对的形式存储和操 作数据的容器类型。

Hashtable 是早期 Java 类库提供的一个哈希表实现,本身是同步的,不支持 null 键和值,由 于同步导致的性能开销,所以已经很少被推荐使用。

HashMap 是应用更加广泛的哈希表实现,行为上大致上与 HashTable 一致,主要区别在于 HashMap 不是同步的,支持 null 键和值等。通常情况下,HashMap 进行 put 或者 get 操 作,可以达到常数时间的性能,所以它是绝大部分利用键值对存取场景的首选,比如,实现一个 用户 ID 和用户信息对应的运行时存储结构。

TreeMap 则是基于红黑树的一种提供顺序访问的 Map,和 HashMap 不同,它的 get、put、 remove 之类操作都是 O(log(n))的时间复杂度,具体顺序可以由指定的 Comparator 来决 定,或者根据键的自然顺序来判断。

如何保证集合是线程安全的? ConcurrentHashMap如何实现高效地线程安全?

Java 提供了不同层面的线程安全支持。在传统集合框架内部,除了 Hashtable 等同步容器,还 提供了所谓的同步包装器(Synchronized Wrapper),我们可以调用 Collections 工具类提供 的包装方法,来获取一个同步的包装容器(如 Collections.synchronizedMap),但是它们都 是利用非常粗粒度的同步方式,在高并发情况下,性能比较低下。

另外,更加普遍的选择是利用并发包提供的线程安全容器类,它提供了:

  • 各种并发容器,比如 ConcurrentHashMap、CopyOnWriteArrayList。
  • 各种线程安全队列(Queue/Deque),如 ArrayBlockingQueue、SynchronousQueue。
  • 各种有序容器的线程安全版本等。

具体保证线程安全的方式,包括有从简单的 synchronize 方式,到基于更加精细化的,比如基 于分离锁实现的 ConcurrentHashMap 等并发实现等。具体选择要看开发的场景需求,总体来 说,并发包内提供的容器通用场景,远优于早期的简单同步实现。

ConcurrentHashMap 的设计实现其实一直在演化,比如在 Java 8 中就发 生了非常大的变化(Java 7 其实也有不少更新),所以,我这里将比较分析结构、实现机制等 方面,对比不同版本的主要区别。

Java提供了哪些IO方式? NIO如何实现多路复用?

Java IO 方式有很多种,基于不同的 IO 抽象模型和交互方式,可以进行简单区分。

首先,传统的 java.io 包,它基于流模型实现,提供了我们最熟知的一些 IO 功能,比如 File 抽 象、输入输出流等。交互方式是同步、阻塞的方式,也就是说,在读取输入流或者写入输出流 时,在读、写动作完成之前,线程会一直阻塞在那里,它们之间的调用是可靠的线性顺序。

java.io 包的好处是代码比较简单、直观,缺点则是 IO 效率和扩展性存在局限性,容易成为应用 性能的瓶颈。

很多时候,人们也把 java.net 下面提供的部分网络 API,比如 Socket、ServerSocket、 HttpURLConnection 也归类到同步阻塞 IO 类库,因为网络通信同样是 IO 行为。

第二,在 Java 1.4 中引入了 NIO 框架(java.nio 包),提供了 Channel、Selector、Buffer 等 新的抽象,可以构建多路复用的、同步非阻塞 IO 程序,同时提供了更接近操作系统底层的高性 能数据操作方式。

第三,在 Java 7 中,NIO 有了进一步的改进,也就是 NIO 2,引入了异步非阻塞 IO 方式,也 有很多人叫它 AIO(Asynchronous IO)。异步 IO 操作基于事件和回调机制,可以简单理解 为,应用操作直接返回,而不会阻塞在那里,当后台处理完成,操作系统会通知相应线程进行后 续工作。

Java有几种文件拷贝方式?哪一种最高效?

Java 有多种比较典型的文件拷贝实现方式,比如: 利用 java.io 类库,直接为源文件构建一个 FileInputStream 读取,然后再为目标文件构建一个 FileOutputStream,完成写入工作。或者,利用 java.nio 类库提供的 transferTo 或 transferFrom 方法实现。 当然,Java 标准类库本身已经提供了几种 Files.copy 的实现。

对于 Copy 的效率,这个其实与操作系统和配置等情况相关,总体上来说,NIO transferTo/From 的方式可能更快,因为它更能利用现代操作系统底层机制,避免不必要拷贝 和上下文切换。

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

接口和抽象类是 Java 面向对象设计的两个基础机制。

接口是对行为的抽象,它是抽象方法的集合,利用接口可以达到 API 定义和实现分离的目的。 接口,不能实例化;不能包含任何非常量成员,任何 field 都是隐含着 public static final 的意 义;同时,没有非静态方法实现,也就是说要么是抽象方法,要么是静态方法。Java 标准类库 中,定义了非常多的接口,比如 java.util.List。

抽象类是不能实例化的类,用 abstract 关键字修饰 class,其目的主要是代码重用。除了不能实 例化,形式上和一般的 Java 类并没有太大区别,可以有一个或者多个抽象方法,也可以没有抽 象方法。抽象类大多用于抽取相关 Java 类的共用方法实现或者是共同成员变量,然后通过继承 的方式达到代码复用的目的。Java 标准库中,比如 collection 框架,很多通用部分就被抽取成 为抽象类,例如 java.util.AbstractList。

谈谈你知道的设计模式?请手动实现单例模式,Spring 等框架中使用了哪些模式?

大致按照模式的应用目标分类,设计模式可以分为创建型模式、结构型模式和行为型模式。

  • 创建型模式,是对对象创建过程的各种问题和解决方案的总结,包括各种工厂模式 (Factory、Abstract Factory)、单例模式(Singleton)、构建器模式(Builder)、原型 模式(ProtoType)。

  • 结构型模式,是针对软件设计结构的总结,关注于类、对象继承、组合方式的实践经验。常 见的结构型模式,包括桥接模式(Bridge)、适配器模式(Adapter)、装饰者模式 (Decorator)、代理模式(Proxy)、组合模式(Composite)、外观模式(Facade)、 享元模式(Flyweight)等。

  • 行为型模式,是从类或对象之间交互、职责划分等角度总结的模式。比较常见的行为型模式 有策略模式(Strategy)、解释器模式(Interpreter)、命令模式(Command)、观察者 模式(Observer)、迭代器模式(Iterator)、模板方法模式(Template Method)、访问 者模式(Visitor)。

public class Singleton {

	private Singleton(){
	}
	
	public static Singleton getSingleton(){
		return Holder.singleton;
	}
	private static class Holder {
		private static Singleton singleton = new Singleton();
	}
}
synchronized和ReentrantLock有什么区别呢?

synchronized 是 Java 内建的同步机制,所以也有人称其为 Intrinsic Locking,它提供了互斥 的语义和可见性,当一个线程已经获取当前锁时,其他试图获取的线程只能等待或者阻塞在那 里。

在 Java 5 以前,synchronized 是仅有的同步手段,在代码中, synchronized 可以用来修饰方 法,也可以使用在特定的代码块儿上,本质上 synchronized 方法等同于把方法全部语句用 synchronized 块包起来。

ReentrantLock,通常翻译为再入锁,是 Java 5 提供的锁实现,它的语义和 synchronized 基 本相同。再入锁通过代码直接调用 lock() 方法获取,代码书写也更加灵活。与此同时, ReentrantLock 提供了很多实用的方法,能够实现很多 synchronized 无法做到的细节控制, 比如可以控制 fairness,也就是公平性,或者利用定义条件等。但是,编码中也需要注意,必须 要明确调用 unlock() 方法释放,不然就会一直持有该锁。

synchronized 和 ReentrantLock 的性能不能一概而论,早期版本 synchronized 在很多场景下 性能相差较大,在后续版本进行了较多改进,在低竞争场景中表现可能优于 ReentrantLock。

synchronized底层如何实现?什么是锁的升级、降级?

synchronized 代码块是由一对儿monitorenter/monitorexit 指令实现的,Monitor 对象是同步的基本实现单元。 在 Java 6 之前,Monitor 的实现完全是依靠操作系统内部的互斥锁,因为需要进行用户态到内 核态的切换,所以同步操作是一个无差别的重量级操作。

现代的(Oracle)JDK 中,JVM 对此进行了大刀阔斧地改进,提供了三种不同的 Monitor 实 现,也就是常说的三种不同的锁:偏斜锁(Biased Locking)、轻量级锁和重量级锁,大大改 进了其性能。

所谓锁的升级、降级,就是 JVM 优化 synchronized 运行的机制,当 JVM 检测到不同的竞争 状况时,会自动切换到适合的锁实现,这种切换就是锁的升级、降级。

当没有竞争出现时,默认会使用偏斜锁。JVM 会利用 CAS 操作(compare and swap),在对 象头上的 Mark Word 部分设置线程 ID,以表示这个对象偏向于当前线程,所以并不涉及真正 的互斥锁。这样做的假设是基于在很多应用场景中,大部分对象生命周期中最多会被一个线程锁 定,使用偏斜锁可以降低无竞争开销。

如果有另外的线程试图锁定某个已经被偏斜过的对象,JVM 就需要撤销(revoke)偏斜锁,并 切换到轻量级锁实现。轻量级锁依赖 CAS 操作 Mark Word 来试图获取锁,如果重试成功,就 使用普通的轻量级锁;否则,进一步升级为重量级锁。

我注意到有的观点认为 Java 不会进行锁降级。实际上据我所知,锁降级确实是会发生的,当 JVM 进入安全点(SafePoint)的时候,会检查是否有闲置的 Monitor,然后试图进行降级。

一个线程两次调用start()方法会出现什么情况?

thread 线程实例化时已经设定了线程状态,不能多次启动!

Java 的线程是不允许启动两次的,第二次调用必然会抛出 IllegalThreadStateException,这是 一种运行时异常,多次调用 start 被认为是编程错误。

关于线程生命周期的不同状态,在 Java 5 以后,线程状态被明确定义在其公共内部枚举类型 java.lang.Thread.State 中,分别是:

  • 新建(NEW),表示线程被创建出来还没真正启动的状态,可以认为它是个 Java 内部状 态。

  • 就绪(RUNNABLE),表示该线程已经在 JVM 中执行,当然由于执行需要计算资源,它可 能是正在运行,也可能还在等待系统分配给它 CPU 片段,在就绪队列里面排队。

  • 在其他一些分析中,会额外区分一种状态 RUNNING,但是从 Java API 的角度,并不能表示 出来。

  • 阻塞(BLOCKED),这个状态和我们前面两讲介绍的同步非常相关,阻塞表示线程在等待 Monitor lock。比如,线程试图通过 synchronized 去获取某个锁,但是其他线程已经独占 了,那么当前线程就会处于阻塞状态。

  • 等待(WAITING),表示正在等待其他线程采取某些操作。一个常见的场景是类似生产者消 费者模式,发现任务条件尚未满足,就让当前消费者线程等待(wait),另外的生产者线程 去准备任务数据,然后通过类似 notify 等动作,通知消费线程可以继续工作了。 Thread.join() 也会令线程进入等待状态。

  • 计时等待(TIMED_WAIT),其进入条件和等待状态类似,但是调用的是存在超时条件的方 法,比如 wait 或 join 等方法的指定超时版本。

  • 终止(TERMINATED),不管是意外退出还是正常执行结束,线程已经完成使命,终止运 行,也有人把这个状态叫作死亡。

在第二次调用 start() 方法的时候,线程可能处于终止或者其他(非 NEW)状态,但是不论如 何,都是不可以再次启动的。

什么情况下 Java 程序会产生死锁?如何定位、修复?

死锁是一种特定的程序状态,在实体之间,由于循环依赖导致彼此一直处于等待之中,没有任何 个体可以继续前进。死锁不仅仅是在线程之间会发生,存在资源独占的进程之间同样也可能出现 死锁。通常来说,我们大多是聚焦在多线程场景中的死锁,指两个或多个线程之间,由于互相持 有对方需要的锁,而永久处于阻塞的状态。

定位死锁最常见的方式就是利用 jstack 等工具获取线程栈,然后定位互相之间的依赖关系,进 而找到死锁。如果是比较明显的死锁,往往 jstack 等就能直接定位,类似 JConsole 甚至可以 在图形界面进行有限的死锁检测。

如果程序运行时发生了死锁,绝大多数情况下都是无法在线解决的,只能重启、修正程序本身问 题。所以,代码开发阶段互相审查,或者利用工具进行预防性排查,往往也是很重要的。

Java并发包提供了哪些并发工具类?

通常所说的并发包也就是 java.util.concurrent 及其子包,集中了 Java 并发的各种基础工 具类,具体主要包括几个方面:

提供了比 synchronized 更加高级的各种同步结构,包括 CountDownLatch、 CyclicBarrier、Sempahore 等,可以实现更加丰富的多线程操作,比如利用 Semaphore 作 为资源控制器,限制同时进行工作的线程数量。

各种线程安全的容器,比如最常见的 ConcurrentHashMap、有序的 ConcunrrentSkipListMap,或者通过类似快照机制,实现线程安全的动态数组CopyOnWriteArrayList 等。

各种并发队列实现,如各种 BlockedQueue 实现,比较典型的 ArrayBlockingQueue、 SynchorousQueue 或针对特定场景的 PriorityBlockingQueue 等。

强大的 Executor 框架,可以创建各种不同类型的线程池,调度任务运行等,绝大部分情况 下,不再需要自己从头实现线程池和任务调度器。

并发包中的ConcurrentLinkedQueue和LinkedBlockingQueue有什么区别?

有时候我们把并发包下面的所有容器都习惯叫作并发容器,但是严格来讲,类似 ConcurrentLinkedQueue 这种“Concurrent*”容器,才是真正代表并发。

关于问题中它们的区别:

  • Concurrent 类型基于 lock-free,在常见的多线程访问场景,一般可以提供较高吞吐量。
  • 而 LinkedBlockingQueue 内部则是基于锁,并提供了 BlockingQueue 的等待性方法。

java.util.concurrent 包提供的容器(Queue、List、Set)、Map,从 命名上可以大概区分为 Concurrent、CopyOnWrite和 Blocking* 等三类,同样是线程安全容 器,可以简单认为:

Concurrent 类型没有类似 CopyOnWrite 之类容器相对较重的修改开销。

但是,凡事都是有代价的,Concurrent 往往提供了较低的遍历一致性。你可以这样理解所谓 的弱一致性,例如,当利用迭代器遍历时,如果容器发生修改,迭代器仍然可以继续进行遍 历。

与弱一致性对应的,就是我介绍过的同步容器常见的行为“fast-fail”,也就是检测到容器在 遍历过程中发生了修改,则抛出 ConcurrentModificationException,不再继续遍历。

弱一致性的另外一个体现是,size 等操作准确性是有限的,未必是 100% 准确。

与此同时,读取的性能具有一定的不确定性。

Java并发类库提供的线程池有哪几种? 分别有什么特点?

Executors 目前提供了 5 种不同的线程池创建配置:

  • newCachedThreadPool(),它是一种用来处理大量短时间工作任务的线程池,具有几个鲜明 特点:它会试图缓存线程并重用,当无缓存线程可用时,就会创建新的工作线程;如果线程 闲置的时间超过 60 秒,则被终止并移出缓存;长时间闲置时,这种线程池,不会消耗什么 资源。其内部使用 SynchronousQueue 作为工作队列。

  • newFixedThreadPool(int nThreads),重用指定数目(nThreads)的线程,其背后使用的 是无界的工作队列,任何时候最多有 nThreads 个工作线程是活动的。这意味着,如果任务 数量超过了活动队列数目,将在工作队列中等待空闲线程出现;如果有工作线程退出,将会 有新的工作线程被创建,以补足指定的数目 nThreads。

  • newSingleThreadExecutor(),它创建的是个 ScheduledExecutorService,也就是可以进 行定时或周期性的工作调度。工作线程数目被限制为 1,所以它保证了所有任务的都是被顺 序执行,最多会有一个任务处于活动状态,并且不允许使用者改动线程池实例,因此可以避 免其改变线程数目。

  • newScheduledThreadPool(int corePoolSize),同样是 ScheduledExecutorService,区别 在于它会保持 corePoolSize 个工作线程。

  • newWorkStealingPool(int parallelism),这是一个经常被人忽略的线程池,Java 8 才加入 这个创建方法,其内部会构建ForkJoinPool,利用Work-Stealing算法,并行地处理任务, 不保证处理顺序。

AtomicInteger底层实现原理是什么?如何在自己的产品代码中应用CAS操作?

AtomicIntger 是对 int 类型的一个封装,提供原子性的访问和更新操作,其原子性操作的实现 是基于 CAS(compare-and-swap)技术。

所谓 CAS,表征的是一些列操作的集合,获取当前数值,进行一些运算,利用 CAS 指令试图进 行更新。如果当前数值未变,代表没有其他线程进行并发修改,则成功更新。否则,可能出现不 同的选择,要么进行重试,要么就返回一个成功或者失败的结果。

从 AtomicInteger 的内部属性可以看出,它依赖于 Unsafe 提供的一些底层能力,进行底层操 作;以 volatile 的 value 字段,记录数值,以保证可见性。

具体的原子操作细节,可以参考任意一个原子更新方法,比如下面的 getAndIncrement。 Unsafe 会利用 value 字段的内存地址偏移,直接完成操作。

因为 getAndIncrement 需要返归数值,所以需要添加失败重试逻辑。

而类似 compareAndSet 这种返回 boolean 类型的函数,因为其返回值表现的就是成功与否, 所以不需要重试。

请介绍类加载过程,什么是双亲委派模型?

一般来说,我们把 Java 的类加载过程分为三个主要步骤:加载、链接、初始化,具体行为在 Java 虚拟机规范里有非常详细的定义。

首先是加载阶段(Loading),它是 Java 将字节码数据从不同的数据源读取到 JVM 中,并映 射为 JVM 认可的数据结构(Class 对象),这里的数据源可能是各种各样的形态,如 jar 文 件、class 文件,甚至是网络数据源等;如果输入数据不是 ClassFile 的结构,则会抛出 ClassFormatError。

加载阶段是用户参与的阶段,我们可以自定义类加载器,去实现自己的类加载过程。 第二阶段是链接(Linking),这是核心的步骤,简单说是把原始的类定义信息平滑地转化入 JVM 运行的过程中。这里可进一步细分为三个步骤:

  • 验证(Verification),这是虚拟机安全的重要保障,JVM 需要核验字节信息是符合 Java 虚 拟机规范的,否则就被认为是 VerifyError,这样就防止了恶意信息或者不合规的信息危害 JVM 的运行,验证阶段有可能触发更多 class 的加载。

  • 准备(Preparation),创建类或接口中的静态变量,并初始化静态变量的初始值。但这里 的“初始化”和下面的显式初始化阶段是有区别的,侧重点在于分配所需要的内存空间,不 会去执行更进一步的 JVM 指令。

  • 解析(Resolution),在这一步会将常量池中的符号引用(symbolic reference)替换为直 接引用。在Java 虚拟机规范中,详细介绍了类、接口、方法和字段等各个方面的解析。

最后是初始化阶段(initialization),这一步真正去执行类初始化的代码逻辑,包括静态字段赋 值的动作,以及执行类定义中的静态初始化块内的逻辑,编译器在编译阶段就会把这部分逻辑整 理好,父类型的初始化逻辑优先于当前类型的逻辑。

双亲委派模型,简单说就是当类加载器(Class-Loader)试图加载某个类型的时候, 除非父加载器找不到相应类型,否则尽量将这个任务代理给当前加载器的父加载器去做。使用委 派模型的目的是避免重复加载 Java 类型。

有哪些方法可以在运行时动态生成一个 Java 类?

我们可以从常见的 Java 类来源分析,通常的开发过程是,开发者编写 Java 代码,调用 javac 编译成 class 文件,然后通过类加载机制载入 JVM,就成为应用运行时可以使用的 Java 类了。 从上面过程得到启发,其中一个直接的方式是从源码入手,可以利用 Java 程序生成一段源码, 然后保存到文件等,下面就只需要解决编译问题了。

有一种笨办法,直接用 ProcessBuilder 之类启动 javac 进程,并指定上面生成的文件作为输 入,进行编译。最后,再利用类加载器,在运行时加载即可。

  • JIT动态编译器,

  • Java Compiler API

谈谈 JVM 内存区域的划分,哪些区域可能发生OutOfMemoryError?
  • 程序计数器(PC,Program Counter Register)。在 JVM 规范中,每个线程都有它自 己的程序计数器,并且任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序 计数器会存储当前线程正在执行的 Java 方法的 JVM 指令地址;或者,如果是在执行本地方 法,则是未指定值(undefined)。

  • Java 虚拟机栈(Java Virtual Machine Stack),早期也叫 Java 栈。每个线程在创建时 都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次的 Java 方 法调用。栈帧中存储着局部变量表、操作数(operand)栈、动态链接、方法正常退出或者异常退出的定 义等。

在一个时间点,对应的只会有一个活动的栈帧, 通常叫作当前帧,方法所在的类叫作当前类。如果在该方法中调用了其他方法,对应的新的栈帧 会被创建出来,成为新的当前帧,一直到它返回结果或者执行结束。JVM 直接对 Java 栈的操作 只有两个,就是对栈帧的压栈和出栈。

  • 堆(Heap),它是 Java 内存管理的核心区域,用来放置 Java 对象实例,几乎所有创建 的 Java 对象实例都是被直接分配在堆上。堆被所有的线程共享,在虚拟机启动时,我们指定 的“Xmx”之类参数就是用来指定最大堆空间等指标。

堆也是垃圾收集器重点照顾的区域,所以堆内空间还会被不同的垃圾收集器进行进一 步的细分,最有名的就是新生代、老年代的划分。

  • 方法区(Method Area)。这也是所有线程共享的一块内存区域,用于存储所谓的元 (Meta)数据,例如类结构信息,以及对应的运行时常量池、字段、方法代码等。 很多人习惯于将方法区称为永久代(PermanentGeneration)。Oracle JDK 8 中将永久代移除,同时增加了元数据区(Metaspace)。

  • 运行时常量池(Run-Time Constant Pool),这是方法区的一部分。如果仔细分析过反 编译的类文件结构,你能看到版本号、字段、方法、超类、接口等各种信息,还有一项信息就是 常量池。Java 的常量池可以存放各种常量信息,不管是编译期生成的各种字面量,还是需要在 运行时决定的符号引用,所以它比一般语言的符号表存储的信息更加宽泛。

  • 本地方法栈(Native Method Stack)。它和 Java 虚拟机栈是非常相似的,支持对本地 方法的调用,也是每个线程都会创建一个。在 Oracle Hotspot JVM 中,本地方法栈和 Java 虚 拟机栈是在同一块儿区域,这完全取决于技术实现的决定,并未在规范中强制。

除了程序计数器,其他区域都有可能会因为可能的空间不足发生OutOfMemoryError

如何监控和诊断 JVM 堆内和堆外内存使用?
  • 可以使用综合性的图形化工具,如 JConsole、VisualVM(注意,从 Oracle JDK 9 开始, VisualVM 已经不再包含在 JDK 安装包中)等。这些工具具体使用起来相对比较直观,直接 连接到 Java 进程,然后就可以在图形化界面里掌握内存使用情况。 以 JConsole 为例,其内存页面可以显示常见的堆内存和各种堆外部分使用状态。

  • 可以使用命令行工具进行运行时查询,如 jstat 和 jmap 等工具都提供了一些选项,可以查看堆、方法区等使用数据。

  • 可以使用 jmap 等提供的命令,生成堆转储(Heap Dump)文件,然后利用 jhat或 Eclipse MAT 等堆转储分析工具进行详细分析。

  • 如果你使用的是 Tomcat、Weblogic 等 Java EE 服务器,这些服务器同样提供了内存管理相关的功能。

  • 从某种程度上来说,GC 日志等输出,同样包含着丰富的信息。

Java常见的垃圾收集器有哪些?

实际上,垃圾收集器(GC,Garbage Collector)是和具体 JVM 实现紧密相关的,不同厂商 (IBM、Oracle),不同版本的 JVM,提供的选择也不同。接下来,我来谈谈最主流的 Oracle JDK。

  • Serial GC,它是最古老的垃圾收集器,“Serial”体现在其收集工作是单线程的,并且在进 行垃圾收集过程中,会进入臭名昭著的“Stop-The-World”状态。当然,其单线程设计也 意味着精简的 GC 实现,无需维护复杂的数据结构,初始化也简单,所以一直是 Client 模式 下 JVM 的默认选项。

  • ParNew GC,很明显是个新生代 GC 实现,它实际是 Serial GC 的多线程版本,最常见的应用场景是配合老年代的 CMS GC 工作。

  • CMS(Concurrent Mark Sweep) GC,基于标记 - 清除(Mark-Sweep)算法,设计目标 是尽量减少停顿时间,这一点对于 Web 等反应时间敏感的应用非常重要,一直到今天,仍 然有很多系统使用 CMS GC。但是,CMS 采用的标记 - 清除算法,存在着内存碎片化问 题,所以难以避免在长时间运行等情况下发生 full GC,导致恶劣的停顿。另外,既然强调了 并发(Concurrent),CMS 会占用更多 CPU 资源,并和用户线程争抢。

  • Parrallel GC,在早期 JDK 8 等版本中,它是 server 模式 JVM 的默认 GC 选择,也被称作 是吞吐量优先的 GC。它的算法和 Serial GC 比较相似,尽管实现要复杂的多,其特点是新生 代和老年代 GC 都是并行进行的,在常见的服务器环境中更加高效。

  • G1 GC 这是一种兼顾吞吐量和停顿时间的 GC 实现,是 Oracle JDK 9 以后的默认 GC 选 项。G1 可以直观的设定停顿时间的目标,相比于 CMS GC,G1 未必能做到 CMS 在最好情 况下的延时停顿,但是最差情况要好很多。G1 GC 仍然存在着年代的概念,但是其内存结构并不是简单的条带式划分,而是类似棋盘的 一个个 region。Region 之间是复制算法,但整体上实际可看作是标记 - 整理(Mark- Compact)算法,可以有效地避免内存碎片,尤其是当 Java 堆非常大的时候,G1 的优势更 加明显。

谈谈你的GC调优思路?

基本的调优思路可以总结为:

  • 理解应用需求和问题,确定调优目标。假设,我们开发了一个应用服务,但发现偶尔会出现 性能抖动,出现较长的服务停顿。评估用户可接受的响应时间和业务量,将目标简化为,希 望 GC 暂停尽量控制在 200ms 以内,并且保证一定标准的吞吐量。

  • 掌握 JVM 和 GC 的状态,定位具体的问题,确定真的有 GC 调优的必要。具体有很多方法, 比如,通过 jstat 等工具查看 GC 等相关状态,可以开启 GC 日志,或者是利用操作系统提供 的诊断工具等。例如,通过追踪 GC 日志,就可以查找是不是 GC 在特定时间发生了长时间 的暂停,进而导致了应用响应不及时。

  • 选择的 GC 类型是否符合我们的应用特征,如果是,具体问题表现在哪里, 是 Minor GC 过长,还是 Mixed GC 等出现异常停顿情况;如果不是,考虑切换到什么类 型,如 CMS 和 G1 都是更侧重于低延迟的 GC 选项。

  • 通过分析确定具体调整的参数或者软硬件配置。

  • 验证是否达到调优目标,如果达到目标,即可以考虑结束调优;否则,重复完成分析、调整、验证这个过程。

Java内存模型中的happen-before是什么?

Happen-before 关系,是 Java 内存模型中保证多线程操作可见性的机制,也是对早期语言规 范中含糊的可见性概念的一个精确定义。

具体表现形式,包括但远不止是我们直觉中的 synchronized、volatile、lock 操作顺序等方面,例如:

  • 线程内执行的每个操作,都保证 happen-before 后面的操作,这就保证了基本的程序顺序 规则,这是开发者在书写程序时的基本约定。

  • 对于一个锁的解锁操作,保证 happen-before 加锁操作。

  • 对象构建完成,保证 happen-before 于 finalizer 的开始动作。

  • 甚至是类似线程内部操作的完成,保证 happen-before 其他 Thread.join() 的线程等。

Java 程序运行在 Docker 等容器环境有哪些新问题?
你了解Java应用开发中的注入攻击吗?

注入式(Inject)攻击是一类非常常见的攻击方式,其基本特征是程序允许攻击者将不可信的动 态内容注入到程序中,并将其执行,这就可能完全改变最初预计的执行过程,产生恶意效果。

  • 最常见的 SQL 注入攻击
Select * from use_info where username = input_usr_name and password = input_pwd

Select * from use_info where username = input_usr_name and password = “” or “” = “”
如何写出安全的 Java 代码?
后台服务出现明显“变慢”,谈谈你的诊断思路?

首先,需要对这个问题进行更加清晰的定义:

  • 服务是突然变慢还是长时间运行后观察到变慢?类似问题是否重复出现?

  • “慢”的定义是什么,我能够理解是系统对其他方面的请求的反应延时变长吗?

第二,理清问题的症状,这更便于定位具体的原因,有以下一些思路:

  • 问题可能来自于 Java 服务自身,也可能仅仅是受系统里其他服务的影响。初始判断可以先确 认是否出现了意外的程序错误,例如检查应用本身的错误日志。

  • 监控 Java 服务自身,例如 GC 日志里面是否观察到 Full GC 等恶劣情况出现,或者是否 Minor GC 在变长等;利用 jstat 等工具,获取内存使用的统计信息也是个常用手段;利用 jstack 等工具检查是否出现死锁等。

  • 如果还不能确定具体问题,对应用进行 Profiling 也是个办法,但因为它会对系统产生侵入 性,如果不是非常必要,大多数情况下并不建议在生产系统进行。

  • 定位了程序错误或者 JVM 配置的问题后,就可以采取相应的补救措施,然后验证是否解决, 否则还需要重复上面部分过程。

有人说“Lambda 能让 Java 程序慢 30 倍”,你怎么看?
JVM优化Java代码时都做了什么?

JVM 在对代码执行的优化可分为运行时(runtime)优化和即时编译器(JIT)优化。运行时优 化主要是解释执行和动态编译通用的一些机制,比如说锁机制(如偏斜锁)、内存分配机制(如 TLAB)等。除此之外,还有一些专门用于优化解释执行效率的,比如说模版解释器、内联缓存 (inline cache,用于优化虚方法调用的动态绑定)。

JVM 的即时编译器优化是指将热点代码以方法为单位转换成机器码,直接运行在底层硬件之 上。它采用了多种优化方式,包括静态编译器可以使用的如方法内联、逃逸分析,也包括基于程 序运行 profile 的投机性优化(speculative/optimistic optimization)。这个怎么理解呢?比 如我有一条 instanceof 指令,在编译之前的执行过程中,测试对象的类一直是同一个,那么即 时编译器可以假设编译之后的执行过程中还会是这一个类,并且根据这个类直接返回 instanceof 的结果。如果出现了其他类,那么就抛弃这段编译后的机器码,并且切换回解释执 行。

当然,JVM 的优化方式仅仅作用在运行应用代码的时候。如果应用代码本身阻塞了,比如说并 发时等待另一线程的结果,这就不在 JVM 的优化范畴啦。

谈谈MySQL支持的事务隔离级别,以及悲观锁和乐观锁的原理和应用场景?

所谓隔离级别(Isolation Level),就是在数据库事务中,为保证并发数据读写的正确性而提出 的定义,它并不是 MySQL 专有的概念,而是源于ANSI/ISO制定的SQL-92标准。

每种关系型数据库都提供了各自特色的隔离级别实现,虽然在通常的定义中是以锁为实现单元, 但实际的实现千差万别。以最常见的 MySQL InnoDB 引擎为例,它是基于 MVCC(Multi-Versioning Concurrency Control)和锁的复合实现,按照隔离程度从低到高,MySQL 事务隔 离级别分为四个不同层次:

  • 读未提交(Read uncommitted),就是一个事务能够看到其他事务尚未提交的修改,这是最低的隔离水平,允许脏读出现。

  • 读已提交(Read committed),事务能够看到的数据都是其他事务已经提交的修改,也就 是保证不会看到任何中间性状态,当然脏读也不会出现。读已提交仍然是比较低级别的隔 离,并不保证再次读取时能够获取同样的数据,也就是允许其他事务并发修改数据,允许不 可重复读和幻象读(Phantom Read)出现。

  • 可重复读(Repeatable reads),保证同一个事务中多次读取的数据是一致的,这是 MySQL InnoDB 引擎的默认隔离级别,但是和一些其他数据库实现不同的是,可以简单认为 MySQL 在可重复读级别不会出现幻象读。

  • 串行化(Serializable),并发事务之间是串行化的,通常意味着读取需要获取共享读锁,更 新需要获取排他写锁,如果 SQL 使用 WHERE 语句,还会获取区间锁(MySQL 以 GAP 锁 形式实现,可重复读级别中默认也会使用),这是最高的隔离级别。

至于悲观锁和乐观锁,也并不是 MySQL 或者数据库中独有的概念,而是并发编程的基本概念。 主要区别在于,操作共享数据时,“悲观锁”即认为数据出现冲突的可能性更大,而“乐观 锁”则是认为大部分情况不会出现冲突,进而决定是否采取排他性措施。

反映到 MySQL 数据库应用开发中,悲观锁一般就是利用类似 SELECT … FOR UPDATE 这样的 语句,对数据加锁,避免其他事务意外修改数据。乐观锁则与 Java 并发包中的 AtomicFieldUpdater 类似,也是利用 CAS 机制,并不会对数据加锁,而是通过对比数据的时 间戳或者版本号,来实现乐观锁需要的版本判断。

前面提到的 MVCC,其本质就可以看作是种乐观锁机制,而排他性的读写锁、双阶段锁 等则是悲观锁的实现。

构建一下简化的火车余票查询和购票系统。同时查询的人可能很 多,虽然具体座位票只能是卖给一个人,但余票可能很多,而且也并不能预测哪个查询者会购 票,这个时候就更适合用乐观锁。

谈谈Spring Bean的生命周期和作用域?

Spring Bean 生命周期比较复杂,可以分为创建和销毁两个过程。

首先,创建 Bean 会经过一系列的步骤,主要包括:

  • 实例化 Bean 对象。
  • 设置 Bean 属性。
  • 如果我们通过各种 Aware 接口声明了依赖关系,则会注入 Bean 对容器基础设施层面的依赖。具体包括 BeanNameAware、BeanFactoryAware 和 ApplicationContextAware,分别会注入 Bean ID、Bean Factory 或者 ApplicationContext。
  • 调用 BeanPostProcessor 的前置初始化方法 postProcessBeforeInitialization。
  • 如果实现了 InitializingBean 接口,则会调用 afterPropertiesSet 方法。
  • 调用 Bean 自身定义的 init 方法。
  • 调用 BeanPostProcessor 的后置初始化方法 postProcessAfterInitialization。
  • 创建过程完毕

第二,Spring Bean 的销毁过程会依次调用 DisposableBean 的 destroy 方法和 Bean 自身定 制的 destroy 方法。

Spring Bean 有五个作用域,其中最基础的有下面两种:

  • Singleton,这是 Spring 的默认作用域,也就是为每个 IOC 容器创建唯一的一个 Bean 实例。

  • Prototype,针对每个 getBean 请求,容器都会单独创建一个 Bean 实例。

从 Bean 的特点来看,Prototype 适合有状态的 Bean,而 Singleton 则更适合无状态的情况。 另外,使用 Prototype 作用域需要经过仔细思考,毕竟频繁创建和销毁 Bean 是有明显开销 的。

如果是 Web 容器,则支持另外三种作用域:

  • Request,为每个 HTTP 请求创建单独的 Bean 实例。
  • Session,很显然 Bean 实例的作用域是 Session 范围。
  • GlobalSession,用于 Portlet 容器,因为每个 Portlet 有单独的 Session,GlobalSession提供一个全局性的 HTTP Session。