面试之java提高-杨晓峰专栏笔记

学习杨晓峰专栏的笔记,39个热门面试题的解读,分享给正在面试或即将面试的小伙伴们

文章目录

      • 1. 谈谈你对java平台的理解
      • 2. 对比Exception和Error,另外,运行时异常与一般异常有什么区别?
      • 3. 谈谈final、 finally、 finalize有什么不同?
      • 4. 强引用、软引用、弱引用、幻象引用有什么区别?具体使用场景是什么?
      • 5. 理解Java的字符串, String、 StringBufer、 StringBuilder有什么区别?
      • 6. 谈谈Java反射机制,动态代理是基于什么原理?
      • 7. int和Integer有什么区别?谈谈Integer的值缓存范围。
      • 8. 对比Vector、 ArrayList、 LinkedList有何区别?
      • 9. 对比Hashtable、 HashMap、 TreeMap有什么不同? 谈谈你对HashMap的掌握
      • 10. 如何保证容器是线程安全的? ConcurrentHashMap如何实现高效地线程安全?
      • 11. Java提供了哪些IO方式? NIO如何实现多路复用?
      • 12. Java有几种文件拷贝方式?哪一种最高效?
      • 13. 谈谈接口和抽象类有什么区别?
      • 14. 谈谈你知道的设计模式?请手动实现单例模式, Spring等框架中使用了哪些模式?
        • 装饰器模式
        • 工厂模式
      • 15. synchronized和ReentrantLock有什么区别?有人说synchronized最慢,这话靠谱吗?
      • 16. synchronized底层如何实现?什么是锁的升级、降级?
      • 17. 一个线程两次调用start()方法会出现什么情况?谈谈线程的生命周期和状态转移。
      • 18. 什么情况下Java程序会产生死锁?如何定位、修复?
      • 19. Java并发包提供了哪些并发工具类?
      • 20. 并发包中的ConcurrentLinkedQueue和LinkedBlockingQueue有什么区别?
      • 21. Java并发类库提供的线程池有哪几种? 分别有什么特点?
      • 22. AtomicInteger底层实现原理是什么?如何在自己的产品代码中应用CAS操作?
      • 23. 请介绍类加载过程,什么是双亲委派模型?
      • 24. 有哪些方法可以在运行时动态生成一个Java类?
      • 25. 谈谈JVM内存区域的划分,哪些区域可能发生OutOfMemoryError?
      • 26. 如何监控和诊断JVM堆内和堆外内存使用?
      • 27. Java常见的垃圾收集器有哪些?
      • 28. 谈谈你的GC调优思路?
      • 29. Java内存模型中的happen-before是什么?
      • 30. Java程序运行在Docker等容器环境有哪些新问题?
      • 31. 你了解Java应用开发中的注入攻击吗?
      • 32. 如何写出安全的Java代码?
      • 33. 后台服务出现明显“变慢”,谈谈你的诊断思路?
      • 34. 有人说“Lambda能让Java程序慢30倍”,你怎么看?
      • 35. JVM优化Java代码时都做了什么?
      • 36. 谈谈MySQL支持的事务隔离级别,以及悲观锁和乐观锁的原理和应用场景?
      • 37. 谈谈Spring Bean的生命周期和作用域?
      • 38. 对比Java标准NIO类库,你知道Netty是如何实现更高性能的吗?
      • 39. 谈谈常用的分布式ID的设计方案? Snowfake是否受冬令时切换影响?
      • 补充

1. 谈谈你对java平台的理解

Java本身是一种面向对象的语言,最显著的特性有两个方面,一是所谓的“书写一次,到处运行”(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能够在运行时将热点代码编译成机器码,这种情况下部分热点代码就属于编译执行,而不是解释执行了。

2. 对比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之类,通常是可以编码避免的逻辑错误,具体根据需要来判断是否需要捕获,并不会在编译期强制要求。

3. 谈谈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。

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

不同的引用类型,主要体现的是对象不同的可达性(reachable)状态和对垃圾收集的影响。
所谓强引用(“Strong” Reference),就是我们最常见的普通对象引用,只要还有强引用指向一个对象,就能表明对象还“活着”,垃圾收集器不会碰这种对象。对于一个普通的对象,如果没有其他的引用关系,只要超过了引用的作用域或者显式地将相应(强)引用赋值为null,就是可以被垃圾收集的了,当然具体回收时机还是要看垃圾收集策略。
软引用(SoftReference),是一种相对强引用弱化一些的引用,可以让对象豁免一些垃圾收集,只有当JVM认为内存不足时,才会去试图回收软引用指向的对象。 JVM会确保在抛出OutOfMemoryError之前,清理软引用指向的对象。软引用通常用来实现内存敏感的缓存,如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓
存的同时,不会耗尽内存。
弱引用(WeakReference)并不能使对象豁免垃圾收集,仅仅是提供一种访问在弱引用状态下对象的途径。这就可以用来构建一种没有特定约束的关系,比如,维护一种非强制性的映射关系,如果试图获取时对象还在,就使用它,否则重现实例化。它同样是很多缓存实现的选择。
对于幻象引用,有时候也翻译成虚引用,你不能通过它访问对象。幻象引用仅仅是提供了一种确保对象被fnalize以后,做某些事情的机制,比如,通常用来做所谓的PostMortem清理机制,我在专栏上一讲中介绍的Java平台自身Cleaner机制等,也有人利用幻象引用监控对象的创建和销毁。

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

String是Java语言非常基础和重要的类,提供了构造和管理字符串的各种基本逻辑。它是典型的Immutable类,被声明成为fnal class,所有属性也都是fnal的。也由于它的不可变性,类似拼接、裁剪字符串等动作,都会产生新的String对象。由于字符串操作的普遍性,所以相关操作的效率往往对应用性能有明显影响。
StringBufer是为解决上面提到拼接产生太多中间对象的问题而提供的一个类,我们可以用append或者add方法,把字符串添加到已有序列的末尾或者指定位置。 StringBufer本质是一个线程安全的可修改字符序列,它保证了线程安全,也随之带来了额外的性能开销,所以除非有线程安全的需要,不然还是推荐使用它的后继者,也就是StringBuilder。
StringBuilder是Java 1.5中新增的,在能力上和StringBufer没有本质区别,但是它去掉了线程安全的部分,有效减小了开销,是绝大部分情况下进行字符串拼接的首选。

6. 谈谈Java反射机制,动态代理是基于什么原理?

反射机制是Java语言提供的一种基础功能,赋予程序在运行时自省(introspect,官方用语)的能力。通过反射我们可以直接操作类或者对象,比如获取某个对象的类定义,获取类声明的属性和方法,调用方法或者构造对象,甚至可以运行时修改类定义。
动态代理是一种方便运行时动态构建代理、动态处理代理方法调用的机制,很多场景都是利用类似机制做到的,比如用来包装RPC调用、面向切面的编程(AOP)。
实现动态代理的方式很多,比如JDK自身提供的动态代理,就是主要利用了上面提到的反射机制。还有其他的实现方式,比如利用传说中更高性能的字节码操作机制,类似ASM、 cglib(基于ASM)、 Javassist等。

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

int是我们常说的整形数字,是Java的8个原始数据类型(Primitive Types, boolean、 byte 、 short、 char、 int、 foat、 double、 long)之一。 Java语言虽然号称一切都是对象,但原始数据类型是例外。
Integer是int对应的包装类,它有一个int类型的字段存储数据,并且提供了基本操作,比如数学运算、 int和字符串之间转换等。在Java 5中,引入了自动装箱和自动拆箱功能(boxing/unboxing), Java可以根据上下文,自动进行转换,极大地简化了相关编程。
关于Integer的值缓存,这涉及Java 5中另一个改进。构建Integer对象的传统方式是直接调用构造器,直接new一个对象。但是根据实践,我们发现大部分数据操作都是集中在有限的、较小的数值范围,因而,在Java 5中新增了静态工厂方法valueOf,在调用它的时候会利用一个缓存机制,带来了明显的性能改进。按照Javadoc, 这个值默认缓存是-128到127之间。

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

这三者都是实现集合框架中的List,也就是所谓的有序集合,因此具体功能也比较近似,比如都提供按照位置进行定位、添加或者删除的操作,都提供迭代器以遍历其内容等。但因为具体的设计区别,在行为、性能、线程安全等方面,表现又有很大不同。
Vector是Java早期提供的线程安全的动态数组,如果不需要线程安全,并不建议选择,毕竟同步是有额外开销的。 Vector内部是使用对象数组来保存数据,可以根据需要自动的增加容量,当数组已满时,会创建新的数组,并拷贝原有数组数据。
ArrayList是应用更加广泛的动态数组实现,它本身不是线程安全的,所以性能要好很多。与Vector近似, ArrayList也是可以根据需要调整容量,不过两者的调整逻辑有所区别, Vector在扩容时会提高1倍,而ArrayList则是增加50%。
LinkedList顾名思义是Java提供的双向链表,所以它不需要像上面两种那样调整容量,它也不是线程安全的。

9. 对比Hashtable、 HashMap、 TreeMap有什么不同? 谈谈你对HashMap的掌握

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来决定,或者根据键的自然顺序来判断。

10. 如何保证容器是线程安全的? ConcurrentHashMap如何实现高效地线程安全?

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

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

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

11. 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、 Bufer等新的抽象,可以构建多路复用的、同步非阻塞IO程序,同时提供了更接近操作系统底层的高性能数据操作方式。
第三,在Java 7中, NIO有了进一步的改进,也就是NIO 2,引入了异步非阻塞IO方式,也有很多人叫AIO(Asynchronous IO)。异步IO操作基于事件和回调机制,可以简单理解为,应用操作直接返回,而不会阻塞在那里,当后台处理完成,操作系统会通知相应线程进行后续工作。

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

Java有多种比较典型的文件拷贝实现方式,比如:
利用java.io类库,直接为源文件构建一个FileInputStream读取,然后再为目标文件构建一个FileOutputStream,完成写入工作。

public satic void copyFileByStream(File source, File des) throws
IOException {
try (InputStream is = new FileInputStream(source);
OutputStream os = new FileOutputStream(des);){
byte[] bufer = new byte[1024];
int length;
while ((length = is.read(bufer)) > 0) {
os.write(bufer, 0, length);
}
}
}

或者,利用java.nio类库提供的transferTo或transferFrom方法实现。

public satic void copyFileByChannel(File source, File des) throws
IOException {
try (FileChannel sourceChannel = new FileInputStream(source)
.getChannel();
FileChannel targetChannel = new FileOutputStream(des).getChannel
();){
for (long count = sourceChannel.size() ;count>0 ;) {
long transferred = sourceChannel.transferTo(
sourceChannel.position(), count, targetChannel); sourceChannel.position(sourceChannel.position() + transferred);
count -= transferred;
}
}
}

当然, Java标准类库本身已经提供了几种Files.copy的实现。
对于Copy的效率,这个其实与操作系统和配置等情况相关,总体上来说, NIO transferTo/From的方式可能更快,因为它更能利用现代操作系统底层机制,避免不必要拷贝和上下文切换

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

接口和抽象类是Java面向对象设计的两个基础机制。
接口是对行为的抽象,它是抽象方法的集合,利用接口可以达到API定义和实现分离的目的。接口,不能实例化;不能包含任何非常量成员,任何field都是隐含着public static final的意义;同时,没有非静态方法实现,也就是说要么是抽象方法,要么是静态方法。 Java标准类库中,定义了非常多的接口,比如java.util.List。
抽象类是不能实例化的类,用abstract关键字修饰class,其目的主要是代码重用。除了不能实例化,形式上和一般的Java类并没有太大区别,可以有一个或者多个抽象方法,也可以没有抽象方法。抽象类大多用于抽取相关Java类的共用方法实现或者是共同成员变量,然后通过继承的方式达到代码复用的目的。 Java标准库中,比如collection框架,很多通用
部分就被抽取成为抽象类,例如java.util.AbstractList。
Java类实现interface使用implements关键词,继承abstract class则是使用extends关键词,我们可以参考Java标准库中的ArrayList。

public class ArrayLis<E> extends AbsractLis<E>
implements Lis<E>, RandomAccess, Cloneable, java.io.Serializable
{
//...
}

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

大致按照模式的应用目标分类,设计模式可以分为创建型模式、结构型模式和行为型模式。
创建型模式,是对对象创建过程的各种问题和解决方案的总结,包括各种工厂模式(Factory、 Abstract Factory)、单例模式(Singleton)、构建器模式(Builder)、原型模式(ProtoType)。
结构型模式,是针对软件设计结构的总结,关注于类、对象继承、组合方式的实践经验。常见的结构型模式,包括桥接模式(Bridge)、适配器模式(Adapter)、装饰者模式(Decorator)、代理模式(Proxy)、组合模式(Composite)、外观模式(Facade)、享元模式(Flyweight)等。
行为型模式,是从类或对象之间交互、职责划分等角度总结的模式。比较常见的行为型模式有策略模式(Strategy)、解释器模式(Interpreter)、命令模式(Command)、观察者模式(Observer)、迭代器模式(Iterator)、模板方法模式(Template Method)、访问者模式(Visitor)。

装饰器模式

eg. IO框架,我们知道InputStream是一个抽象类,标准类库中提供了FileInputStream、 ByteArrayInputStream等各种不同的子类,分别从不同角度对InputStream进行了功能扩展,这是典型的装饰器模式应用案例。

工厂模式

JDK最新版本中 HTTP/2 Client API,下面这个创
建HttpRequest的过程,就是典型的构建器模式(Builder)

HttpReques reques = HttpReques.newBuilder(new URI(uri))
.header(headerAlice, valueAlice)
.headers(headerBob, value1Bob,
headerCarl, valueCarl,
headerBob, value2Bob)
.GET()
.build();

使用构建器模式,可以比较优雅地解决构建复杂对象的麻烦,这里的“复杂”是指类似需要输入的参数组合较多,如果用构造函数,我们往往需要为每一种可能的输入参数组合实现相应的构造函数,一系列复杂的构造函数会让代码阅读性和可维护性变得很差。

创建型模式的初衷,即,将对象创建过程单独抽象出来,从结构上把对象使用逻辑和创建逻辑相互独立,隐藏对象实例的细节,进而为使用者实现了更加规范、统一的逻辑。

15. synchronized和ReentrantLock有什么区别?有人说synchronized最慢,这话靠谱吗?

synchronized是Java内建的同步机制,所以也有人称其为Intrinsic Locking,它提供了互斥的语义和可见性,当一个线程已经获取当前锁时,其他试图获取的线程只能等待或者阻塞在那里。
在Java 5以前, synchronized是仅有的同步手段,在代码中, synchronized可以用来修饰方法,也可以使用在特定的代码块儿上,本质上synchronized方法等同于把方法全部语句用synchronized块包起来。
ReentrantLock,通常翻译为再入锁,是Java 5提供的锁实现,它的语义和synchronized基本相同。再入锁通过代码直接调用lock()方法获取,代码书写也更加灵活。与此同时, ReentrantLock提供了很多实用的方法,能够实现很多synchronized无法做到的细节控制,比如可以控制fairness,也就是公平性,或者利用定义条件等。但是,编码中也需
要注意,必须要明确调用unlock()方法释放,不然就会一直持有该锁。
synchronized和ReentrantLock的性能不能一概而论,早期版本synchronized在很多场景下性能相差较大,在后续版本进行了较多改进,在低竞争场景中表现可能优于ReentrantLock。

16. 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,然后试图进行降级。

17. 一个线程两次调用start()方法会出现什么情况?谈谈线程的生命周期和状态转移。

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等方法的指定超时版本,如下面示例:

public fnal native void wait(long timeout) throws InterruptedException;

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

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

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

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

面试之java提高-杨晓峰专栏笔记_第1张图片

定位死锁最常见的方式就是利用jstack等工具获取线程栈,然后定位互相之间的依赖关系,进而找到死锁。如果是比较明显的死锁,往往jstack等就能直接定位,类似JConsole甚至可以在图形界面进行有限的死锁检测。
如果程序运行时发生了死锁,绝大多数情况下都是无法在线解决的,只能重启、修正程序本身问题。所以,代码开发阶段互相审查,或者利用工具进行预防性排查,往往也是很重要的。

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

我们通常所说的并发包也就是java.util.concurrent及其子包,集中了Java并发的各种基础工具类,具体主要包括几个方面:
提供了比synchronized更加高级的各种同步结构,包括CountDownLatch、 CyclicBarrier、 Semaphore等,可以实现更加丰富的多线程操作,比如利用Semaphore作为资源控制器,限制同时进行工作的线程数量。
各种线程安全的容器,比如最常见的ConcurrentHashMap、有序的ConcunrrentSkipListMap,或者通过类似快照机制,实现线程安全的动态数组CopyOnWriteArrayList等。
各种并发队列实现,如各种BlockedQueue实现,比较典型的ArrayBlockingQueue、 SynchorousQueue或针对特定场景的PriorityBlockingQueue等。
强大的Executor框架,可以创建各种不同类型的线程池,调度任务运行等,绝大部分情况下,不再需要自己从头实现线程池和任务调度器。

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

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

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

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

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

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

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

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

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

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

通常开发者都是利用Executors提供的通用线程池创建方法,去创建不同配置的线程池,主要区别在于不同的ExecutorService类型或者不同的初始参数。
Executors目前提供了5种不同的线程池创建配置:

  • newCachedThreadPool(),它是一种用来处理大量短时间工作任务的线程池,具有几个鲜明特点:它会试图缓存线程并重用,当无缓存线程可用时,就会创建新的工作线程;如果线程闲置的时间超过60秒,则被终止并移出缓存;长时间闲置时,这种线程池,不会消耗什么资源。其内部使用SynchronousQueue作为工作队列。
  • newFixedThreadPool(int nThreads),重用指定数目(nThreads)的线程,其背后使用的是无界的工作队列,任何时候最多有nThreads个工作线程是活动的。这意味着,如果任务数量超过了活动队列数目,将在工作队列中等待空闲线程出现;如果有工作线程退出,将会有新的工作线程被创建,以补足指定的数目nThreads。
  • newSingleThreadExecutor(),它的特点在于工作线程数目被限制为1,操作一个无界的工作队列,所以它保证了所有任务的都是被顺序执行,最多会有一个任务处于活动状态,并且不允许使用者改动线程池实例,因此可以避免其改变线程数目。
  • newSingleThreadScheduledExecutor()和newScheduledThreadPool(int corePoolSize),创建的是个ScheduledExecutorService,可以进行定时或周期性的工作调度,区别在于单一工作线程还是多个工作线程。
  • newWorkStealingPool(int parallelism),这是一个经常被人忽略的线程池, Java 8才加入这个创建方法,其内部会构建ForkJoinPool,利用Work-Stealing算法,并行地处理任务,不保证处理顺序。

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

AtomicIntger是对int类型的一个封装,提供原子性的访问和更新操作,其原子性操作的实现是基于CAS(compare-and-swap)技术。
所谓CAS,表征的是一些列操作的集合,获取当前数值,进行一些运算,利用CAS指令试图进行更新。如果当前数值未变,代表没有其他线程进行并发修改,则成功更新。否则,可
能出现不同的选择,要么进行重试,要么就返回一个成功或者失败的结果。
从AtomicInteger的内部属性可以看出,它依赖于Unsafe提供的一些底层能力,进行底层操作;以volatile的value字段,记录数值,以保证可见性。

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

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

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

CAS是Java并发中所谓lock-free机制的基础。

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

一般来说,我们把Java的类加载过程分为三个主要步骤:加载、链接、初始化,具体行为在Java虚拟机规范里有非常详细的定义。
首先是加载阶段(Loading),它是Java将字节码数据从不同的数据源读取到JVM中,并映射为JVM认可的数据结构(Class对象),这里的数据源可能是各种各样的形态,如jar文件、 class文件,甚至是网络数据源等;如果输入数据不是ClassFile的结构,则会抛出ClassFormatError。
加载阶段是用户参与的阶段,我们可以自定义类加载器,去实现自己的类加载过程。
第二阶段是链接(Linking),这是核心的步骤,简单说是把原始的类定义信息平滑地转化入JVM运行的过程中。这里可进一步细分为三个步骤:

  • 验证(Verifcation),这是虚拟机安全的重要保障, JVM需要核验字节信息是符合Java虚拟机规范的,否则就被认为是VerifyError,这样就防止了恶意信息或者不合规的信息危害JVM的运行,验证阶段有可能触发更多class的加载。
  • 准备(Preparation),创建类或接口中的静态变量,并初始化静态变量的初始值。但这里的“初始化”和下面的显式初始化阶段是有区别的,侧重点在于分配所需要的内存空间,不会去执行更进一步的JVM指令。
  • 解析(Resolution),在这一步会将常量池中的符号引用(symbolic reference)替换为直接引用。在Java虚拟机规范中,详细介绍了类、接口、方法和字段等各个方面的解析。

最后是初始化阶段(initialization),这一步真正去执行类初始化的代码逻辑,包括静态字段赋值的动作,以及执行类定义中的静态初始化块内的逻辑,编译器在编译阶段就会把这部分逻辑整理好,父类型的初始化逻辑优先于当前类型的逻辑。
再来谈谈双亲委派模型,简单说就是当类加载器(Class-Loader)试图加载某个类型的时候,除非父加载器找不到相应类型,否则尽量将这个任务代理给当前加载器的父加载器去做。使用委派模型的目的是避免重复加载Java类型。

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

我们可以从常见的Java类来源分析,通常的开发过程是,开发者编写Java代码,调用javac编译成class文件,然后通过类加载机制载入JVM,就成为应用运行时可以使用的Java类了。
从上面过程得到启发,其中一个直接的方式是从源码入手,可以利用Java程序生成一段源码,然后保存到文件等,下面就只需要解决编译问题了。
有一种笨办法,直接用ProcessBuilder之类启动javac进程,并指定上面生成的文件作为输入,进行编译。最后,再利用类加载器,在运行时加载即可。
前面的方法,本质上还是在当前程序进程之外编译的,那么还有没有不这么low的办法呢?
你可以考虑使用Java Compiler API,这是JDK提供的标准API,里面提供了与javac对等的编译器功能,具体请参考java.compiler相关文档。
进一步思考,我们一直围绕Java源码编译成为JVM可以理解的字节码,换句话说,只要是符合JVM规范的字节码,不管它是如何生成的,是不是都可以被JVM加载呢?我们能不能直
接生成相应的字节码,然后交给类加载器去加载呢?
当然也可以,不过直接去写字节码难度太大,通常我们可以利用Java字节码操纵工具和类库来实现,比如在专栏第6讲中提到的ASM、 Javassist、 cglib等。

25. 谈谈JVM内存区域的划分,哪些区域可能发生OutOfMemoryError?

通常可以把JVM内存区域分为下面几个方面,其中,有的区域是以线程为单位,而有的区域则是整个JVM进程唯一的。
首先, 程序计数器(PC, Program Counter Register)。在JVM规范中,每个线程都有它自己的程序计数器,并且任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的Java方法的JVM指令地址;或者,如果是在执行本地方法,则是未指定值(undefned)。
第二, Java虚拟机栈(Java Virtual Machine Stack),早期也叫Java栈。每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次的Java方法调用。
前面谈程序计数器时,提到了当前方法;同理,在一个时间点,对应的只会有一个活动的栈帧,通常叫作当前帧,方法所在的类叫作当前类。如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,成为新的当前帧,一直到它返回结果或者执行结束。 JVM直接对Java栈的操作只有两个,就是对栈帧的压栈和出栈。
栈帧中存储着局部变量表、操作数(operand)栈、动态链接、方法正常退出或者异常退出的定义等。
第三, 堆(Heap),它是Java内存管理的核心区域,用来放置Java对象实例,几乎所有创建的Java对象实例都是被直接分配在堆上。堆被所有的线程共享,在虚拟机启动时,我们指定的“Xmx”之类参数就是用来指定最大堆空间等指标。
理所当然,堆也是垃圾收集器重点照顾的区域,所以堆内空间还会被不同的垃圾收集器进行进一步的细分,最有名的就是新生代、老年代的划分。
第四, 方法区(Method Area)。这也是所有线程共享的一块内存区域,用于存储所谓的元(Meta)数据,例如类结构信息,以及对应的运行时常量池、字段、方法代码等。
由于早期的Hotspot JVM实现,很多人习惯于将方法区称为永久代(Permanent Generation)。 Oracle JDK 8中将永久代移除,同时增加了元数据区(Metaspace)。
第五, 运行时常量池(Run-Time Constant Pool),这是方法区的一部分。如果仔细分析过反编译的类文件结构,你能看到版本号、字段、方法、超类、接口等各种信息,还有一项信息就是常量池。 Java的常量池可以存放各种常量信息,不管是编译期生成的各种字面量,还是需要在运行时决定的符号引用,所以它比一般语言的符号表存储的信息更加宽泛。
第六, 本地方法栈(Native Method Stack)。它和Java虚拟机栈是非常相似的,支持对本地方法的调用,也是每个线程都会创建一个。在Oracle Hotspot JVM中,本地方法栈和Java虚拟机栈是在同一块儿区域,这完全取决于技术实现的决定,并未在规范中强制。

26. 如何监控和诊断JVM堆内和堆外内存使用?

了解JVM内存的方法有很多,具体能力范围也有区别,简单总结如下:

  • 可以使用综合性的图形化工具,如JConsole、 VisualVM(注意,从Oracle JDK 9开始, VisualVM已经不再包含在JDK安装包中)等。这些工具具体使用起来相对比较直观,直接连接到Java进程,然后就可以在图形化界面里掌握内存使用情况。

以JConsole为例,其内存页面可以显示常见的堆内存和各种堆外部分使用状态。

  • 也可以使用命令行工具进行运行时查询,如jstat和jmap等工具都提供了一些选项,可以查看堆、方法区等使用数据。
  • 或者,也可以使用jmap等提供的命令,生成堆转储(Heap Dump)文件,然后利用jhat或Eclipse MAT等堆转储分析工具进行详细分析。
  • 如果你使用的是Tomcat、 Weblogic等Java EE服务器,这些服务器同样提供了内存管理相关的功能。
  • 另外,从某种程度上来说, GC日志等输出,同样包含着丰富的信息。

这里有一个相对特殊的部分,就是是堆外内存中的直接内存,前面的工具基本不适用,可以使用JDK自带的Native Memory Tracking(NMT)特性,它会从JVM本地内存分配的角度进行解读。

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

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

  • Serial GC,它是最古老的垃圾收集器, “Serial”体现在其收集工作是单线程的,并且在进行垃圾收集过程中,会进入臭名昭著的“Stop-The-World”状态。当然,其单线程设计也意味着精简的GC实现,无需维护复杂的数据结构,初始化也简单,所以一直是Client模式下JVM的默认选项。从年代的角度,通常将其老年代实现单独称作Serial Old,它采用了标记-整理(Mark-Compact)算法,区别于新生代的复制算法。Serial GC的对应JVM参数是:

    -XX:+UseSerialGC
    
  • ParNew GC,很明显是个新生代GC实现,它实际是Serial GC的多线程版本,最常见的应用场景是配合老年代的CMS GC工作,下面是对应参数

    -XX:+UseConcMarkSweepGC -XX:+UseParNewGC
    
  • 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都是并行进行的,在常见的服务器环境中更加高效。开启选项是:

    -XX:+UseParallelGC
    

    另外, Parallel GC引入了开发者友好的配置项,我们可以直接设置暂停时间或吞吐量等目标, JVM会自动进行适应性调整,例如下面参数:

    -XX:MaxGCPauseMillis=value
    -XX:GCTimeRatio=N // GC时间和用户时间比例 = 1 / (N+1)
    
  • G1 GC这是一种兼顾吞吐量和停顿时间的GC实现,是Oracle JDK 9以后的默认GC选项。 G1可以直观的设定停顿时间的目标,相比于CMS GC, G1未必能做到CMS在最好情况下的延时停顿,但是最差情况要好很多。G1 GC仍然存在着年代的概念,但是其内存结构并不是简单的条带式划分,而是类似棋盘的一个个region。 Region之间是复制算法,但整体上实际可看作是标记-整理(MarkCompact)算法,可以有效地避免内存碎片,尤其是当Java堆非常大的时候, G1的优势更加明显。G1吞吐量和停顿表现都非常不错,并且仍然在不断地完善,与此同时CMS已经在JDK 9中被标记为废弃(deprecated),所以G1 GC值得你深入掌握。

28. 谈谈你的GC调优思路?

谈到调优,这一定是针对特定场景、特定目的的事情, 对于GC调优来说,首先就需要清楚调优的目标是什么?从性能的角度看,通常关注三个方面,内存占用(footprint)、延时(latency)和吞吐量(throughput),大多数情况下调优会侧重于其中一个或者两个方面的目标,很少有情况可以兼顾三个不同的角度。当然,除了上面通常的三个方面,也可能需要考虑其他GC相关的场景,例如, OOM也可能与不合理的GC相关参数有关;或者,应用启动速度方面的需求, GC也会是个考虑的方面。
基本的调优思路可以总结为:

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

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

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

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

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

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

Happen-before关系,是Java内存模型中保证多线程操作可见性的机制,也是对早期语言规范中含糊的可见性概念的一个精确定义。
它的具体表现形式,包括但远不止是我们直觉中的synchronized、 volatile、 lock操作顺序等方面,例如:

  • 线程内执行的每个操作,都保证happen-before后面的操作,这就保证了基本的程序顺序规则,这是开发者在书写程序时的基本约定。
  • 对于volatile变量,对它的写操作,保证happen-before在随后对该变量的读取操作。
  • 对于一个锁的解锁操作,保证happen-before加锁操作。
  • 对象构建完成,保证happen-before于fnalizer的开始动作。
  • 甚至是类似线程内部操作的完成,保证happen-before其他Thread.join()的线程等。

这些happen-before关系是存在着传递性的,如果满足a happen-before b和b happen-before c,那么a happen-before c也成立。
前面我一直用happen-before,而不是简单说前后,是因为它不仅仅是对执行时间的保证,也包括对内存读、写操作顺序的保证。仅仅是时钟顺序上的先后,并不能保证线程交互的可见性

30. Java程序运行在Docker等容器环境有哪些新问题?

对于Java来说, Docker毕竟是一个较新的环境,例如,其内存、 CPU等资源限制是通过CGroup(Control Group)实现的,早期的JDK版本(8u131之前)并不能识别这些限制,进而会导致一些基础问题:

  • 如果未配置合适的JVM堆和元数据区、直接内存等参数, Java就有可能试图使用超过容器限制的内存,最终被容器OOM kill,或者自身发生OOM。
  • 错误判断了可获取的CPU资源,例如, Docker限制了CPU的核数, JVM就可能设置不合适的GC并行线程数等。

从应用打包、发布等角度出发, JDK自身就比较大,生成的镜像就更为臃肿,当我们的镜像非常多的时候,镜像的存储等开销就比较明显了。
如果考虑到微服务、 Serverless等新的架构和场景, Java自身的大小、内存占用、启动速度,都存在一定局限性,因为Java早期的优化大多是针对长时间运行的大型服务器端应用。

31. 你了解Java应用开发中的注入攻击吗?

注入式(Inject)攻击是一类非常常见的攻击方式,其基本特征是程序允许攻击者将不可信的动态内容注入到程序中,并将其执行,这就可能完全改变最初预计的执行过程,产生恶意效果。
下面是几种主要的注入式攻击途径,原则上提供动态执行能力的语言特性,都需要提防发生注入攻击的可能。
首先,就是最常见的SQL注入攻击。一个典型的场景就是Web系统的用户登录功能,根据用户输入的用户名和密码,我们需要去后端数据库核实信息。

第二,操作系统命令注入。 Java语言提供了类似Runtime.exec(…)的API,可以用来执行特定命令

第三, XML注入攻击。 Java核心类库提供了全面的XML处理、转换等各种API,而XML自身是可以包含动态内容的,例如XPATH,如果使用不当,可能导致访问恶意内容。 还有类似LDAP等允许动态内容的协议,都是可能利用特定命令,构造注入式攻击的,包括XSS(Cross-site Scripting)攻击,虽然并不和Java直接相关,但也可能在JSP等动态页面中发生。

针对SQL注入:

  • 在数据输入阶段,填补期望输入和可能输入之间的鸿沟。可以进行输入校验,限定什么类型的输入是合法的,例如,不允许输入标点符号等特殊字符,或者特定结构的输入。
  • 在Java应用进行数据库访问时,如果不用完全动态的SQL,而是利用PreparedStatement,可以有效防范SQL注入。不管是SQL注入,还是OS命令注入,程序利用字符串拼接生成运行逻辑都是个可能的风险点!
  • 在数据库层面,如果对查询、修改等权限进行了合理限制,就可以在一定程度上避免被注入删除等高破坏性的代码

32. 如何写出安全的Java代码?

可以用特定类型的安全风险为例,如拒绝服务(DoS)攻击,分析Java开发者需要重点考虑的点。
DoS是一种常见的网络攻击,有人也称其为“洪水攻击”。最常见的表现是,利用大量机器发送请求,将目标网站的带宽或者其他资源耗尽,导致其无法响应正常用户的请求。

我认为,从Java语言的角度,更加需要重视的是程序级别的攻击,也就是利用Java、 JVM或应用程序的瑕疵,进行低成本的DoS攻击,这也是想要写出安全的Java代码所必须考虑
的。例如:

  • 如果使用的是早期的JDK和Applet等技术,攻击者构建合法但恶劣的程序就相对容易,例如,将其线程优先级设置为最高,做一些看起来无害但空耗资源的事情。幸运的是类似技术已经逐步退出历史舞台,在JDK 9以后,相关模块就已经被移除。
  • 哈希碰撞攻击,就是个典型的例子,对方可以轻易消耗系统有限的CPU和线程资源。从这个角度思考,类似加密、解密、图形处理等计算密集型任务,都要防范被恶意滥用,以免攻击者通过直接调用或者间接触发方式,消耗系统资源。
  • 利用Java构建类似上传文件或者其他接受输入的服务,需要对消耗系统内存或存储的上限有所控制,因为我们不能将系统安全依赖于用户的合理使用。其中特别注意的是涉及解压缩功能时,就需要防范Zip bomb等特定攻击。
  • 另外, Java程序中需要明确释放的资源有很多种,比如文件描述符、数据库连接,甚至是再入锁,任何情况下都应该保证资源释放成功,否则即使平时能够正常运行,也可能被攻击者利用而耗尽某类资源,这也算是可能的DoS攻击来源。

所以可以看出,实现安全的Java代码,需要从功能设计到实现细节,都充分考虑可能的安全影响。

33. 后台服务出现明显“变慢”,谈谈你的诊断思路?

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

  • 服务是突然变慢还是长时间运行后观察到变慢?类似问题是否重复出现?
  • “慢”的定义是什么,我能够理解是系统对其他方面的请求的反应延时变长吗?

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

  • 问题可能来自于Java服务自身,也可能仅仅是受系统里其他服务的影响。初始判断可以先确认是否出现了意外的程序错误,例如检查应用本身的错误日志。对于分布式系统,很多公司都会实现更加系统的日志、性能等监控系统。一些Java诊断工具也可以用于这个诊断,例如通过JFR(Java Flight Recorder),监控应用是否大量出现了某种类型的异常。

    如果有,那么异常可能就是个突破点。

    如果没有,可以先检查系统级别的资源等情况,监控CPU、内存等资源是否被其他进程大量占用,并且这种占用是否不符合系统正常运行状况。

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

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

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

34. 有人说“Lambda能让Java程序慢30倍”,你怎么看?

我认为, “Lambda能让Java程序慢30倍”这个争论实际反映了几个方面:
第一,基准测试是一个非常有效的通用手段,让我们以直观、量化的方式,判断程序在特定条件下的性能表现。
第二,基准测试必须明确定义自身的范围和目标,否则很有可能产生误导的结果。前面代码片段本身的逻辑就有瑕疵,更多的开销是源于自动装箱、拆箱(autoboxing/unboxing),而不是源自Lambda和Stream,所以得出的初始结论是没有说服力的。
第三,虽然Lambda/Stream为Java提供了强大的函数式编程能力,但是也需要正视其局限性:

  • 一般来说,我们可以认为Lambda/Stream提供了与传统方式接近对等的性能,但是如果对于性能非常敏感,就不能完全忽视它在特定场景的性能差异了,例如: 初始化的开销。Lambda并不算是语法糖,而是一种新的工作机制,在首次调用时, JVM需要为其构建CallSite实例。这意味着,如果Java应用启动过程引入了很多Lambda语句,会导致启动过程变慢。其实现特点决定了JVM对它的优化可能与传统方式存在差异。
  • 增加了程序诊断等方面的复杂性,程序栈要复杂很多, Fluent风格本身也不算是对于调试非常友好的结构,并且在可检查异常的处理方面也存在着局限性等。

35. JVM优化Java代码时都做了什么?

JVM在对代码执行的优化可分为运行时(runtime)优化和即时编译器(JIT)优化。运行时优化主要是解释执行和动态编译通用的一些机制,比如说锁机制(如偏斜锁)、内存分配机制(如TLAB)等。除此之外,还有一些专门用于优化解释执行效率的,比如说模版解释器、内联缓存(inline cache,用于优化虚方法调用的动态绑定)。
JVM的即时编译器优化是指将热点代码以方法为单位转换成机器码,直接运行在底层硬件之上。它采用了多种优化方式,包括静态编译器可以使用的如方法内联、逃逸分析,也包括基于程序运行profle的投机性优化(speculative/optimistic optimization)。这个怎么理解呢?比如我有一条instanceof指令,在编译之前的执行过程中,测试对象的类一直是
同一个,那么即时编译器可以假设编译之后的执行过程中还会是这一个类,并且根据这个类直接返回instanceof的结果。如果出现了其他类,那么就抛弃这段编译后的机器码,并且切换回解释执行。
当然, JVM的优化方式仅仅作用在运行应用代码的时候。如果应用代码本身阻塞了,比如说并发时等待另一线程的结果,这就不在JVM的优化范畴啦。

36. 谈谈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,其本质就可以看作是种乐观锁机制,而排他性的读写锁、双阶段锁等则是悲观锁的实现。
有关它们的应用场景,你可以构建一下简化的火车余票查询和购票系统。同时查询的人可能很多,虽然具体座位票只能是卖给一个人,但余票可能很多,而且也并不能预测哪个查询者会购票,这个时候就更适合用乐观锁。

37. 谈谈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。
  • 创建过程完毕。

你可以参考下面示意图理解这个具体过程和先后顺序

面试之java提高-杨晓峰专栏笔记_第2张图片
第二, 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。

38. 对比Java标准NIO类库,你知道Netty是如何实现更高性能的吗?

单独从性能角度, Netty在基础的NIO等类库之上进行了很多改进,例如:

  • 更加优雅的Reactor模式实现、灵活的线程模型、利用EventLoop等创新性的机制,可以非常高效地管理成百上千的Channel。
  • 充分利用了Java的Zero-Copy机制,并且从多种角度, “斤斤计较”般的降低内存分配和回收的开销。例如,使用池化的Direct Bufer等技术,在提高IO性能的同时,减少了对象的创建和销毁;利用反射等技术直接操纵SelectionKey,使用数组而不是Java容器等。
  • 使用更多本地代码。例如,直接利用JNI调用Open SSL等方式,获得比Java内建SSL引擎更好的性能。
  • 在通信协议、序列化等其他角度的优化。

总的来说, Netty并没有Java核心类库那些强烈的通用性、跨平台等各种负担,针对性能等特定目标以及Linux等特定环境,采取了一些极致的优化手段。

39. 谈谈常用的分布式ID的设计方案? Snowfake是否受冬令时切换影响?

首先,我们需要明确通常的分布式ID定义,基本的要求包括:

  • 全局唯一,区别于单点系统的唯一,全局是要求分布式系统内唯一。
  • 有序性,通常都需要保证生成的ID是有序递增的。例如,在数据库存储等场景中,有序ID便于确定数据位置,往往更加高效。

目前业界的方案很多,典型方案包括:

  • 基于数据库自增序列的实现。这种方式优缺点都非常明显,好处是简单易用,但是在扩展性和可靠性等方面存在局限性。
  • 基于Twitter早期开源的Snowfake的实现,以及相关改动方案。这是目前应用相对比较广泛的一种方式,其结构定义你可以参考下面的示意图

面试之java提高-杨晓峰专栏笔记_第3张图片

整体长度通常是64 (1 + 41 + 10+ 12 = 64)位,适合使用Java语言中的long类型来存储。
头部是1位的正负标识位。
紧跟着的高位部分包含41位时间戳,通常使用System.currentTimeMillis()。
后面是10位的WorkerID,标准定义是5位数据中心 + 5位机器ID,组成了机器编号,以区分不同的集群节点。
最后的12位就是单位毫秒内可生成的序列号数目的理论极限。

关于第二个问题, Snowfake是否受冬令时切换影响?
我认为没有影响,你可以从Snowfake的具体算法实现寻找答案。我们知道Snowfake算法的Java实现,大都是依赖于System.currentTimeMillis(),这个数值代表什么呢?
从Javadoc可以看出,它是返回当前时间和1970年1月1号UTC时间相差的毫秒数,这个数值与夏/冬令时并没有关系,所以并不受其影响。


补充

分布式领域的面试热点,例如:
分布式事务,包括其产生原因、业务背景、主流的解决方案等。
理解CAP、 BASE等理论,懂得从最终一致性等角度来思考问题,理解Paxos、 Raft等一致性算法。
理解典型的分布式锁实现,例如最常见的Redis分布式锁。
负载均衡等分布式领域的典型算法,至少要了解主要方案的原理

TODO

你可能感兴趣的:(面试准备)