java核心知识

1 方法重载

在java语言中,要重载一个方法,除了要与原方法具有相同的简单名称之外,还要求必须拥有一个与原方法不同的特征签名,特征签名就是一个方法中各个参数在常量池的字段符号引用的集合,也就是因为返回值不会包含在特征签名中,因此java语言里面是无法仅仅依靠返回值的不同来对一个已有方法进行重载的。但是在Class文件格式中,特征签名的范围更大一些,只要描述符不是完全一致的两个方法也可以共存。也就是说如果两个方法有相同的名称和特征签名,但返回值不同,那么也是可以合法共存于同一个Class文件中的。

2 谈谈对java的理解

典型回答
Java本身是一种面向对象的语言,最显著的特性有两个方面,一是所谓的“书写一次,到处运行”(Write once, run anywhere),能够非常容易地获得跨平台能力;另外就是垃圾收集(GC, Garbage Collection),Java通过垃圾收集器(Garbage Collector)回收分配内存,大部分情况下,程序员不需要自己操心内存的分配和回收。
对于“Java是解释执行”这句话,这个说法不太准确。我们开发的Java的源代码,首先通过Javac编译成为字节码(bytecode),然后,在运行时,通过 Java虚拟机(JVM)内嵌的解释器将字节码转换成为最终的机器码。但是常见的JVM,比如我们大多数情况使用的Oracle JDK提供的Hotspot JVM,都提供了JIT(Just-In-Time)编译器,也就是通常所说的动态编译器,JIT能够在运行时将热点代码编译成机器码,这种情况下部分热点代码就属于编译执行,而不是解释执行了。
众所周知,我们通常把Java分为编译期和运行时。这里说的Java的编译和C/C++是有着不同的意义的,Javac的编译,编译Java源码生成“.class”文件里面实际是字节码,而不是可以直接执行的机器码。Java通过字节码和Java虚拟机(JVM)这种跨平台的抽象,屏蔽了操作系统和硬件的细节,这也是实现 一次编译,到处执行 的基础。

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

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

典型回答:
final可以用来修饰类、方法、变量,分别有不同的意义,final修饰的class代表不可以继承扩展,final的变量是不可以修改的,而final的方法也是不可以重写的(override)。
fnally则是Java保证重点代码一定要被执行的一种机制。我们可以使用try-fnally或者try-catch-fnally来进行类似关闭JDBC连接、保证unlock锁等动作。
fnalize是基础类java.lang.Object的一个方法,它的设计目的是保证对象在被垃圾收集前完成特定资源的回收。fnalize机制现在已经不推荐使用,并且在JDK 9开始被标记
为deprecated。

考点分析:
如果你关注过Java核心类库的定义或源码, 有没有发现java.lang包下面的很多类,相当一部分都被声明成为final class?在第三方类库的一些基础类中同样如此,这可以有效避免API使用者更改基础功能,某种程度上,这是保证平台安全的必要手段。
使用fnal修饰参数或者变量,也可以清楚地避免意外赋值导致的编程错误,甚至,有人明确推荐将所有方法参数、本地变量、成员变量声明成final。final变量产生了某种程度的不可变(immutable)的效果,所以,可以用于保护只读数据,尤其是在并发编程中,因为明确地不能再赋值final变量,有利于减少额外的同步开销,也可以省去一些防御性拷贝的必要。

对于finally,明确知道怎么使用就足够了。需要关闭的连接等资源,更推荐使用Java 7中添加的try-with-resources语句,因为通常Java平台能够更好地处理异常情况,编码量也
要少很多,何乐而不为呢。

对于finalize,我们要明确它是不推荐使用的,业界实践一再证明它不是个好的办法,在Java 9中,甚至明确将Object.finalize()标记为deprecated!如果没有特别的原因,不要实
现finalize方法,也不要指望利用它来进行资源回收。
为什么呢?简单说,你无法保证finalize什么时候执行,执行的是否符合预期。使用不当会影响性能,导致程序死锁、挂起等。

知识扩展:
1.注意,final不是immutable!final只是约束final所修饰的变量引用不可以再被赋值,但对其行为不加约束。例如:final List list = new ArrayList<>();这个list对象虽然被final修饰,但仍可以put()添加元素。
Immutable在很多场景是非常棒的选择,某种意义上说,Java语言目前并没有原生的不可变支持,如果要实现immutable的类,我们需要做到:
将class自身声明为final,这样别人就不能扩展来绕过限制了。
将所有成员变量定义为private和fnal,并且不要实现setter方法。
通常构造对象时,成员变量使用深度拷贝来初始化,而不是直接赋值,这是一种防御措施,因为你无法确定输入对象不被其他人修改。
如果确实需要实现getter方法,或者其他可能会返回内部状态的方法,使用copy-on-write原则,创建私有的copy。

2.finalize真的那么不堪?

因为,fnalize被设计成在对象被垃圾收集前调用,这就意味着实现了fnalize方法的对象是个“特殊公民”,JVM要对它进行额外处理。fnalize本质上成为了快速回收的阻碍者,可能导致你的对象经过多个垃圾收集周期才能被回收。

3.有什么机制可以替换finalize吗?

Java平台目前在逐步使用java.lang.ref.Cleaner来替换掉原有的fnalize实现。Cleaner的实现利用了幻象引用(PhantomReference),这是一种常见的所谓post-mortem清理机制。我会在后面的专栏系统介绍Java的各种引用,利用幻象引用和引用队列,我们可以保证对象被彻底销毁前做一些类似资源回收的工作,比如关闭文件描述符(操作系统有限的资源),它比fnalize更加轻量、更加可靠。

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

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

知识扩展
1.对象可达性状态流转分析
首先,请你看下面流程图,我这里简单总结了对象生命周期和不同可达性状态,以及不同状态可能的改变关系,可能未必100%严谨,来阐述下可达性的变化。
java核心知识_第1张图片
我来解释一下上图的具体状态,这是Java定义的不同可达性级别(reachability level),具体如下:

  1. 强可达(StronglyReachable),就是当一个对象可以有一个或多个线程可以不通过各种引用访问到的情况。比如,我们新创建一个对象,那么创建它的线程对它就是强可达。
  2. 软可达(SoftlyReachable),就是当我们只能通过软引用才能访问到对象的状态。
  3. 弱可达(Weakly Reachable),类似前面提到的,就是无法通过强引用或者软引用访问,只能通过弱引用访问时的状态。这是十分临近finalize状态的时机,当弱引用被清除的时候,就符合fnalize的条件了。
  4. 幻象可达(Phantom Reachable),上面流程图已经很直观了,就是没有强、软、弱引用关联,并且fnalize过了,只有幻象引用指向这个对象的时候。
  5. 当然,还有一个最后的状态,就是不可达(unreachable),意味着对象可以被清除了。
    判断对象可达性,是JVM垃圾收集器决定如何处理对象的一部分考虑。
    所有引用类型,都是抽象类java.lang.ref.Reference的子类,你可能注意到它提供了get()方法:除了幻象引用(因为get永远返回null),如果对象还没有被销毁,都可以通过get方法获取原有对象。这意味着,利用软引用和弱引用,我们可以将访问到的对象,重新指向强引用,也就是人为的改变了对象的可达性状态!这也是为什么我在上面图里有些地方画了双向箭头。
    所以,对于软引用、弱引用之类,垃圾收集器可能会存在二次确认的问题,以保证处于弱引用状态的对象,没有改变为强引用。
    2.引用队列(ReferenceQueue)使用
    谈到各种引用的编程,就必然要提到引用队列。我们在创建各种引用并关联到响应对象时,可以选择是否需要关联引用队列,JVM会在特定时机将引用enqueue到队列里,我们可以从队列里获取引用(remove方法在这里实际是有获取的意思)进行相关后续逻辑。尤其是幻象引用,get方法只返回null,如果再不指定引用队列,基本就没有意义了。看看下面的示例代码。利用引用队列,我们可以在对象处于相应状态时(对于幻象引用,就是前面说的被fnalize了,处于幻象可达状态),执行后期处理逻辑。

6 String、StringBuffer、StringBuilder有什么区别?

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

考点分析
几乎所有的应用开发都离不开操作字符串,理解字符串的设计和实现以及相关工具如拼接类的使用,对写出高质量代码是非常有帮助的。关于这个问题,我前面的回答是一个通常的概要性回答,至少你要知道String是Immutable的,字符串操作不当可能会产生大量临时字符串,以及线程安全方面的区别。
如果继续深入,面试官可以从各种不同的角度考察,比如可以:

  • 通过String和相关类,考察基本的线程安全设计与实现,各种基础编程实践。
  • 考察JVM对象缓存机制的理解以及如何良好地使用。
  • 考察JVM优化Java代码的一些技巧。
  • String相关类的演进,比如Java 9中实现的巨大变化。

针对上面这几方面,我会在知识扩展部分与你详细聊聊。

知识扩展
1.字符串设计和实现考量
我在前面介绍过,String是Immutable类的典型实现,原生的保证了基础线程安全,因为你无法对它内部数据进行任何修改,这种便利甚至体现在拷贝构造函数中,由于不可变,Immutable对象在拷贝时不需要额外复制数据。
我们再来看看StringBufer实现的一些细节,它的线程安全是通过把各种修改数据的方法都加上synchronized关键字实现的,非常直白。其实,这种简单粗暴的实现方式,非常适合我们常见的线程安全类实现,不必纠结于synchronized性能之类的,有人说“过早优化是万恶之源”,考虑可靠性、正确性和代码可读性才是大多数应用开发最重要的因素。

为了实现修改字符序列的目的,StringBufer和StringBuilder底层都是利用可修改的(char,JDK 9以后是byte)数组,二者都继承了AbstractStringBuilder,里面包含了基本操作,区别仅在于最终的方法是否加了synchronized。
另外,这个内部数组应该创建成多大的呢?如果太小,拼接的时候可能要重新创建足够大的数组;如果太大,又会浪费空间。目前的实现是,构建时初始字符串长度加16(这意味着,如果没有构建对象时输入最初的字符串,那么初始值就是16)。我们如果确定拼接会发生非常多次,而且大概是可预计的,那么就可以指定合适的大小,避免很多次扩容的开销。扩容会产生多重开销,因为要抛弃原有数组,创建新的(可以简单认为是倍数)数组,还要进行arraycopy。

在JDK 8中,字符串拼接操作会自动被javac转换为StringBuilder操作,而在JDK 9里面则是因为Java 9为了更加统一字符串操作优化,提供了StringConcatFactory,作为一个统一的入口。javac自动生成的代码,虽然未必是最优化的,但普通场景也足够了,你可以酌情选择。

2.字符串缓存
我们粗略统计过,把常见应用进行堆转储(Dump Heap),然后分析对象组成,会发现平均25%的对象是字符串,并且其中约半数是重复的。如果能避免创建重复字符串,可以有效降低内存消耗和对象创建开销。
String在Java 6以后提供了intern()方法,目的是提示JVM把相应字符串缓存起来,以备重复使用。在我们创建字符串对象并调用intern()方法的时候,如果已经有缓存的字符串,就会返回缓存里的实例,否则将其缓存起来。一般来说,JVM会将所有的类似“abc”这样的文本字符串,或者字符串常量之类缓存起来。

看起来很不错是吧?但实际情况估计会让你大跌眼镜。一般使用Java 6这种历史版本,并不推荐大量使用intern,为什么呢?魔鬼存在于细节中,被缓存的字符串是存在所谓PermGen里的,也就是臭名昭著的 永久代 ,这个空间是很有限的,也基本不会被FullGC之外的垃圾收集照顾到。所以,如果使用不当,OOM就会光顾。
在后续版本中,这个缓存被放置在堆中,这样就极大避免了永久代占满的问题,甚至永久代在JDK 8中被MetaSpace(元数据区)替代了。而且,默认缓存大小也在不断地扩大中,从最初的1009,到7u40以后被修改为60013。你可以使用下面的参数直接打印具体数字,可以拿自己的JDK立刻试验一下。

Intern是一种显式地排重机制,但是它也有一定的副作用,因为需要开发者写代码时明确调用,一是不方便,每一个都显式调用是非常麻烦的;另外就是我们很难保证效率,应用开发阶段很难清楚地预计字符串的重复情况,有人认为这是一种污染代码的实践。
幸好在Oracle JDK 8u20之后,推出了一个新的特性,也就是G1 GC下的字符串排重。它是通过将相同数据的字符串指向同一份数据来做到的,是JVM底层的改变,并不需要Java类库做什么修改。

3.String自身的演化
如果你仔细观察过Java的字符串,在历史版本中,它是使用char数组来存数据的,这样非常直接。但是Java中的char是两个bytes大小,拉丁语系语言的字符,根本就不需要太宽的char,这样无区别的实现就造成了一定的浪费。密度是编程语言平台永恒的话题,因为归根结底绝大部分任务是要来操作数据的。
其实在Java 6的时候,Oracle JDK就提供了压缩字符串的特性,但是这个特性的实现并不是开源的,而且在实践中也暴露出了一些问题,所以在最新的JDK版本中已经将它移除了。

在Java 9中,我们引入了Compact Strings的设计,对字符串进行了大刀阔斧的改进。将数据存储方式从char数组,改变为一个byte数组加上一个标识编码的所谓coder,并且将相关字符串操作类都进行了修改。另外,所有相关的Intrinsic之类也都进行了重写,以保证没有任何性能损失。

虽然底层实现发生了这么大的改变,但是Java字符串的行为并没有任何大的变化,所以这个特性对于绝大部分应用来说是透明的,绝大部分情况不需要修改已有代码。

当然,在极端情况下,字符串也出现了一些能力退化,比如最大字符串的大小。你可以思考下,原来char数组的实现,字符串的最大长度就是数组本身的长度限制,但是替换成byte数组,同样数组长度下,存储能力是退化了一倍的!还好这是存在于理论中的极限,还没有发现现实应用受此影响。在通用的性能测试和产品实验中,我们能非常明显地看到紧凑字符串带来的优势,即更小的内存占用、更快的操作速度。

今天我从String、StringBufer和StringBuilder的主要设计和实现特点开始,分析了字符串缓存的intern机制、非代码侵入性的虚拟机层面排重、Java 9中紧凑字符的改进,并且初步接触了JVM的底层优化机制intrinsic。从实践的角度,不管是Compact Strings还是底层intrinsic优化,都说明了使用Java基础类库的优势,它们往往能够得到最大程度、最高质量的优化,而且只要升级JDK版本,就能零成本地享受这些益处。

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

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

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

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

考点分析
这个题目给我的第一印象是稍微有点诱导的嫌疑,可能会下意识地以为动态代理就是利用反射机制实现的,这么说也不算错但稍微有些不全面。功能才是目的,实现的方法有很多。总的来说,这道题目考察的是Java语言的另外一种基础机制: 反射,它就像是一种魔法,引入运行时自省能力,赋予了Java语言令人意外的活力,通过运行时操作元数据或对象,Java可以灵活地操作运行时才能确定的信息。而动态代理,则是延伸出来的一种广泛应用于产品开发中的技术,很多繁琐的重复编程,都可以被动态代理机制优雅地解决。

从考察知识点的角度,这道题涉及的知识点比较庞杂,所以面试官能够扩展或者深挖的内容非常多,比如:

  1. 考察你对反射机制的了解和掌握程度。
  2. 动态代理解决了什么问题,在你业务系统中的应用场景是什么?
  3. JDK动态代理在设计和实现上与cglib等方式有什么不同,进而如何取舍?
  4. 这些考点似乎不是短短一篇文章能够囊括的,我会在知识扩展部分尽量梳理一下。

知识扩展

1.反射机制及其演进
对于Java语言的反射机制本身,如果你去看一下java.lang或java.lang.refect包下的相关抽象,就会有一个很直观的印象了。Class、Field、Method、Constructor等,这些完全就是我们去操作类和对象的元数据对应。反射各种典型用例的编程,相信有太多文章或书籍进行过详细的介绍,我就不再赘述了,至少你需要掌握基本场景编程,这里是官方提供的参考文档https://docs.oracle.com/javase/tutorial/refect/index.html 。

关于反射,有一点我需要特意提一下,就是反射提供的AccessibleObject.setAccessible(boolean fag)。它的子类也大都重写了这个方法,这里的所谓accessible可以理解成修饰成员的public、protected、private,这意味着我们可以在运行时修改成员访问限制!

setAccessible的应用场景非常普遍,遍布我们的日常开发、测试、依赖注入等各种框架中。比如,在O/R Mapping框架中,我们为一个Java实体对象,运行时自动生成setter、getter的逻辑,这是加载或者持久化数据非常必要的,框架通常可以利用反射做这个事情,而不需要开发者手动写类似的重复代码。

另一个典型场景就是绕过API访问控制。我们日常开发时可能被迫要调用内部API去做些事情,比如,自定义的高性能NIO框架需要显式地释放DirectBufer,使用反射绕开限制是一种常见办法。

但是,在Java 9以后,这个方法的使用可能会存在一些争议,因为Jigsaw项目新增的模块化系统,出于强封装性的考虑,对反射访问进行了限制。Jigsaw引入了所谓Open的概念,只有当被反射操作的模块和指定的包对反射调用者模块Open,才能使用setAccessible;否则,被认为是不合法(illegal)操作。如果我们的实体类是定义在模块里面,我们需要在模块描述符中明确声明:

因为反射机制使用广泛,根据社区讨论,目前,Java 9仍然保留了兼容Java 8的行为,但是很有可能在未来版本,完全启用前面提到的针对setAccessible的限制,即只有当被反射操作的模块和指定的包对反射调用者模块Open,才能使用setAccessible,我们可以使用下面参数显式设置。

2.动态代理

前面的问题问到了动态代理,我们一起看看,它到底是解决什么问题?

首先,它是一个代理机制。如果熟悉设计模式中的代理模式,我们会知道,代理可以看作是对调用目标的一个包装,这样我们对目标代码的调用不是直接发生的,而是通过代理完成。其实很多动态代理场景,我认为也可以看作是装饰器(Decorator)模式的应用,我会在后面的专栏设计模式主题予以补充。

通过代理可以让调用者与实现者之间解耦。比如进行RPC调用,框架内部的寻址、序列化、反序列化等,对于调用者往往是没有太大意义的,通过代理,可以提供更加友善的界面。

代理的发展经历了静态到动态的过程,源于静态代理引入的额外工作。类似早期的RMI之类古董技术,还需要rmic之类工具生成静态stub等各种文件,增加了很多繁琐的准备工作,而这又和我们的业务逻辑没有关系。利用动态代理机制,相应的stub等类,可以在运行时生成对应的调用操作也是动态完成,极大地提高了我们的生产力。改进后的RMI已经不再需要手动去准备这些了,虽然它仍然是相对古老落后的技术,未来也许会逐步被移除。

这么说可能不够直观,我们可以看JDK动态代理的一个简单例子。下面只是加了一句print,在生产系统中,我们可以轻松扩展类似逻辑进行诊断、限流等。

public class MyDynamicProxy {
	public satic  void main (String[] args) {
		HelloImpl hello = new HelloImpl();
		MyInvocationHandler handler = new MyInvocationHandler(hello);
		// 构造代码实例
		Hello proxyHello = (Hello) Proxy.newProxyInsance(HelloImpl.class.getClassLoader(), HelloImpl.class.getInterfaces(), handler);
		// 调用代理方法
		proxyHello.sayHello();
	}
}
interface Hello {
	void sayHello();
}
class HelloImpl implements  Hello {
		@Override
		public void sayHello() {
			Sysem.out.println("Hello World");
		}
}
class MyInvocationHandler implements InvocationHandler {
		private Object target;
		public MyInvocationHandler(Object target) {
			this.target = target;
		}
		@Override
		public Object invoke(Object proxy, Method method, Object[] args)
		throws Throwable {
			Sysem.out.println("Invoking sayHello");
			Object result = method.invoke(target, args);
			return result;
		}
}

上面的JDK Proxy例子,非常简单地实现了动态代理的构建和代理操作。首先,实现对应的InvocationHandler;然后,以接口Hello为纽带,为被调用目标构建代理对象,进而应用程序就可以使用代理对象间接运行调用目标的逻辑,代理为应用插入额外逻辑(这里是println)提供了便利的入口。从API设计和实现的角度,这种实现仍然有局限性,因为它是以接口为中心的,相当于添加了一种对于被调用者没有太大意义的限制。我们实例化的是Proxy对象,而不是真正的被调用类型,这在实践中还是可能带来各种不便和能力退化。

如果被调用者没有实现接口,而我们还是希望利用动态代理机制,那么可以考虑其他方式。我们知道Spring AOP支持两种模式的动态代理,JDK Proxy或者cglib,如果我们选择cglib方式,你会发现对接口的依赖被克服了。

cglib动态代理采取的是创建目标类的子类的方式,因为是子类化,我们可以达到近似使用被调用者本身的效果。在Spring编程中,框架通常会处理这种情况,当然我们也可以显式指定。关于类似方案的实现细节,我就不再详细讨论了。那我们在开发中怎样选择呢?我来简单对比下两种方式各自优势。

JDK Proxy的优势:

  • 最小化依赖关系,减少依赖意味着简化开发和维护,JDK本身的支持,可能比cglib更加可靠。
  • 平滑进行JDK版本升级,而字节码类库通常需要进行更新以保证在新版Java上能够使用。
  • 代码实现简单。

基于类似cglib框架的优势:

  • 有的时候调用目标可能不便实现额外接口,从某种角度看,限定调用者实现接口是有些侵入性的实践,类似cglib动态代理就没有这种限制
  • 只操作我们关心的类,而不必为其他相关类增加工作量。
  • 高性能。

外,从性能角度,我想补充几句。记得有人曾经得出结论说JDK Proxy比cglib或者Javassist慢几十倍。坦白说,不去争论具体的benchmark细节,在主流JDK版本中,JDK Proxy在典型场景可以提供对等的性能水平,数量级的差距基本上不是广泛存在的。而且,反射机制性能在现代JDK中,自身已经得到了极大的改进和优化,同时,JDK很多功能也不完全是反射,同样使用了ASM进行字节码操作。

我们在选型中,性能未必是唯一考量,可靠性、可维护性、编程工作量等往往是更主要的考虑因素,毕竟标准类库和反射编程的门槛要低得多,代码量也是更加可控的,如果我们比较下不同开源项目在动态代理开发上的投入,也能看到这一点。

动态代理应用非常广泛,虽然最初多是因为RPC等使用进入我们视线,但是动态代理的使用场景远远不仅如此,它完美符合Spring AOP等切面编程。我在后面的专栏还会进一步详细分析AOP的目的和能力。简单来说它可以看作是对OOP的一个补充,因为OOP对于跨越不同对象或类的分散、纠缠逻辑表现力不够,比如在不同模块的特定阶段做一些事情,类似日志、用户鉴权、全局性异常处理、性能监控,甚至事务处理等,你可以参考下面这张图。
java核心知识_第2张图片
AOP通过(动态)代理机制可以让开发者从这些繁琐事项中抽身出来,大幅度提高了代码的抽象程度和复用度。从逻辑上来说,我们在软件设计和实现中的类似代理,如Facade、Observer等很多设计目的,都可以通过动态代理优雅地实现。

8 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之间。

考点分析
今天这个问题涵盖了Java里的两个基础要素:原始数据类型、包装类。谈到这里,就可以非常自然地扩展到自动装箱、自动拆箱机制,进而考察封装类的一些设计和实践。坦白说,
理解基本原理和用法已经足够日常工作需求了,但是要落实到具体场景,还是有很多问题需要仔细思考才能确定。
面试官可以结合其他方面,来考察面试者的掌握程度和思考逻辑,比如:

  • 我在专栏第1讲中介绍的Java使用的不同阶段:编译阶段、运行时,自动装箱/自动拆箱是发生在什么阶段?
  • 我在前面提到使用静态工厂方法valueOf会使用到缓存机制,那么自动装箱的时候,缓存机制起作用吗?
  • 为什么我们需要原始数据类型,Java的对象似乎也很高效,应用中具体会产生哪些差异?
  • 阅读过Integer源码吗?分析下类或某些方法的设计要点。

似乎有太多内容可以探讨,我们一起来分析一下

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

像前面提到的整数,javac替我们自动把装箱转换为Integer.valueOf(),把拆箱替换为Integer.intValue(),这似乎这也顺道回答了另一个问题,既然调用的是Integer.valueOf,自然能够得到缓存的好处啊。

如何程序化的验证上面的结论呢?
你可以写一段简单的程序包含下面两句代码,然后反编译一下。当然,这是一种从表现倒推的方法,大多数情况下,我们还是直接参考规范文档会更加可靠,毕竟软件承诺的是遵循规范,而不是保持当前行为。

Integer integer = 1;
int unboxing = integer ++;

java核心知识_第3张图片
这种缓存机制并不是只有Integer才有,同样存在于其他的一些包装类,比如:

  • Boolean,缓存了true/false对应实例,确切说,只会返回两个常量实例Boolean.TRUE/FALSE。
  • Short,同样是缓存了-128到127之间的数值。
  • Byte,数值有限,所以全部都被缓存。
  • Character,缓存范围’\u0000’ 到 ‘\u007F’。

自动装箱/自动拆箱似乎很酷,在编程实践中,有什么需要注意的吗?
原则上,建议避免无意中的装箱、拆箱行为,尤其是在性能敏感的场合,创建10万个Java对象和10万个整数的开销可不是一个数量级的,不管是内存使用还是处理速度,光是对象头的空间占用就已经是数量级的差距了。

我们其实可以把这个观点扩展开,使用原始数据类型、数组甚至本地代码实现等,在性能极度敏感的场景往往具有比较大的优势,用其替换掉包装类、动态数组(如ArrayList)等可以作为性能优化的备选项。一些追求极致性能的产品或者类库,会极力避免创建过多对象。当然,在大多数产品代码里,并没有必要这么做,还是以开发效率优先。以我们经常会使用到的计数器实现为例,下面是一个常见的线程安全计数器实现。

2.源码分析
考察是否阅读过、是否理解JDK源代码可能是部分面试官的关注点,这并不完全是一种苛刻要求,阅读并实践高质量代码也是程序员成长的必经之路,下面我来分析下Integer的源码。

整体看一下Integer的职责,它主要包括各种基础的常量,比如最大值、最小值、位数等;前面提到的各种静态工厂方法valueOf();获取环境变量数值的方法;各种转换方法,比如转换为不同进制的字符串,如8进制,或者反过来的解析方法等。我们进一步来看一些有意思的地方。

首先,继续深挖缓存,Integer的缓存范围虽然默认是-128到127,但是在特别的应用场景,比如我们明确知道应用会频繁使用更大的数值,这时候应该怎么办呢?
缓存上限值实际是可以根据需要调整的,JVM提供了参数设置:
-XX:AutoBoxCacheMax=N

这些实现,都体现在java.lang.Integer源码之中,并实现在IntegerCache的静态初始化块里。

第二,我们在分析字符串的设计实现时,提到过字符串是不可变的,保证了基本的信息安全和并发编程中的线程安全。如果你去看包装类里存储数值的成员变量“value”,你会发现,不管是Integer还Boolean等,都被声明为“private final”,所以,它们同样是不可变类型!

这种设计是可以理解的,或者说是必须的选择。想象一下这个应用场景,比如Integer提供了getInteger()方法,用于方便地读取系统属性,我们可以用属性来设置服务器某个服务的端口,如果我可以轻易地把获取到的Integer对象改变为其他数值,这会带来产品可靠性方面的严重问题。

第三,Integer等包装类,定义了类似SIZE或者BYTES这样的常量,这反映了什么样的设计考虑呢?如果你使用过其他语言,比如C、C++,类似整数的位数,其实是不确定的,可能在不同的平台,比如32位或者64位平台,存在非常大的不同。那么,在32位JDK或者64位JDK里,数据位数会有不同吗?或者说,这个问题可以扩展为,我使用32位JDK开发编译的程序,运行在64位JDK上,需要做什么特别的移植工作吗?

其实,这种移植对于Java来说相对要简单些,因为原始数据类型是不存在差异的,这些明确定义在Java语言规范里面,不管是32位还是64位环境,开发者无需担心数据的位数差异。

对于应用移植,虽然存在一些底层实现的差异,比如64位HotSpot JVM里的对象要比32位HotSpot JVM大(具体区别取决于不同JVM实现的选择),但是总体来说,并没有行为差异,应用移植还是可以做到宣称的 "一次书写,到处执行 ",应用开发者更多需要考虑的是容量、能力等方面的差异。

3.原始类型线程安全
前面提到了线程安全设计,你有没有想过,原始数据类型操作是不是线程安全的呢?

这里可能存在着不同层面的问题:

  • 原始数据类型的变量,显然要使用并发相关手段,才能保证线程安全,这些我会在专栏后面的并发主题详细介绍。如果有线程安全的计算需要,建议考虑使用类似AtomicInteger、AtomicLong这样的线程安全类。
  • 特别的是,部分比较宽的数据类型,比如float、double,甚至不能保证更新操作的原子性,可能出现程序读取到只更新了一半数据位的数值!

4.Java原始数据类型和引用类型局限性
前面我谈了非常多的技术细节,最后再从Java平台发展的角度来看看,原始数据类型、对象的局限性和演进。
对于Java应用开发者,设计复杂而灵活的类型系统似乎已经习以为常了。但是坦白说,毕竟这种类型系统的设计是源于很多年前的技术决定,现在已经逐渐暴露出了一些副作用,例如:

  • 原始数据类型和Java泛型并不能配合使用
    这是因为Java的泛型某种程度上可以算作伪泛型,它完全是一种编译期的技巧,Java编译期会自动将类型转换为对应的特定类型,这就决定了使用泛型,必须保证相应类型可以转换为Object。
  • 无法高效地表达数据,也不便于表达复杂的数据结构,比如vector和tuple
    我们知道Java的对象都是引用类型,如果是一个原始数据类型数组,它在内存里是一段连续的内存,而对象数组则不然,数据存储的是引用,对象往往是分散地存储在堆的不同位置。这种设计虽然带来了极大灵活性,但是也导致了数据操作的低效,尤其是无法充分利用现代CPU缓存机制。

Java为对象内建了各种多态、线程安全等方面的支持,但这不是所有场合的需求,尤其是数据处理重要性日益提高,更加高密度的值类型是非常现实的需求。

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

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

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

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

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

考点分析
似乎从我接触Java开始,这个问题就一直是经典的面试题,前面我的回答覆盖了三者的一些基本的设计和实现。

一般来说,也可以补充一下不同容器类型适合的场景:

  • Vector和ArrayList作为动态数组,其内部元素以数组形式顺序存储的,所以非常适合随机访问的场合。除了尾部插入和删除元素,往往性能会相对较差,比如我们在中间位置插入一个元素,需要移动后续所有元素。
  • 而LinkedList进行节点插入、删除却要高效得多,但是随机访问性能则要比动态数组慢。

所以,在应用开发中,如果事先可以估计到,应用操作是偏向于插入、删除,还是随机访问较多,就可以针对性的进行选择。这也是面试最常见的一个考察角度,给定一个场景,选择适合的数据结构,所以对于这种典型选择一定要掌握清楚。

考察Java集合框架,我觉得有很多方面需要掌握:

  • Java集合框架的设计结构,至少要有一个整体印象。
  • Java提供的主要容器(集合和Map)类型,了解或掌握对应的数据结构、算法,思考具体技术选择。
  • 将问题扩展到性能、并发等领域。
  • 集合框架的演进与发展。

作为Java专栏,我会在尽量围绕Java相关进行扩展,否则光是罗列集合部分涉及的数据结构就要占用很大篇幅。这并不代表那些不重要,数据结构和算法是基本功,往往也是必考的点,有些公司甚至以考察这些方面而非常知名(甚至是 臭名昭著 )。我这里以需要掌握典型排序算法为例,你至少需要熟知:

  • 内部排序,至少掌握基础算法如归并排序、交换排序(冒泡、快排)、选择排序、插入排序等。
  • 外部排序,掌握利用内存和外部存储处理超大数据集,至少要理解过程和思路。

考察算法不仅仅是如何简单实现,面试官往往会刨根问底,比如哪些是排序是不稳定的呢(快排、堆排),或者思考稳定意味着什么;对不同数据集,各种排序的最好或最差情况;从某个角度如何进一步优化(比如空间占用,假设业务场景需要最小辅助空间,这个角度堆排序就比归并优异)等,从简单的了解,到进一步的思考,面试官通常还会观察面试者处理问题和沟通时的思路。

以上只是一个方面的例子,建议学习相关书籍,如《算法导论》《编程珠玑》等,或相关教程。对于特定领域,比如推荐系统,建议咨询领域专家。单纯从面试的角度,很多朋友推荐使用一些算法网LeetCode等,帮助复习和准备面试,但坦白说我并没有刷过这些算法题,这也是仁者见仁智者见智的事情,招聘时我更倾向于考察面试者自身最擅长的东西,免得招到纯面试高手。

知识扩展
我们先一起来理解集合框架的整体设计,为了有个直观的印象,我画了一个简要的类图。注意,为了避免混淆,我这里没有把java.util.concurrent下面的线程安全容器添加进来;也没有列出Map容器,虽然通常概念上我们也会把Map作为集合框架的一部分,但是它本身并不是真正的集合(Collection)。

所以,我今天主要围绕狭义的集合框架,其他都会在专栏后面的内容进行讲解。
java核心知识_第4张图片
我们可以看到Java的集合框架,Collection接口是所有集合的根,然后扩展开提供了三大类集合,分别是:

  • List,也就是我们前面介绍最多的有序集合,它提供了方便的访问、插入、删除等操作。
  • Set,Set是不允许重复元素的,这是和List最明显的区别,也就是不存在两个对象equals返回true。我们在日常开发中有很多需要保证元素唯一性的场合。
  • Queue/Deque,则是Java提供的标准队列结构的实现,除了集合的基本功能,它还支持类似先入先出(FIFO, First-in-First-Out)或者后入先出(LIFO,Last-In-First-Out)等特定行为。这里不包括BlockingQueue,因为通常是并发编程场合,所以被放置在并发包里。

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

考点分析
上面的回答,只是对一些基本特征的简单总结,针对Map相关可以扩展的问题很多,从各种数据结构、典型应用场景,到程序设计实现的技术考量,尤其是在Java8里,HashMap本身发生了非常大的变化,这些都是经常考察的方面。

很多朋友向我反馈,面试官似乎钟爱考察HashMap的设计和实现细节,所以今天我会增加相应的源码解读,主要专注于下面几个方面:

  • 理解Map相关类似整体结构,尤其是有序数据结构的一些要点。
  • 从源码去分析HashMap的设计和实现要点,理解容量、负载因子等,为什么需要这些参数,如何影响Map的性能,实践中如何取舍等。
  • 理解树化改造的相关原理和改进原因。

除了典型的代码分析,还有一些有意思的并发相关问题也经常会被提到,如HashMap在并发环境可能出现无限循环占用CPU、size不准确等诡异的问题。

我认为这是一种典型的使用错误,因为HashMap明确声明不是线程安全的数据结构,如果忽略这一点,简单用在多线程场景里,难免会出现问题。

理解导致这种错误的原因,也是深入理解并发程序运行的好办法。对于具体发生了什么,你可以参考这篇很久以前的分析,里面甚至提供了示意图,我就不再重复别人写好的内容了。

知识扩展
1.Map整体结构

首先,我们先对Map相关类型有个整体了解,Map虽然通常被包括在Java集合框架里,但是其本身并不是狭义上的集合类型(Collection),具体你可以参考下面这个简单类图。
java核心知识_第5张图片
大部分使用Map的场景,通常就是放入、访问或者删除,而对顺序没有特别要求,HashMap在这种情况下基本是最好的选择。HashMap的性能表现非常依赖于哈希码的有效性,请务必掌握hashCode和equals的一些基本约定,比如:

  • equals相等,hashCode一定要相等。
  • 重写了hashCode也要重写equals。
  • hashCode需要保持一致性,状态改变返回的哈希值仍然要一致。
  • equals的对称、反射、传递等特性。

这方面内容网上有很多资料,我就不在这里详细展开了。
针对有序Map的分析内容比较有限,我再补充一些,虽然LinkedHashMap和TreeMap都可以保证某种顺序,但二者还是非常不同的。

LinkedHashMap通常提供的是遍历顺序符合插入顺序,它的实现是通过为条目(键值对)维护一个双向链表。注意,通过特定构造函数,我们可以创建反映访问顺序的实例,所谓的put、get、compute等,都算作访问。

这种行为适用于一些特定应用场景,例如,我们构建一个空间占用敏感的资源池,希望可以自动将最不常被访问的对象释放掉,这就可以利用LinkedHashMap提供的机制来实现,参考下面的示例:

对于TreeMap,它的整体顺序是由键的顺序关系决定的,通过Comparator或Comparable(自然顺序)来决定。

我在上一讲留给你的思考题提到了,构建一个具有优先级的调度系统的问题,其本质就是个典型的优先队列场景,Java标准库提供了基于二叉堆实现的PriorityQueue,它们都是依赖于同一种排序机制,当然也包括TreeMap的马甲TreeSet。

类似hashCode和equals的约定,为了避免模棱两可的情况,自然顺序同样需要符合一个约定,就是compareTo的返回值需要和equals一致,否则就会出现模棱两可情况。我们可以分析TreeMap的put方法实现:
java核心知识_第6张图片
从代码里,你可以看出什么呢? 当我不遵守约定时,两个不符合唯一性(equals)要求的对象被当作是同一个(因为,compareTo返回0),这会导致歧义的行为表现。

11 如何保证容器时线程安全的?ConcurrentHashMap如何实现高效地线程安全?

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

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

  • 各种并发容器,比如ConcurrentHashMap、CopyOnWriteArrayList。
  • 各种线程安全队列(Queue/Deque),如ArrayBlockingQueue、SynchronousQueue。
  • 各种有序容器的线程安全版本等。
    具体保证线程安全的方式,包括有从简单的synchronize方式,到基于更加精细化的,比如基于分离锁实现的ConcurrentHashMap等并发实现等。具体选择要看开发的场景需求,总体来说,并发包内提供的容器通用场景,远优于早期的简单同步实现。

考点分析
谈到线程安全和并发,可以说是Java面试中必考的考点,我上面给出的回答是一个相对宽泛的总结,而且ConcurrentHashMap等并发容器实现也在不断演进,不能一概而论。
如果要深入思考并回答这个问题及其扩展方面,至少需要:

  • 理解基本的线程安全工具。
  • 理解传统集合框架并发编程中Map存在的问题,清楚简单同步方式的不足。
  • 梳理并发包内,尤其是ConcurrentHashMap采取了哪些方法来提高并发表现。
  • 最好能够掌握ConcurrentHashMap自身的演进,目前的很多分析资料还是基于其早期版本。
    今天我主要是延续专栏之前两讲的内容,重点解读经常被同时考察的HashMap和ConcurrentHashMap。今天这一讲并不是对并发方面的全面梳理,毕竟这也不是专栏一讲可以介绍完整的,算是个开胃菜吧,类似CAS等更加底层的机制,后面会在Java进阶模块中的并发主题有更加系统的介绍。

知识扩展
1.为什么需要ConcurrentHashMap?
Hashtable本身比较低效,因为它的实现基本就是将put、get、size等各种方法加上“synchronized”。简单来说,这就导致了所有并发操作都要竞争同一把锁,一个线程在进行同步操作时,其他线程只能等待,大大降低了并发操作的效率。

前面已经提过HashMap不是线程安全的,并发情况会导致类似CPU占用100%等一些问题,那么能不能利用Collections提供的同步包装器来解决问题呢?
看看下面的代码片段,我们发现同步包装器只是利用输入Map构造了另一个同步版本,所有操作虽然不再声明成为synchronized方法,但是还是利用了“this”作为互斥的mutex,没有真正意义上的改进!

java核心知识_第7张图片
所以,Hashtable或者同步包装版本,都只是适合在非高度并发的场景下。

2.ConcurrentHashMap分析
我们再来看看ConcurrentHashMap是如何设计实现的,为什么它能大大提高并发效率。
首先,我这里强调,ConcurrentHashMap的设计实现其实一直在演化,比如在Java 8中就发生了非常大的变化(Java 7其实也有不少更新),所以,我这里将比较分析结构、实现机制等方面,对比不同版本的主要区别。

早期ConcurrentHashMap,其实现是基于:

  • 分离锁,也就是将内部进行分段(Segment),里面则是HashEntry的数组,和HashMap类似,哈希相同的条目也是以链表形式存放。
  • HashEntry内部使用volatile的value字段来保证可见性,也利用了不可变对象的机制以改进利用Unsafe提供的底层能力,比如volatile access,去直接完成部分操作,以最优化性能,毕竟Unsafe中的很多操作都是JVM intrinsic优化过的。

你可以参考下面这个早期ConcurrentHashMap内部结构的示意图,其核心是利用分段设计,在进行并发操作的时候,只需要锁定相应段,这样就有效避免了类似Hashtable整体同步的问题,大大提高了性能。

java核心知识_第8张图片
在构造的时候,Segment的数量由所谓的concurrentcyLevel决定,默认是16,也可以在相应构造函数直接指定。注意,Java需要它是2的幂数值,如果输入是类似15这种非幂值,会被自动调整到16之类2的幂数值。
具体情况,我们一起看看一些Map基本操作的源码,这是JDK 7比较新的get代码。针对具体的优化部分,为方便理解,我直接注释在代码段里,get操作需要保证的是可见性,所以并没有什么同步逻辑。
java核心知识_第9张图片
而对于put操作,首先是通过二次哈希避免哈希冲突,然后以Unsafe调用方式,直接获取相应的Segment,然后进行线程安全的put操作:
java核心知识_第10张图片
java核心知识_第11张图片
所以,从上面的源码清晰的看出,在进行并发写操作时:

  • ConcurrentHashMap会获取再入锁,以保证数据一致性,Segment本身就是基于ReentrantLock的扩展实现,所以,在并发修改期间,相应Segment是被锁定的。
  • 在最初阶段,进行重复性的扫描,以确定相应key值是否已经在数组里面,进而决定是更新还是放置操作,你可以在代码里看到相应的注释。重复扫描、检测冲突是ConcurrentHashMap的常见技巧。
  • 我在专栏上一讲介绍HashMap时,提到了可能发生的扩容问题,在ConcurrentHashMap中同样存在。不过有一个明显区别,就是它进行的不是整体的扩容,而是单独
    对Segment进行扩容,细节就不介绍了。

另外一个Map的size方法同样需要关注,它的实现涉及分离锁的一个副作用。
试想,如果不进行同步,简单的计算所有Segment的总值,可能会因为并发put,导致结果不准确,但是直接锁定所有Segment进行计算,就会变得非常昂贵。其实,分离锁也限制了Map的初始化等操作。

所以,ConcurrentHashMap的实现是通过重试机制(RETRIES_BEFORE_LOCK,指定重试次数2),来试图获得可靠值。如果没有监控到发生变化(通过对比Segment.modCount),就直接返回,否则获取锁进行操作。

下面我来对比一下,在Java 8和之后的版本中,ConcurrentHashMap发生了哪些变化呢?

  • 总体结构上,它的内部存储变得和我在专栏上一讲介绍的HashMap结构非常相似,同样是大的桶(bucket)数组,然后内部也是一个个所谓的链表结构(bin),同步的粒度要更细致一些。
  • 其内部仍然有Segment定义,但仅仅是为了保证序列化时的兼容性而已,不再有任何结构上的用处。
  • 因为不再使用Segment,初始化操作大大简化,修改为lazy-load形式,这样可以有效避免初始开销,解决了老版本很多人抱怨的这一点。
  • 数据存储利用volatile来保证可见性。
  • 使用CAS等操作,在特定场景进行无锁并发操作。
  • 使用Unsafe、LongAdder之类底层手段,进行极端情况的优化。

先看看现在的数据存储内部实现,我们可以发现Key是final的,因为在生命周期中,一个条目的Key发生变化是不可能的;与此同时val,则声明为volatile,以保证可见性。
java核心知识_第12张图片
我这里就不再介绍get方法和构造函数了,相对比较简单,直接看并发的put是如何实现的。
java核心知识_第13张图片
初始化操作实现在initTable里面,这是一个典型的CAS使用场景,利用volatile的sizeCtl作为互斥手段:如果发现竞争性的初始化,就spin在那里,等待条件恢复;否则利用CAS设置排他标志。如果成功则进行初始化;否则重试。
java核心知识_第14张图片
当bin为空时,同样是没有必要锁定,也是以CAS操作去放置。你有没有注意到,在同步逻辑上,它使用的是synchronized,而不是通常建议的ReentrantLock之类,这是为什么呢?现代JDK中,synchronized已经被不断优化,可以不再过分担心性能差异,另外,相比于ReentrantLock,它可以减少内存消耗,这是个非常大的优势。
.与此同时,更多细节实现通过使用Unsafe进行了优化,例如tabAt就是直接利用getObjectAcquire,避免间接调用的开销。
在这里插入图片描述
再看看,现在是如何实现size操作的。阅读代码你会发现,真正的逻辑是在sumCount方法中, 那么sumCount做了什么呢?
java核心知识_第15张图片
我们发现,虽然思路仍然和以前类似,都是分而治之的进行计数,然后求和处理,但实现却基于一个奇怪的CounterCell 难道它的数值,就更加准确吗?数据一致性是怎么保证的?

在这里插入图片描述
其实,对于CounterCell的操作,是基于java.util.concurrent.atomic.LongAdder进行的,是一种JVM利用空间换取更高效率的方法,利用了Striped64内部的复杂逻辑。这个东西非常小众,大多数情况下,建议还是使用AtomicLong,足以满足绝大部分应用的性能需求。
今天我从线程安全问题开始,概念性的总结了基本容器工具,分析了早期同步容器的问题,进而分析了Java 7和Java 8中ConcurrentHashMap是如何设计实现的,希望ConcurrentHashMap的并发技巧对你在日常开发可以有所帮助。

12 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操作基于事件和回调机制,可以简单理解为,应用操作直接返回,而不会阻塞在那里,当后台处理完成,操作系统会通知相应线程进行后续工作。

13 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);count -= transferred;
			}
		}
}

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

考点分析
今天这个问题,从面试的角度来看,确实是一个面试考察的点,针对我上面的典型回答,面试官还可能会从实践角度,或者IO底层实现机制等方面进一步提问。这一讲的内容从面试题出发,主要还是为了让你进一步加深对Java IO类库设计和实现的了解。
从实践角度,我前面并没有明确说NIO transfer的方案一定最快,真实情况也确实未必如此。我们可以根据理论分析给出可行的推断,保持合理的怀疑,给出验证结论的思路,有时
候面试官考察的就是如何将猜测变成可验证的结论,思考方式远比记住结论重要。

从技术角度展开,下面这些方面值得注意:

  • 不同的copy方式,底层机制有什么区别?
  • 为什么零拷贝(zero-copy)可能有性能优势?
  • Buffer分类与使用。
  • Direct Buffer对垃圾收集等方面的影响与实践选择。

接下来,我们一起来分析一下吧。
知识扩展
1.拷贝实现机制分析
先来理解一下,前面实现的不同拷贝方法,本质上有什么明显的区别。
首先,你需要理解用户态空间(User Space)和内核态空间(Kernel Space),这是操作系统层面的基本概念,操作系统内核、硬件驱动等运行在内核态空间,具有相对高的特权;而用户态空间,则是给普通应用和服务使用。你可以参考:https://en.wikipedia.org/wiki/User_space。

当我们使用输入输出流进行读写时,实际上是进行了多次上下文切换,比如应用读取数据时,先在内核态将数据从磁盘读取到内核缓存,再切换到用户态将数据从内核缓存读取到用户缓存。

写入操作也是类似,仅仅是步骤相反,你可以参考下面这张图。
java核心知识_第16张图片
所以,这种方式会带来一定的额外开销,可能会降低IO效率。

而基于NIO transferTo的实现方式,在Linux和Unix上,则会使用到零拷贝技术,数据传输并不需要用户态参与,省去了上下文切换的开销和不必要的内存拷贝,进而可能提高应用拷贝性能。注意,transferTo不仅仅是可以用在文件拷贝中,与其类似的,例如读取磁盘文件,然后进行Socket发送,同样可以享受这种机制带来的性能和扩展性提高。

transferTo的传输过程是:
java核心知识_第17张图片
2.Java IO/NIO源码结构
前面我在典型回答中提了第三种方式,即Java标准库也提供了文件拷贝方法(java.nio.fle.Files.copy)。如果你这样回答,就一定要小心了,因为很少有问题的答案是仅仅调用某个方法。从面试的角度,面试官往往会追问:既然你提到了标准库,那么它是怎么实现的呢?有的公司面试官以喜欢追问而出名,直到追问到你说不知道。

其实,这个问题的答案还真不是那么直观,因为实际上有几个不同的copy方法。

public satic Path copy(Path source, Path target, CopyOption... options)
throws IOException

public satic long copy(InputStream in, Path target, CopyOption... options)
throws IOException

public satic long copy(Path source, OutputStream out)throws IOException

可以看到,copy不仅仅是支持文件之间操作,没有人限定输入输出流一定是针对文件的,这是两个很实用的工具方法。

后面两种copy实现,能够在方法实现里直接看到使用的是InputStream.transferTo(),你可以直接看源码,其内部实现其实是stream在用户态的读写;而对于第一种方法的分析过程要相对麻烦一些,可以参考下面片段。简单起见,我只分析同类型文件系统拷贝过程。

public satic Path copy(Path source, Path target, CopyOption... options)
throws IOException
{
	FileSysemProvider provider = provider(source);
	if (provider(target) == provider) {
	// same provider
	provider.copy(source, target, options);//这是本文分析的路径
	} else {
	// diferent providers
	CopyMoveHelper.copyToForeignTarget(source, target, options);
	}
	return target;
}

我把源码分析过程简单记录如下,JDK的源代码中,内部实现和公共API定义也不是可以能够简单关联上的,NIO部分代码甚至是定义为模板而不是Java源文件,在build过程自动生成源码,下面顺便介绍一下部分JDK代码机制和如何绕过隐藏障碍。

  • 首先,直接跟踪,发现FileSystemProvider只是个抽象类,阅读它的源码能够理解到,原来文件系统实际逻辑存在于JDK内部实现里,公共API其实是通过ServiceLoader机制加载一系列文件系统实现,然后提供服务。
  • 我们可以在JDK源码里搜索FileSystemProvider和nio,可以定位到sun/nio/fs,我们知道NIO底层是和操作系统紧密相关的,所以每个平台都有自己的部分特有文件系统逻辑。
  • 省略掉一些细节,最后我们一步步定位到UnixFileSystemProvider → UnixCopyFile.Transfer,发现这是个本地方法。
  • 最后,明确定位到UnixCopyFile.c,其内部实现清楚说明竟然只是简单的用户态空间拷贝!

java核心知识_第18张图片

所以,我们明确这个最常见的copy方法其实不是利用transferTo,而是本地技术实现的用户态拷贝。
前面谈了不少机制和源码,我简单从实践角度总结一下,如何提高类似拷贝等IO操作的性能,有一些宽泛的原则:

前面谈了不少机制和源码,我简单从实践角度总结一下,如何提高类似拷贝等IO操作的性能,有一些宽泛的原则:

  • 在程序中,使用缓存等机制,合理减少IO次数(在网络通信中,如TCP传输,window大小也可以看作是类似思路)。
  • 使用transferTo等机制,减少上下文切换和额外IO操作。
  • 尽量减少不必要的转换过程,比如编解码;对象序列化和反序列化,比如操作文本文件或者网络通信,如果不是过程中需要使用文本信息,可以考虑不要将二进制信息转换成字符串,直接传输二进制信息。

3.掌握NIO Buffer

我在上一讲提到Buffer是NIO操作数据的基本工具,Java为每种原始数据类型都提供了相应的Buffer实现(布尔除外),所以掌握和使用Buffer是十分必要的,尤其是涉及Direct Buffer等使用,因为其在垃圾收集等方面的特殊性,更要重点掌握。
java核心知识_第19张图片
Buffer有几个基本属性:

  • capcity,它反映这个Buffer到底有多大,也就是数组的长度。
  • position,要操作的数据起始位置。
  • limit,相当于操作的限额。在读取或者写入时,limit的意义很明显是不一样的。比如,读取操作时,很可能将limit设置到所容纳数据的上限;而在写入时,则会设置容量或容量以下的可写限度。
  • mark,记录上一次postion的位置,默认是0,算是一个便利性的考虑,往往不是必须的。

前面三个是我们日常使用最频繁的,我简单梳理下Bufer的基本操作:

  • 我们创建了一个ByteBufer,准备放入数据,capcity当然就是缓冲区大小,而position就是0,limit默认就是capcity的大小。
  • 当我们写入几个字节的数据时,position就会跟着水涨船高,但是它不可能超过limit的大小。
  • 如果我们想把前面写入的数据读出来,需要调用fip方法,将position设置为0,limit设置为以前的position那里。
  • 如果还想从头再读一遍,可以调用rewind,让limit不变,position再次设置为0。

4.Direct Buffer和垃圾收集
我这里重点介绍两种特别的Bufer。

  • Direct Buffer:如果我们看Buffer的方法定义,你会发现它定义了isDirect()方法,返回当前Bufer是否是Direct类型。这是因为Java提供了堆内和堆外(Direct)Buffer,我们可以以它的allocate或allocateDirect方法直接创建。
  • MappedByteBuffer:它将文件按照指定大小直接映射为内存区域,当程序访问这个内存区域时将直接操作这块儿文件数据,省去了将数据从内核空间向用户空间传输的损耗。我们可以使用FileChannel.map创建MappedByteBuffer,它本质上也是种Direct Buffer。

但是请注意,Direct Bufer创建和销毁过程中,都会比一般的堆内Bufer增加部分开销,所以通常都建议用于长期使用、数据较大的场景。
使用Direct Bufer,我们需要清楚它对内存和JVM参数的影响。首先,因为它不在堆上,所以Xmx之类参数,其实并不能影响Direct Bufer等堆外成员所使用的内存额度,我们可以使用下面参数设置大小:
-XX:MaxDirectMemorySize=512M

从参数设置和内存问题排查角度来看,这意味着我们在计算Java可以使用的内存大小的时候,不能只考虑堆的需要,还有Direct Buffer等一系列堆外因素。如果出现内存不足,堆外内存占用也是一种可能性。

另外,大多数垃圾收集过程中,都不会主动收集Direct Buffer,它的垃圾收集过程,就是基于我在专栏前面所介绍的Cleaner(一个内部实现)和幻象引用(PhantomReference)机制,其本身不是public类型,内部实现了一个Deallocator负责销毁的逻辑。对它的销毁往往要拖到full GC的时候,所以使用不当很容易导致OutOfMemoryError。

对于Direct Bufer的回收,我有几个建议:

  • 在应用程序中,显式地调用System.gc()来强制触发。
  • 另外一种思路是,在大量使用Direct Buffer的部分框架中,框架会自己在程序中调用释放方法,Netty就是这么做的,有兴趣可以参考其实现(PlatformDependent0)。
  • 重复使用Direct Buffer

5.跟踪和诊断Direct Bufer内存占用?

因为通常的垃圾收集日志等记录,并不包含Direct Bufer等信息,所以Direct Bufer内存诊断也是个比较头疼的事情。幸好,在JDK 8之后的版本,我们可以方便地使用Native Memory Tracking(NMT)特性来进行诊断,你可以在程序启动时加上下面参数:

-XX:NativeMemoryTracking={summary|detail}

注意,激活NMT通常都会导致JVM出现5%~10%的性能下降,请谨慎考虑。运行时,可以采用下面命令进行交互式对比:
java核心知识_第20张图片
我们可以在Internal部分发现Direct Bufer内存使用的信息,这是因为其底层实际是利用unsafe_allocatememory。严格说,这不是JVM内部使用的内存,所以在JDK 11以后,其实它是归类在other部分里。

JDK 9的输出片段如下,“+”表示的就是diff命令发现的分配变化:

在这里插入图片描述
注意:JVM的堆外内存远不止Direct Bufer,NMT输出的信息当然也远不止这些,我在专栏后面有综合分析更加具体的内存结构的主题。
今天我分析了Java IO/NIO底层文件操作数据的机制,以及如何实现零拷贝的高性能操作,梳理了Bufer的使用和类型,并针对Direct Bufer的生命周期管理和诊断进行了较详细的分析。

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

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

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

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

Java类实现interface使用implements关键词,继承abstract class则是使用extends关键词,我们可以参考Java标准库中的ArrayList。
java核心知识_第21张图片
考点分析
这是个非常高频的Java面向对象基础问题,看起来非常简单的问题,如果面试官稍微深入一些,你会发现很多有意思的地方,可以从不同角度全面地考察你对基本机制的理解和掌握。比如:

  • 对于Java的基本元素的语法是否理解准确。能否定义出语法基本正确的接口、抽象类或者相关继承实现,涉及重载(Overload)、重写(Override)更是有各种不同的题目。
  • 在软件设计开发中妥善地使用接口和抽象类。你至少知道典型应用场景,掌握基础类库重要接口的使用;掌握设计方法,能够在review代码的时候看出明显的不利于未来维护的设计。
  • 掌握Java语言特性演进。现在非常多的框架已经是基于Java 8,并逐渐支持更新版本,掌握相关语法,理解设计目的是很有必要的。

知识扩展
我会从接口、抽象类的一些实践,以及语言变化方面去阐述一些扩展知识点。

Java相比于其他面向对象语言,如C++,设计上有一些基本区别,比如Java不支持多继承。这种限制,在规范了代码实现的同时,也产生了一些局限性,影响着程序设计结构。Java类可以实现多个接口,因为接口是抽象方法的集合,所以这是声明性的,但不能通过扩展多个抽象类来重用逻辑。

在一些情况下存在特定场景,需要抽象出与具体实现、实例化无关的通用逻辑,或者纯调用关系的逻辑,但是使用传统的抽象类会陷入到单继承的窘境。以往常见的做法是,实现由静态方法组成的工具类(Utils),比如java.util.Collections。

设想,为接口添加任何抽象方法,相应的所有实现了这个接口的类,也必须实现新增方法,否则会出现编译错误。对于抽象类,如果我们添加非抽象方法,其子类只会享受到能力扩展,而不用担心编译出问题。

接口的职责也不仅仅限于抽象方法的集合,其实有各种不同的实践。有一类没有任何方法的接口,通常叫作Marker Interface,顾名思义,它的目的就是为了声明某些东西,比如我们熟知的Cloneable、Serializable等。这种用法,也存在于业界其他的Java产品代码中。

从表面看,这似乎和Annotation异曲同工,也确实如此,它的好处是简单直接。对于Annotation,因为可以指定参数和值,在表达能力上要更强大一些,所以更多人选择使用Annotation。

Java 8增加了函数式编程的支持,所以又增加了一类定义,即所谓functional interface,简单说就是只有一个抽象方法的接口,通常建议使用@FunctionalInterfaceAnnotation来标记。Lambda表达式本身可以看作是一类functional interface,某种程度上这和面向对象可以算是两码事。我们熟知的Runnable、Callable之类,都是functional interface,这里不再多介绍了,有兴趣你可以参考:https://www.oreilly.com/learning/java-8-functional-interfaces 。

还有一点可能让人感到意外,严格说,Java 8以后,接口也是可以有方法实现的!

从Java 8开始,interface增加了对default method的支持。Java 9以后,甚至可以定义private default method。Default method提供了一种二进制兼容的扩展已有接口的办法。比如,我们熟知的java.util.Collection,它是collection体系的root interface,在Java 8中添加了一系列default method,主要是增加Lambda、Stream相关的功能。我在专栏前面提到的类似Collections之类的工具类,很多方法都适合作为default method实现在基础接口里面。

你可以参考下面代码片段:
java核心知识_第22张图片
面向对象设计

谈到面向对象,很多人就会想起设计模式,那些是非常经典的问题和设计方法的总结。我今天来夯实一下基础,先来聊聊面向对象设计的基本方面。

我们一定要清楚面向对象的基本要素:封装、继承、多态。

封装的目的是隐藏事务内部的实现细节,以便提高安全性和简化编程。封装提供了合理的边界,避免外部调用者接触到内部的细节。我们在日常开发中,因为无意间暴露了细节导致的难缠bug太多了,比如在多线程环境暴露内部状态,导致的并发修改问题。从另外一个角度看,封装这种隐藏,也提供了简化的界面,避免太多无意义的细节浪费调用者的精力。

继承是代码复用的基础机制,类似于我们对于马、白马、黑马的归纳总结。但要注意,继承可以看作是非常紧耦合的一种关系,父类代码修改,子类行为也会变动。在实践中,过度滥用继承,可能会起到反效果。

多态,你可能立即会想到重写(override)和重载(overload)、向上转型。简单说,重写是父子类中相同名字和参数的方法,不同的实现;重载则是相同名字的方法,但是不同的参数,本质上这些方法签名是不一样的,为了更好说明,请参考下面的样例代码:

java核心知识_第23张图片
这里你可以思考一个小问题,方法名称和参数一致,但是返回值不同,这种情况在Java代码中算是有效的重载吗? 答案是不是的,编译都会出错的。

进行面向对象编程,掌握基本的设计原则是必须的,我今天介绍最通用的部分,也就是所谓的S.O.L.I.D原则。

  • 单一职责(SingleResponsibility),类或者对象最好是只有单一职责,在程序设计中如果发现某个类承担着多种义务,可以考虑进行拆分。
  • 开关原则(Open-Close, Open for extension, close for modifcation),设计要对扩展开放,对修改关闭。换句话说,程序设计应保证平滑的扩展性,尽量避免因为新增同类功能而修改已有实现,这样可以少产出些回归(regression)问题。
  • 里氏替换(Liskov Substitution),这是面向对象的基本要素之一,进行继承关系抽象时,凡是可以用父类或者基类的地方,都可以用子类替换。
  • 接口分离(Interface Segregation),我们在进行类和接口设计时,如果在一个接口里定义了太多方法,其子类很可能面临两难,就是只有部分方法对它是有意义的,这就破坏了程序的内聚性。对于这种情况,可以通过拆分成功能单一的多个接口,将行为进行解耦。在未来维护中,如果某个接口设计有变,不会对使用其他接口的子类构成影响。
  • 依赖反转(Dependency Inversion),实体应该依赖于抽象而不是实现。也就是说高层次模块,不应该依赖于低层次模块,而是应该基于抽象。实践这一原则是保证产品代码之
    间适当耦合度的法宝。

OOP原则实践中的取舍
值得注意的是,现代语言的发展,很多时候并不是完全遵守前面的原则的,比如,Java 10中引入了本地方法类型推断和var类型。按照,里氏替换原则,我们通常这样定义变量:

在这里插入图片描述

如果使用var类型,可以简化为:
java核心知识_第24张图片

理论上,这种语法上的便利,其实是增强了程序对实现的依赖,但是微小的类型泄漏却带来了书写的变量和代码可读性的提高,所以,实践中我们还是要按照得失利弊进行选择,而不是一味得遵循原则。

OOP原则在面试题目中的分析

我在以往面试中发现,即使是有多年编程经验的工程师,也还没有真正掌握面向对象设计的基本的原则,如开关原则(Open-Close)。看看下面这段代码,改编自朋友圈盛传的某伟大公司产品代码,你觉得可以利用面向对象设计原则如何改进?

java核心知识_第25张图片
这段代码的一个问题是,业务逻辑集中在一起,当出现新的用户类型时,比如,大数据发现了我们是肥羊,需要去收获一下, 这就需要直接去修改服务方法代码实现,这可能会意外影响不相关的某个用户类型逻辑。

利用开关原则,我们可以尝试改造为下面的代码:

java核心知识_第26张图片
上面的示例,将不同对象分类的服务方法进行抽象,把业务逻辑的紧耦合关系拆开,实现代码的隔离保证了方便的扩展。
今天我对Java面向对象技术进行了梳理,对比了抽象类和接口,分析了Java语言在接口层面的演进和相应程序设计实现,最后回顾并实践了面向对象设计的基本原则,希望对你有所帮助。

14 谈谈设计模式

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

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

15 synchronized和ReentranLock有什么区别呢?

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

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

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

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

考点分析
今天的题目是考察并发编程的常见基础题,我给出的典型回答算是一个相对全面的总结。对于并发编程,不同公司或者面试官面试风格也不一样,有个别大厂喜欢一直追问你相关机制的扩展或者底层,有的喜欢从实用角度出发,所以你在准备并发编程方面需要一定的耐心。

我认为,锁作为并发的基础工具之一,你至少需要掌握:

  • 理解什么是线程安全。
  • synchronized、ReentrantLock等机制的基本使用与案例。

更近一步,你还需要:

  • 掌握synchronized、ReentrantLock底层实现;理解锁膨胀、降级;理解偏斜锁、自旋锁、轻量级锁、重量级锁等概念。
  • 掌握并发包中java.util.concurrent.lock各种不同实现和案例分析。

知识扩展
专栏前面几期穿插了一些并发的概念,有同学反馈理解起来有点困难,尤其对一些并发相关概念比较陌生,所以在这一讲,我也对会一些基础的概念进行补充。

首先,我们需要理解什么是线程安全。
我建议阅读Brain Goetz等专家撰写的《Java并发编程实战》(Java Concurrency in Practice),虽然可能稍显学究,但不可否认这是一本非常系统和全面的Java并发编程书
籍。按照其中的定义,线程安全是一个多线程环境下正确性的概念,也就是保证多线程环境下共享的、可修改的状态的正确性,这里的状态反映在程序中其实可以看作是数据。

换个角度来看,如果状态不是共享的,或者不是可修改的,也就不存在线程安全问题,进而可以推理出保证线程安全的两个办法:

  • 封装:通过封装,我们可以将对象内部状态隐藏、保护起来。.
  • 不可变:还记得我们在专栏第3讲强调的final和immutable吗,就是这个道理,Java语言目前还没有真正意义上的原生不可变,但是未来也许会引入。

线程安全需要保证几个基本特性:

  • 原子性,简单说就是相关操作不会中途被其他线程干扰,一般通过同步机制实现。
  • 可见性,是一个线程修改了某个共享变量,其状态能够立即被其他线程知晓,通常被解释为将线程本地状态反映到主内存上,volatile就是负责保证可见性的。
  • 有序性,是保证线程内串行语义,避免指令重排等。

你可能感兴趣的:(java)