java核心技术36讲

一.谈谈你对Java平台的理解

  1. Java两大特性(a.write once, run anywhere; b.GC)

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

  3. 在运行时,JVM 会通过类加载器(Class-Loader)加载字节码,解释或者编译执行。就像我前面提到的,主流 Java 版本中,如 JDK 8 实际是解释和编译混合的一种模式,即所谓的混合模式(-Xmixed)。通常运行在 server 模式的 JVM,会进行上万次调用以收集足够的信息进行高效的编译,client 模式这个门限是 1500 次。Oracle Hotspot JVM 内置了两个不同的 JIT compiler,C1 对应前面说的 client 模式,适用于对于启动速度敏感的应用,比如普通 Java 桌面应用;C2 对应 server 模式,它的优化是为长时间运行的服务器端应用设计的。默认是采用所谓的分层编译(TieredCompilation)

  4. Java 虚拟机启动时,可以指定不同的参数对运行模式进行选择。 比如,指定“-Xint”,就是告诉 JVM 只进行解释执行,不对代码进行编译,这种模式抛弃了 JIT 可能带来的性能优势。毕竟解释器(interpreter)是逐条读入,逐条解释运行的。与其相对应的,还有一个“-Xcomp”参数,这是告诉 JVM 关闭解释器,不要进行解释执行,或者叫作最大优化级别。那你可能会问这种模式是不是最高效啊?简单说,还真未必。“-Xcomp”会导致 JVM 启动变慢非常多,同时有些 JIT 编译器优化方式,比如分支预测,如果不进行 profiling,往往并不能进行有效优化。注:JIT为方法级,它会缓存编译过的字节码在CodeCache中,而不需要被重复解释

  5. 除了我们日常最常见的 Java 使用模式,其实还有一种新的编译方式,即所谓的 AOT(Ahead-of-Time Compilation),直接将字节码编译成机器代码,这样就避免了 JIT 预热等各方面的开销,比如 Oracle JDK 9 就引入了实验性的 AOT 特性,并且增加了新的 jaotc 工具。

    利用下面的命令把某个类或者某个模块编译成为 AOT 库。

    jaotc --output libHelloWorld.so HelloWorld.class
    jaotc --output libjava.base.so --module java.base
    

    然后,在启动时直接指定就可以了。而且,Oracle JDK 支持分层编译和 AOT 协作使用,这两者并不是二选一的关系。 如果你有 兴趣,可以参考相关文档:http://openjdk.java.net/jeps/295。AOT 也不仅仅是只有这一种方式,业界早就有第三方工具(如 GCJ、Excelsior JET)提供相关功能。

    java -XX:AOTLibrary=./libHelloWorld.so,./libjava.base.so HelloWorld
    
  6. “一次编译、到处运行”说的是Java语言跨平台的特性,Java的跨平台特性与Java虚拟机的存在密不可分,可在不同的环境中运行。比如说Windows平台和Linux平台都有相应的JDK,安装好JDK后也就有了Java语言的运行环境。其实Java语言本身与其他的编程语言没有特别大的差异,并不是说Java语言可以跨平台,而是在不同的平台都有可以让Java语言运行的环境而已,所以才有了Java一次编译,到处运行这样的效果。 严格的讲,跨平台的语言不止Java一种,但Java是较为成熟的一种。“一次编译,到处运行”这种效果跟编译器有关。编程语言的处理需要编译器和解释器。Java虚拟机和DOS类似,相当于一个供程序运行的平台。 程序从源代码到运行的三个阶段:编码——编译——运行——调试。Java在编译阶段则体现了跨平台的特点。编译过程大概是这样的:首先是将Java源代码转化成.CLASS文件字节码,这是第一次编译。.class文件就是可以到处运行的文件。然后Java字节码会被转化为目标机器代码,这是是由JVM来执行的,即Java的第二次编译。 “到处运行”的关键和前提就是JVM。因为在第二次编译中JVM起着关键作用。在可以运行Java虚拟机的地方都内含着一个JVM操作系统。从而使JAVA提供了各种不同平台上的虚拟机制,因此实现了“到处运行”的效果。需要强调的一点是,java并不是编译机制,而是解释机制。Java字节码的设计充分考虑了JIT这一即时编译方式,可以将字节码直接转化成高性能的本地机器码,这同样是虚拟机的一个构成部分。

  7. 我对『Compile once, run anywhere』这个宣传语提出的历史背景非常感兴趣。这个宣传语似乎在暗示 C 语言有一个缺点:对于每一个不同的平台,源代码都要被编译一次。我不解的地方是,为什么这会是一个问题?不同的平台,可执行的机器码必然是不一样的。源代码自然需要依据不同的平台分别被编译。 我觉得真正问题不在编译这一块,而是在 C 语言源文件这一块。我没有 C 语言的编程经验,但是似乎 C 语言程序经常需要调用操作系统层面的 API。不同的操作系统,API 一般不同。为了支持多平台,C 语言程序的源文件需要根据不同平台修改多次。这应该是一个非常大的痛点。我回头查了一下当时的宣传语,原文是『Write once, run anywhere』,焦点似乎并不在编译上,而是在对源文件的修改上。

  8. 宏观角度:跟c/c++最大的不同点在于,c/c++编程是面向操作系统的,需要开发者极大地关心不同操作系统之间的差异性;而Java平台通过虚拟机屏蔽了操作系统的底层细节,使得开发者无需过多地关心不同操作系统之间的差异性。通过增加一个间接的中间层来进行”解耦“是计算机领域非常常用的一种”艺术手法“,虚拟机是这样,操作系统是这样,HTTP也是这样。Java平台已经形成了一个生态系统,在这个生态系统中,有着诸多的研究领域和应用领域:(1). 虚拟机、编译技术的研究(例如:GC优化、JIT、AOT等):对效率的追求是人类的另一个天性之一;(2). Java语言本身的优化;(3). 大数据处理;(4). Java并发编程(5). 客户端开发(例如:Android平台)

  9. 微观角度:Java平台中有两大核心:

    • Java语言本身、JDK中所提供的核心类库和相关工具

    从事Java平台的开发,掌握Java语言、核心类库以及相关工具是必须的,这是基础中的基础。对语言本身的了解,需要开发者非常熟悉语言的语法结构;而Java又是一种面对对象的语言,这又需要开发者深入了解面对对象的设计理念; Java核心类库包含集合类、线程相关类、IO、NIO、J.U.C并发包等;JDK提供的工具包含:基本的编译工具、虚拟机性能检测相关工具等。

    • Java虚拟机以及其他包含的GC

    Java语言具有跨平台的特性,也正是因为虚拟机的存在。Java源文件被编译成字节码,被虚拟机加载后执行。这里隐含的意思有两层:1)大部分情况下,编程者只需要关心Java语言本身,而无需特意关心底层细节。包括对内存的分配和回收,也全权交给了GC。2)对于虚拟机而言,只要是符合规范的字节码,它们都能被加载执行,当然,能正常运行的程序光满足这点是不行的,程序本身需要保证在运行时不出现异常。所以,Scala、Kotlin、Jython等语言也可以跑在虚拟机上。围绕虚拟机的效率问题展开,将涉及到一些优化技术,例如:JIT、AOT。因为如果虚拟机加载字节码后,完全进行解释执行,这势必会影响执行效率。所以,对于这个运行环节,虚拟机会进行一些优化处理,例如JIT技术,会将某些运行特别频繁的代码编译成机器码。而AOT技术,是在运行前,通过工具直接将字节码转换为机器码。

  10. JVM的内存模型,堆、栈、方法区;字节码的跨平台性;对象在JVM中的强引用,弱引用,软引用,虚引用,是否可用finalise方法救救它?;双亲委派进行类加载,什么是双亲呢?双亲就是多亲,一份文档由我加载,然后你也加载,这份文档在JVM中是一样的吗?;多态思想是Java需要最核心的概念,也是面向对象的行为的一个最好诠释;理解方法重载与重写在内存中的执行流程,怎么定位到这个具体方法的;发展流程,JDK5(重写bug),JDK6(商用最稳定版),JDK7(switch的字符串支持),JDK8(函数式编程),一直在发展进化;理解祖先类Object,它的行为是怎样与现实生活连接起来的。4,理解23种设计模式,因为它是道与术的结合体。

二、Exception和Error有什么区别

  1. Exception 和 Error 都是继承了 Throwable 类,在 Java 中只有 Throwable 类型的实例才可以被抛出(throw)或者捕获(catch),它是异常处理机制的基本组成类型。Exception 和 Error 体现了 Java 平台设计者对不同异常情况的分类。Exception 是程序正常运行中,可以预料的意外情况,可能并且应该被捕获,进行相应处理。Error 是指在正常情况下,不大可能出现的情况,绝大部分的 Error 都会导致程序(比如 JVM 自身)处于非正常的、不可恢复状态。既然是非正常情况,所以不便于也不需要捕获,常见的比如 OutOfMemoryError 之类,都是 Error 的子类。

  2. Exception 又分为可检查(checked)异常和不检查(unchecked)异常,可检查异常在源代码里必须显式地进行捕获处理,这是编译期检查的一部分。不可查的 Error是 Throwable 不是 Exception。不检查异常就是所谓的运行时异常,类似 NullPointerException、ArrayIndexOutOfBoundsException 之类,通常是可以编码避免的逻辑错误,具体根据需要来判断是否需要捕获,并不会在编译期强制要求。

  3. 理解 Throwable、Exception、Error 的设计和分类。比如,掌握那些应用最为广泛的子类,以及如何自定义异常等。其中有些子类型,最好重点理解一下,比如 NoClassDefFoundError 和 ClassNotFoundException 有什么区别,这也是个经典的入门题目。

img

  1. 理解 Java 语言中操作 Throwable 的元素和实践。掌握最基本的语法是必须的,如 try-catch-finally 块,throw、throws 关键字等。与此同时,也要懂得如何处理典型场景。异常处理代码比较繁琐,比如我们需要写很多千篇一律的捕获代码,或者在 finally 里面做一些资源回收工作。随着 Java 语言的发展,引入了一些更加便利的特性,比如 try-with-resourcesmultiple catch,具体可以参考下面的代码段。在编译时期,会自动生成相应的处理逻辑,比如,自动按照约定俗成 close 那些扩展了 AutoCloseable 或者 Closeable 的对象。

    try (BufferedReader br = new BufferedReader(…);
         BufferedWriter writer = new BufferedWriter(…)) {// Try-with-resources
    // do something
    catch ( IOException | XEception e) {// Multiple catch
       // Handle it
    } 
    
  2. 在稍微复杂一点的生产系统中,标准出错(STERR)不是个合适的输出选项,因为你很难判断出到底输出到哪里去了。尤其是对于分布式系统,如果发生异常,但是无法找到堆栈轨迹(stacktrace),这纯属是为诊断设置障碍。所以,最好使用产品日志,详细地输出到日志系统里。

  3. try-catch 代码段会产生额外的性能开销,或者换个角度说,它往往会影响 JVM 对代码进行优化,所以建议仅捕获有必要的代码段,尽量不要一个大的 try 包住整段的代码;与此同时,利用异常控制代码流程,也不是一个好主意,远比我们通常意义上的条件语句(if/else、switch)要低效。

  4. Java 每实例化一个 Exception,都会对当时的栈进行快照,这是一个相对比较重的操作。如果发生的非常频繁,这个开销可就不能被忽略了。

  5. 不要在finally代码块中处理返回值,按照我们程序员的惯性认知:当遇到return语句的时候,执行函数会立刻返回。但是,在Java语言中,如果存在finally就会有例外。除了return语句,try代码块中的break或continue语句也可能使控制权进入finally代码块。请勿在try代码块中调用return、break或continue语句。万一无法避免,一定要确保finally的存在不会改变函数的返回值。函数返回值有两种类型:值类型与对象引用。对于对象引用,要特别小心,如果在finally代码块中对函数返回的对象成员属性进行了修改,即使不在finally块中显式调用return语句,这个修改也会作用于返回值上。

  6. 勿将异常用于控制流。

  7. 至于响应式编程,我可以泛化为异步编程的概念嘛?一般各种异步编程框架都会对异常的传递和堆栈信息做处理吧?比如promise/future风格的。本质上大致就是把lambda中的异常捕获并封装,再进一步延续异步上下文,或者转同步处理时拿到原始的错误和堆栈信息,也可以泛化为异步编程的概念,比如Future Stage之类使用ExecutionException的思路

三、谈谈Final、Finally和Finalize有什么不同

  1. final 并不等同于 immutable。Immutable 在很多场景是非常棒的选择,某种意义上说,Java 语言目前并没有原生的不可变支持。如果要实现 immutable 的类,我们需要做到(这些原则在并发编程实践中经常被提到):

    • 将 class 自身声明为 final,这样别人就不能扩展来绕过限制了。

    • 将所有成员变量定义为 private 和 final,并且不要实现 setter 方法。

    • 通常构造对象时,成员变量使用深度拷贝来初始化,而不是直接赋值,这是一种防御措施,因为你无法确定输入对象不被其他人修改。

    • 如果确实需要实现 getter 方法,或者其他可能会返回内部状态的方法,使用 copy-on-write 原则,创建私有的 copy。

  2. 资源用完即显式释放,或者利用资源池来尽量重用

  3. Java 平台目前在逐步使用 java.lang.ref.Cleaner 来替换掉原有的 finalize 实现。Cleaner 的实现利用了幻象引用(PhantomReference),这是一种常见的所谓 post-mortem 清理机制。吸取了 finalize 里的教训,每个 Cleaner 的操作都是独立的,它有自己的运行线程,所以可以避免意外死锁等问题。从可预测性的角度来判断,Cleaner 或者幻象引用改善的程度仍然是有限的,如果由于种种原因导致幻象引用堆积,同样会出现问题。所以,Cleaner 适合作为一种最后的保证手段,而不是完全依赖 Cleaner 进行资源回收,不然我们就要再做一遍 finalize 的噩梦了。

  4. 很多第三方库自己直接利用幻象引用定制资源收集,比如广泛使用的 MySQL JDBC driver 之一的 mysql-connector-j,就利用了幻象引用机制。幻象引用也可以进行类似链条式依赖关系的动作,比如,进行总量控制的场景,保证只有连接被关闭,相应资源被回收,连接池才能创建新的连接。另外,这种代码如果稍有不慎添加了对资源的强引用关系,就会导致循环引用关系,前面提到的 MySQL JDBC 就在特定模式下有这种问题,导致内存泄漏。代码中将 State 定义为 static,就是为了避免普通的内部类隐含着对外部对象的强引用,因为那样会使外部对象无法进入幻象可达的状态。

  5. 匿名内部类,访问局部变量时,局部变量为啥要用final来修饰?这个因为Java inner class实际会copy一份,不是去直接使用局部变量,final可以防止出现数据一致性问题

  6. finally 总是执行,除非程序或者线程被中断。

  7. finalize 有一种用途:在 Java 中调用非 Java 代码,在非 Java 代码中若调用了C的 malloc 来分配内存,如果不调用 C 的free 函数,会导致内存泄露。所以需要在 finalize 中调用它。

  8. JDK 自身使用的 Cleaner 机制仍然是有缺陷的,你有什么更好的建议吗?

    • 临时对象,使用完毕后,赋值为null,可以加快对象的回收

    • 公用资源对象,比如数据库连接,使用连接池

    • native调用资源的释放,比如一个进程初始化调用一次,退出调用一次,这类场景可以

四、强引用、软引用、弱引用、幻象引用有什么区别

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

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

  3. 软引用通常用来实现内存敏感的缓存,如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。例如:图片缓存框架中,“内存缓存”中的图片是以这种引用来保存,使得JVM在发生OOM之前,可以回收这部分缓存

  4. 弱引用(WeakReference)并不能使对象豁免垃圾收集,仅仅是提供一种访问在弱引用状态下对象的途径。这就可以用来构建一种没有特定约束的关系,比如,维护一种非强制性的映射关系,如果试图获取时对象还在,就使用它,否则重现实例化。它同样是很多缓存实现的选择。例如,一个类发送网络请求,承担callback的静态内部类,则常以虚引用的方式来保存外部类(宿主类)的引用,当外部类需要被JVM回收时,不会因为网络请求没有及时回来,导致外部类不能被回收,引起内存泄漏。

  5. 对于幻象引用,有时候也翻译成虚引用,你不能通过它访问对象。幻象引用仅仅是提供了一种确保对象被 finalize 以后,做某些事情的机制,比如,通常用来做所谓的 Post-Mortem 清理机制, Java 平台自身 Cleaner 机制等,也有人利用幻象引用监控对象的创建和销毁。幻象引用必须搭配引用队列来使用,当垃圾回收期准备回收一个对象时,如果发现它还有虚引用,那么就在回收之前将它放入引用队列并采取操作。应用场景:可用来跟踪对象被垃圾回收器回收的活动,当一个虚引用关联的对象被垃圾收集器回收之前会收到一条系统通知。

  6. 诊断 MySQL connector-j 驱动在特定模式下(useCompression=true)的内存泄漏问题,就需要我们理解怎么排查幻象引用的堆积问题

  7. 所有引用类型,都是抽象类 java.lang.ref.Reference 的子类,你可能注意到它提供了 get() 方法:除了幻象引用(因为 get 永远返回 null),如果对象还没有被销毁,都可以通过 get 方法获取原有对象。这意味着,利用软引用和弱引用,我们可以将访问到的对象,重新指向强引用,也就是人为的改变了对象的可达性状态!这也是为什么我在上面图里有些地方画了双向箭头。

  8. 如果我们错误的保持了强引用(比如,赋值给了 static 变量),那么对象可能就没有机会变回类似弱引用的可达性状态了,就会产生内存泄漏。所以,检查弱引用指向对象是否被垃圾收集,也是诊断是否有特定内存泄漏的一个思路,如果我们的框架使用到弱引用又怀疑有内存泄漏,就可以从这个角度检查。

  9. 谈到各种引用的编程,就必然要提到引用队列。我们在创建各种引用并关联到响应对象时,可以选择是否需要关联引用队列,JVM 会在特定时机将引用 enqueue 到队列里,我们可以从队列里获取引用(remove 方法在这里实际是有获取的意思)进行相关后续逻辑。尤其是幻象引用,get 方法只返回 null,如果再不指定引用队列,基本就没有意义了。看看下面的示例代码。利用引用队列,我们可以在对象处于相应状态时(对于幻象引用,就是前面说的被 finalize 了,处于幻象可达状态),执行后期处理逻辑。

    Object counter = new Object();
    ReferenceQueue refQueue = new ReferenceQueue<>();
    PhantomReference<Object> p = new PhantomReference<>(counter, refQueue);
    counter = null;
    System.gc();
    try {
        // Remove 是一个阻塞方法,可以指定 timeout,或者选择一直阻塞
        Reference<Object> ref = refQueue.remove(1000L);
        if (ref != null) {
            // do something
        }
    } catch (InterruptedException e) {
        // Handle it
    }
    
  10. 软引用通常会在最后一次引用后,还能保持一段时间,默认值是根据堆剩余空间计算的(以 M bytes 为单位)。从 Java 1.3.1 开始,提供了 -XX:SoftRefLRUPolicyMSPerMB 参数,我们可以以毫秒(milliseconds)为单位设置。比如,下面这个示例就是设置为 3 秒(3000 毫秒)。这个剩余空间,其实会受不同 JVM 模式影响,对于 Client 模式,比如通常的 Windows 32 bit JDK,剩余空间是计算当前堆里空闲的大小,所以更加倾向于回收;而对于 server 模式 JVM,则是根据 -Xmx 指定的最大值来计算。本质上,这个行为还是个黑盒,取决于 JVM 实现,即使是上面提到的参数,在新版的 JDK 上也未必有效,另外 Client 模式的 JDK 已经逐步退出历史舞台。所以在我们应用时,可以参考类似设置,但不要过于依赖它。

    -XX:SoftRefLRUPolicyMSPerMB=3000
    
  11. 诊断 JVM 引用情况:

  12. 如果你怀疑应用存在引用(或 finalize)导致的回收问题,可以有很多工具或者选项可供选择,比如 HotSpot JVM 自身便提供了明确的选项(PrintReferenceGC)去获取相关信息,我指定了下面选项去使用 JDK 8 运行一个样例应用,这是 JDK 8 使用 ParrallelGC 收集的垃圾收集日志,各种引用数量非常清晰。:

    -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintReferenceGC
    
    0.403: [GC (Allocation Failure) 0.871: [SoftReference, 0 refs, 0.0000393 secs]0.871: [WeakReference, 8 refs, 0.0000138 secs]0.871: [FinalReference, 4 refs, 0.0000094 secs]0.871: [PhantomReference, 0 refs, 0 refs, 0.0000085 secs]0.871: [JNI Weak Reference, 0.0000071 secs][PSYoungGen: 76272K->10720K(141824K)] 128286K->128422K(316928K), 0.4683919 secs] [Times: user=1.17 sys=0.03, real=0.47 secs] 
    
  13. 软引用通过SoftReference类实现。 软引用的生命周期比强引用短一些。只有当 JVM 认为内存不足时,才会去试图回收软引用指向的对象:即JVM 会确保在抛出 OutOfMemoryError 之前,清理软引用指向的对象。软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收器回收,Java虚拟机就会把这个软引用加入到与之关联的引用队列中。后续,我们可以调用ReferenceQueue的poll()方法来检查是否有它所关心的对象被回收。如果队列为空,将返回一个null,否则该方法返回队列中前面的一个Reference对象。

五、String、StringBuffer、StringBuilder有什么区别

  1. StringBuffer 的线程安全是通过把各种修改数据的方法都加上 synchronized 关键字实现的,非常直白。其实,这种简单粗暴的实现方式,非常适合常见的线程安全类实现,不必纠结于 synchronized 性能之类的,有人说“过早优化是万恶之源”,考虑可靠性、正确性和代码可读性才是大多数应用开发最重要的因素。

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

  3. 非静态的拼接逻辑在 JDK 8 中会自动被 javac 转换为 StringBuilder 操作;而在 JDK 9 里面,则是体现了思路的变化。Java 9 利用 InvokeDynamic,将字符串拼接的优化与 javac 生成的字节码解耦,假设未来 JVM 增强相关运行时实现,将不需要依赖 javac 的任何修改。

    public class StringConcat {
         public static String concat(String str) {
           return str + “aa” + “bb”;
         }
    }
    

    先编译再反编译,比如使用不同版本的 JDK:

    ${JAVA_HOME}/bin/javac StringConcat.java
    ${JAVA_HOME}/bin/javap -v StringConcat.class
    

    JDK 8 的输出片段是( ldc的含义是:将常量值从常量池中取出来并且压入栈中。在编译期间,该字符串变量的值已经确定了下来,并且将该字符串值缓存在缓冲区中,同时让该变量指向该字符串值,后面如果有使用相同的字符串值,则继续指向同一个字符串值):

    0: new            #2                  // class java/lang/StringBuilder
    3: dup
    4: invokespecial  #3                  // Method java/lang/StringBuilder."<init>":()V
    7: aload_0
    8: invokevirtual  #4                  // Method java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
    11: ldc           #5                  // String aa
    13: invokevirtual #4                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
    16: ldc           #6                  // String bb
    18: invokevirtual #4                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
    21: invokevirtual #7                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
    

    而在 JDK 9 中,反编译的结果就会有点特别了,片段是:

    // concat method
    1: invokedynamic #2,  0              // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;)Ljava/lang/String;
    
    // ...
    // 实际是利用了 MethodHandle, 统一了入口
    0: #15 REF_invokeStatic java/lang/invoke/StringConcatFactory.makeConcatWithConstants:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite;
    

    可以看到,非静态的拼接逻辑在 JDK 8 中会自动被 javac 转换为 StringBuilder 操作;而在 JDK 9 里面,则是体现了思路的变化。Java 9 利用 InvokeDynamic,将字符串拼接的优化与 javac 生成的字节码解耦,假设未来 JVM 增强相关运行时实现,将不需要依赖 javac 的任何修改。

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

  5. 在运行时,字符串的一些基础操作会直接利用 JVM 内部的 Intrinsic 机制,往往运行的就是特殊优化的本地代码,而根本就不是 Java 代码生成的字节码。Intrinsic 可以简单理解为,是一种利用 native 方式 hard-coded 的逻辑,算是一种特别的内联,很多优化还是需要直接使用特定的 CPU 指令

  6. 在字符串内容不经常发生变化的业务场景优先使用String类。例如:常量声明、少量的字符串拼接操作等。如果有大量的字符串内容拼接,避免使用String与String之间的“+”操作,因为这样会产生大量无用的中间对象,耗费空间且执行效率低下(新建对象、回收对象花费大量时间)。

  7. 在频繁进行字符串的运算(如拼接、替换、删除等),并且运行在多线程环境下,建议使用StringBuffer,例如XML解析、HTTP参数解析与封装

  8. 在频繁进行字符串的运算(如拼接、替换、删除等),并且运行在单线程环境下,建议使用StringBuilder,例如SQL语句拼装、JSON封装等。

六、动态代理是基于什么原理

  1. 反射提供的 AccessibleObject.setAccessible(boolean flag)。它的子类也大都重写了这个方法,这里的所谓 accessible 可以理解成修饰成员的 public、protected、private,这意味着我们可以在运行时修改成员访问限制。setAccessible 的应用场景非常普遍,遍布我们的日常开发、测试、依赖注入等各种框架中。比如,在 O/R Mapping 框架中,我们为一个 Java 实体对象,运行时自动生成 setter、getter 的逻辑,这是加载或者持久化数据非常必要的,框架通常可以利用反射做这个事情,而不需要开发者手动写类似的重复代码。另一个典型场景就是绕过 API 访问控制。日常开发时可能被迫要调用内部 API 去做些事情,比如,自定义的高性能 NIO 框架需要显式地释放 DirectBuffer,使用反射绕开限制是一种常见办法

  2. 代理可以看作是对调用目标的一个包装,这样我们对目标代码的调用不是直接发生的,而是通过代理完成。其实很多动态代理场景,也可以看作是装饰器(Decorator)模式的应用

  3. JDK 动态代理的一个简单例子,非常简单地实现了动态代理的构建和代理操作。首先,实现对应的 InvocationHandler;然后,以接口 Hello 为纽带,为被调用目标构建代理对象,进而应用程序就可以使用代理对象间接运行调用目标的逻辑,代理为应用插入额外逻辑(这里是 println)提供了便利的入口.从 API 设计和实现的角度,这种实现仍然有局限性,因为它是以接口为中心的,相当于添加了一种对于被调用者没有太大意义的限制。我们实例化的是 Proxy 对象,而不是真正的被调用类型,这在实践中还是可能带来各种不便和能力退化。如果被调用者没有实现接口,而我们还是希望利用动态代理机制,那么可以考虑其他方式。我们知道 Spring AOP 支持两种模式的动态代理,JDK Proxy 或者 cglib,如果我们选择 cglib 方式,你会发现对接口的依赖被克服了。

    public class MyDynamicProxy {
        public static  void main (String[] args) {
            HelloImpl hello = new HelloImpl();
            MyInvocationHandler handler = new MyInvocationHandler(hello);
            // 构造代码实例
            Hello proxyHello = (Hello) Proxy.newProxyInstance(HelloImpl.class.getClassLoader(), HelloImpl.class.getInterfaces(), handler);
            // 调用代理方法
            proxyHello.sayHello();
        }
    }
    interface Hello {
        void sayHello();
    }
    class HelloImpl implements  Hello {
        @Override
        public void sayHello() {
            System.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 {
            System.out.println("Invoking sayHello");
            Object result = method.invoke(target, args);
            return result;
        }
    }
    
  4. cglib 动态代理基于ASM机制实现,通过生成业务类的子类作为代理类。采取的是创建目标类的子类的方式,因为是子类化,我们可以达到近似使用被调用者本身的效果。在 Spring 编程中,框架通常会处理这种情况,当然我们也可以显式指定

  5. JDK Proxy 的优势

    • 最小化依赖关系,减少依赖意味着简化开发和维护,JDK 本身的支持,可能比 cglib 更加可靠。

    • 平滑进行 JDK 版本升级,而字节码类库通常需要进行更新以保证在新版 Java 上能够使用。

    • 代码实现简单。

    基于类似 cglib 框架的优势

    • 有的时候调用目标可能不便实现额外接口,从某种角度看,限定调用者实现接口是有些侵入性的实践,类似 cglib 动态代理就没有这种限制
    • 只操作我们关心的类,而不必为其他相关类增加工作量。
    • 高性能。
  6. 动态代理应用非常广泛,虽然最初多是因为 RPC 等使用进入我们视线,但是动态代理的使用场景远远不仅如此,它完美符合 Spring AOP 等切面编程。简单来说它可以看作是对 OOP 的一个补充,因为 OOP 对于跨越不同对象或类的分散、纠缠逻辑表现力不够,比如在不同模块的特定阶段做一些事情,类似日志、用户鉴权、全局性异常处理、性能监控,甚至事务处理等。

    img

  7. 动态代理三大要素

    • 抽象类接口
    • 被代理类(具体实现抽象接口的类)
    • 动态代理类:实际调用被代理类的方法和属性的类
  8. “自省”它与反射是有区别的,“自省”机制仅指程序在运行时对自身信息(元数据)的检测,而反射机制不仅包括要能在运行时对程序自身信息进行检测,还要求程序能进一步根据这些信息改变程序状态或结构。

  9. lambda也是需要jvm生成call site,然后invokedynamic之类调用,所以首次调用开销明显

七、int和integer有什么区别

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

  2. 缓存机制:

    • Boolean,缓存了 true/false 对应实例,确切说,只会返回两个常量实例 Boolean.TRUE/FALSE。

    • Integer、Short,缓存了 -128 到 127 之间的数值。

    • Byte,数值有限,所以全部都被缓存。

    • Character,缓存范围’\u0000’ 到 ‘\u007F’。

  3. 建议避免无意中的装箱、拆箱行为,尤其是在性能敏感的场合,创建 10 万个 Java 对象和 10 万个整数的开销可不是一个数量级的,不管是内存使用还是处理速度,光是对象头的空间占用就已经是数量级的差距了。使用原始数据类型、数组甚至本地代码实现等,在性能极度敏感的场景往往具有比较大的优势,用其替换掉包装类、动态数组(如 ArrayList)等可以作为性能优化的备选项。一些追求极致性能的产品或者类库,会极力避免创建过多对象。当然,在大多数产品代码里,并没有必要这么做,还是以开发效率优先。以我们经常会使用到的计数器实现为例,下面是一个常见的线程安全计数器实现。

    class Counter {
        private final AtomicLong counter = new AtomicLong();  
        public void increase() {
            counter.incrementAndGet();
        }
    }
    

    如果利用原始数据类型,可以将其修改为

     class CompactCounter {
        private volatile long counter;
        private static final AtomicLongFieldUpdater<CompactCounter> updater = AtomicLongFieldUpdater.newUpdater(CompactCounter.class, "counter");
        public void increase() {
            updater.incrementAndGet(this);
        }
    }
    
  4. 缓存上限值实际是可以根据需要调整的,JVM 提供了参数设置:-XX:AutoBoxCacheMax=N

  5. 原始数据类型操作是不是线程安全的呢?

    • 原始数据类型的变量,显然要使用并发相关手段,才能保证线程安全。如果有线程安全的计算需要,建议考虑使用类似 AtomicInteger、AtomicLong 这样的线程安全类。
    • 特别的是,部分比较宽的数据类型,比如 float、double,甚至不能保证更新操作的原子性,可能出现程序读取到只更新了一半数据位的数值!
  6. 基本数据类型无法高效地表达数据,也不便于表达复杂的数据结构,比如 vector 和 tuple。

  7. 我们知道 Java 的对象都是引用类型,如果是一个原始数据类型数组,它在内存里是一段连续的内存,而对象数组则不然,数据存储的是引用,对象往往是分散地存储在堆的不同位置。这种设计虽然带来了极大灵活性,但是也导致了数据操作的低效,尤其是无法充分利用现代 CPU 缓存机制。Java 为对象内建了各种多态、线程安全等方面的支持,但这不是所有场合的需求,尤其是数据处理重要性日益提高,更加高密度的值类型是非常现实的需求。

  8. 在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

    • HotSpot虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32bit和64bit,官方称它为”Mark Word”。对象头的另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息并不一定要经过对象本身。另外,如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中却无法确定数组的大小。
    • 对象实例就是对象存储的真正有效信息,也是程序中定义各种类型的字段包括父类继承的和子类定义的,这部分的存储顺序会被虚拟机和代码中定义的顺序影响(这里问一下,这个被虚拟机影响是不是就是重排序??如果是的话,我知道的volatile定义的变量不会被重排序应该就是这里不会受虚拟机影响吧??)。
    • 第三部分对齐填充只是一个类似占位符的作用,因为内存的使用都会被填充为八字节的倍数。
  9. 计算对象大小可通过dump内存之后用memory analyze分析,也可以利用:jol,jmap,或者instrument api(Java agent)等等

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

  1. LinkedList是Java提供的双向链表,LinkedList 本身既是 List也是 Deque

  2. Java 标准类库提供了一系列的静态工厂方法,比如,List.of()、Set.of(),大大简化了构建小的容器实例的代码量。JVM 在处理变长参数的时候会有明显的额外开销

  3. 并不是所有的增删都会开辟新内存,没有开辟新内存的尾部增,效率也是杠杠的。尾部删除也不需要开辟新内存,只是移出最后一个对象。ArrayList的特性随机访问快,增删效率差不是绝对的,直接导致结果就是本身适合使用ArrayList的场景会因为这个笼统的说法而选LinkedList

九、对比HashTable、HashMap、TreeMap有什么不同

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

  2. HashMap 在并发环境可能出现无限循环占用 CPU、size 不准确等诡异的问题。HashMap 明确声明不是线程安全的数据结构,如果忽略这一点,简单用在多线程场景里,难免会出现问题。

  3. HashMap 的性能表现非常依赖于哈希码的有效性,请务必掌握 hashCode 和 equals 的一些基本约定

    • equals 相等,hashCode 一定要相等。
    • 重写了 hashCode 也要重写 equals。
    • hashCode 需要保持一致性,状态改变返回的哈希值仍然要一致。
    • equals 的对称、反射、传递等特性。
  4. 为什么 HashMap 要树化呢?

    哈希碰撞频繁,导致链表过长,查询时间陡升,黑客则会利用这个『漏洞』来攻击服务器,让服务器CPU被大量占用,从而引起了安全问题。 而树化(使用红黑树)能将时间复杂度降到O(logn),从而避免查询时间过长。所以说,本质还是个性能问题。 而在现实世界,构造哈希冲突的数据并不是非常复杂的事情,恶意代码就可以利用这些数据大量与服务器端交互,导致服务器端 CPU 大量占用,这就构成了哈希碰撞拒绝服务攻击,国内一线互联网公司就发生过类似攻击事件。

  5. HashTable中的key、value都不能为null;HashMap中的key、value可以为null,很显然只能有一个key为null的键值对,但是允许有多个值为null的键值对;TreeMap中当未实现 Comparator 接口时,key 不可以为null;当实现 Comparator 接口时,若未对null情况进行判断,则key不可以为null,反之亦然。

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

  1. 早期 ConcurrentHashMap,其实现是基于:

    • 分段锁,也就是将内部进行分段(Segment),里面则是 HashEntry 的数组,和 HashMap 类似,哈希相同的条目也是以链表形式存放。
    • HashEntry 内部使用 volatile 的 value 字段来保证可见性,也利用了不可变对象的机制以改进利用 Unsafe 提供的底层能力,比如 volatile access,去直接完成部分操作,以最优化性能,毕竟 Unsafe 中的很多操作都是 JVM intrinsic 优化过的。
  2. 1.8以后的锁的颗粒度,是加在链表头上的,这个是个思路上的突破;1.8中是put CAS 加锁,不依赖与segment加锁,segment数量与桶数量一致;首先判断容器是否为空,为空则进行初始化利用volatile的sizeCtl作为互斥手段,如果发现竞争性的初始化,就暂停在那里,等待条件恢复,否则利用CAS设置排他标志(U.compareAndSwapInt(this, SIZECTL, sc, -1)),否则重试;对key hash计算得到该key存放的桶位置,判断该桶是否为空,为空则利用CAS设置新节点。否则使用synchronize加锁,遍历桶中数据,替换或新增加点到桶中。最后判断是否需要转为红黑树,转换之前判断是否需要扩容,利用LongAdd累加计算

  3. 可重入锁是某个线程已经获得某个锁,可以再次获取锁而不会出现死锁。再次获取锁的时候会判断当前线程是否是已经加锁的线程,如果是对锁的次数+1,释放锁的时候加了几次锁,就需要释放几次锁。

    代码中的锁的递归只是锁的一种表现及证明形式,除了这种形式外,还有另一种表现形式。同一个线程在没有释放锁的情况下多次调用一个加锁方法,如果成功,则也说明是可重入锁。

  4. 现代 JDK 中,synchronized 已经被不断优化,可以不再过分担心性能差异,另外,相比于 ReentrantLock,它可以减少内存消耗,这是个非常大的优势。

  5. 非自旋锁和自旋锁最大的区别,就是如果它遇到拿不到锁的情况,它会把线程阻塞,直到被唤醒。而自旋锁会不停地尝试。自旋锁的好处,那就是自旋锁用循环去不停地尝试获取锁,让线程始终处于 Runnable 状态,节省了线程状态切换带来的开销。自旋锁适用于并发度不是特别高的场景,以及临界区比较短小的情况,这样我们可以利用避免线程切换来提高效率,可是如果临界区很大,线程一旦拿到锁,很久才会释放的话,那就不合适用自旋锁,因为自旋会一直占用 CPU 却无法拿到锁,白白消耗资源

  6. volatile是Java中的关键字,用来修饰会被不同线程访问和修改的变量。JMM(Java内存模型)是围绕并发过程中如何处理可见性、原子性和有序性这3个特征建立起来的,而volatile可以保证其中的两个特性。

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

  1. 输入流、输出流(InputStream/OutputStream)是用于读取或写入字节的,例如操作图片文件。而 Reader/Writer 则是用于操作字符,增加了字符编解码等功能,适用于类似从文件中读取或者写入文本信息。本质上计算机操作的都是字节,不管是网络通信还是文件读取,Reader/Writer 相当于构建了应用逻辑和原始数据之间的桥梁。

  2. BufferedOutputStream 等带缓冲区的实现,可以避免频繁的磁盘读写,进而提高 IO 处理效率。这种设计利用了缓冲区,将批量数据进行一次操作,但在使用中千万别忘了 flush

  3. NIO 的主要组成部分:

    • Buffer,高效的数据容器,除了布尔类型,所有原始数据类型都有相应的 Buffer 实现。

    • Channel,类似在 Linux 之类操作系统上看到的文件描述符,是 NIO 中被用来支持批量式 IO 操作的一种抽象。

      File 或者 Socket,通常被认为是比较高层次的抽象,而 Channel 则是更加操作系统底层的一种抽象,这也使得 NIO 得以充分利用现代操作系统底层机制,获得特定场景的性能优化,例如,DMA(Direct Memory Access)等。不同层次的抽象是相互关联的,我们可以通过 Socket 获取 Channel,反之亦然。

    • Selector,是 NIO 实现多路复用的基础,它提供了一种高效的机制,可以检测到注册在 Selector 上的多个 Channel 中,是否有 Channel 处于就绪状态,进而实现了单线程对多 Channel 的高效管理。

    • Chartset,提供 Unicode 字符串定义,NIO 也提供了相应的编解码器等,例如,通过下面的方式进行字符串到 ByteBuffer 的转换:

      Charset.defaultCharset().encode(“Hello world!”));

  4. NIO2的局限性在于,如果回调时客户端做了重操作,就会影响调度,导致后续的client回调缓慢,这是这种多路复用的主要局限之一,nodejs等其他类似框架都有这问题

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

  1. Java 有多种比较典型的文件拷贝实现方式,比如:

    • 利用 java.io 类库,直接为源文件构建一个 FileInputStream 读取,然后再为目标文件构建一个 FileOutputStream,完成写入工作。
    • 利用 java.nio 类库提供的 transferTo 或 transferFrom 方法实现。
    • Java 标准类库本身已经提供了几种 Files.copy 的实现。
  2. 对于 Copy 的效率,这个其实与操作系统和配置等情况相关,总体上来说,NIO transferTo/From 的方式可能更快,因为它更能利用现代操作系统底层机制,避免不必要拷贝和上下文切换。

  3. 用户态空间(User Space)和内核态空间(Kernel Space),这是操作系统层面的基本概念,操作系统内核、硬件驱动等运行在内核态空间,具有相对高的特权;而用户态空间,则是给普通应用和服务使用。当我们使用输入输出流进行读写时,实际上是进行了多次上下文切换,比如应用读取数据时,先在内核态将数据从磁盘读取到内核缓存,再切换到用户态将数据从内核缓存读取到用户缓存。写入操作也是类似,仅仅是步骤相反,参考下图。

    img

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

    img

  5. 如何提高类似拷贝等 IO 操作的性能,有一些宽泛的原则:

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

    • capcity,它反映这个 Buffer 到底有多大,也就是数组的长度。
    • position,要操作的数据起始位置。
    • limit,相当于操作的限额。在读取或者写入时,limit 的意义很明显是不一样的。比如,读取操作时,很可能将 limit 设置到所容纳数据的上限;而在写入时,则会设置容量或容量以下的可写限度。
    • mark,记录上一次 postion 的位置,默认是 0,算是一个便利性的考虑,往往不是必须的。
  7. Direct Buffer:如果我们看 Buffer 的方法定义,你会发现它定义了 isDirect() 方法,返回当前 Buffer 是否是 Direct 类型。这是因为 Java 提供了堆内和堆外(Direct)Buffer,我们可以以它的 allocate 或者 allocateDirect 方法直接创建。

  8. MappedByteBuffer:它将文件按照指定大小直接映射为内存区域,当程序访问这个内存区域时将直接操作这块儿文件数据,省去了将数据从内核空间向用户空间传输的损耗。我们可以使用FileChannel.map创建 MappedByteBuffer,它本质上也是种 Direct Buffer。

  9. 在实际使用中,Java 会尽量对 Direct Buffer 仅做本地 IO 操作,对于很多大数据量的 IO 密集操作,可能会带来非常大的性能优势,因为:

    • Direct Buffer 生命周期内内存地址都不会再发生更改,进而内核可以安全地对其进行访问,很多 IO 操作会很高效。
    • 减少了堆内对象存储的可能额外维护工作,所以访问效率可能有所提高。
  10. Direct Buffer 创建和销毁过程中,都会比一般的堆内 Buffer 增加部分开销,所以通常都建议用于长期使用、数据较大的场景。使用 Direct Buffer,我们需要清楚它对内存和 JVM 参数的影响。首先,因为它不在堆上,所以 Xmx 之类参数,其实并不能影响 Direct Buffer 等堆外成员所使用的内存额度,从参数设置和内存问题排查角度来看,这意味着我们在计算 Java 可以使用的内存大小的时候,不能只考虑堆的需要,还有 Direct Buffer 等一系列堆外因素。如果出现内存不足,堆外内存占用也是一种可能性。我们可以使用下面参数设置大小:

    -XX:MaxDirectMemorySize=512M

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

  12. 对于 Direct Buffer 的回收,我有几个建议:

    • 在应用程序中,显式地调用 System.gc() 来强制触发。
    • 另外一种思路是,在大量使用 Direct Buffer 的部分框架中,框架会自己在程序中调用释放方法,Netty 就是这么做的,有兴趣可以参考其实现(PlatformDependent0)。
    • 重复使用 Direct Buffer。
  13. 跟踪和诊断 Direct Buffer 内存占用?因为通常的垃圾收集日志等记录,并不包含 Direct Buffer 等信息,所以 Direct Buffer 内存诊断也是个比较头疼的事情。幸好,在 JDK 8 之后的版本,我们可以方便地使用 Native Memory Tracking(NMT)特性来进行诊断,注意,激活 NMT 通常都会导致 JVM 出现 5%~10% 的性能下降,请谨慎考虑。你可以在程序启动时加上下面参数:

    -XX:NativeMemoryTracking=

  14. 其实在初始化 DirectByteBuffer对象时,如果当前堆外内存的条件很苛刻时,会主动调用 System.gc()强制执行FGC。所以一般建议在使用netty时开启XX:+DisableExplicitGC

十三、谈谈接口和抽象类有什么区别

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

  2. 接口的职责也不仅仅限于抽象方法的集合,其实有各种不同的实践。有一类没有任何方法的接口,通常叫作 Marker Interface,顾名思义,它的目的就是为了声明某些东西,比如我们熟知的 Cloneable、Serializable 等。这种用法,也存在于业界其他的 Java 产品代码中。从表面看,这似乎和 Annotation 异曲同工,也确实如此,它的好处是简单直接。对于 Annotation,因为可以指定参数和值,在表达能力上要更强大一些,所以更多人选择使用 Annotation。

  3. 进行面向对象编程,掌握基本的设计原则SOLID原则

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

    List<String> list = new ArrayList<>();
    

    如果使用var类型,可以简化为

    var list = new ArrayList<String>();
    

    但是,list会被推断为“ArrayList”

    ArrayList<String> list = new ArrayList<String>();
    

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

  5. Java 8 增加了函数式编程的支持,所以又增加了一类定义,即所谓 functional interface,简单说就是只有一个抽象方法的接口,通常建议使用 @FunctionalInterface Annotation 来标记。Lambda 表达式本身可以看作是一类 functional interface,某种程度上这和面向对象可以算是两码事。我们熟知的 Runnable、Callable 之类,都是 functional interface

  6. 在实际项目开发过程,一方面是业务需求频繁,需要满足开闭原则,也就是小到一个模块,大到一个架构都需要有好的可扩展性;另外一方面软件往往是团队协同开发的过程;由于团队成员水平参差不齐,这方面的坑不少。可以通过前期做好设计评审、code review等手段去提升代码质量。

  7. 使用时机:当想要支持多重继承,或是为了定义一种类型请使用接口;当打算提供带有部分实现的“模板”类,而将一些功能需要延迟实现请使用抽象类;当你打算提供完整的具体实现请使用类。

十四、谈谈你知道的设计模式

  1. 设计模式可以分为创建型模式、结构型模式和行为型模式。

    • 创建型模式,是对对象创建过程的各种问题和解决方案的总结,包括各种工厂模式(Factory、Abstract Factory)、单例模式(Singleton)、构建器模式(Builder)、原型模式(ProtoType)。
    • 结构型模式,是针对软件设计结构的总结,关注于类、对象继承、组合方式的实践经验。常见的结构型模式,包括桥接模式(Bridge)、适配器模式(Adapter)、装饰者模式(Decorator)、代理模式(Proxy)、组合模式(Composite)、外观模式(Facade)、享元模式(Flyweight)等。
    • 行为型模式,是从类或对象之间交互、职责划分等角度总结的模式。比较常见的行为型模式有策略模式(Strategy)、解释器模式(Interpreter)、命令模式(Command)、观察者模式(Observer)、迭代器模式(Iterator)、模板方法模式(Template Method)、访问者模式(Visitor)。
  2. 使用构建器模式,可以比较优雅地解决构建复杂对象的麻烦,这里的“复杂”是指类似需要输入的参数组合较多,如果用构造函数,我们往往需要为每一种可能的输入参数组合实现相应的构造函数,一系列复杂的构造函数会让代码阅读性和可维护性变得很差。

  3. 实际上jdk里面就用了static final,单例模式面试中常见实现:

    public class Singleton {
        private static volatile Singleton singleton = null;
        private Singleton(){
        }
        
        public static Singleton getSingleton(){
            if(singleton == null){	//尽量避免重复进入代码块
                synchronized (Singleton.class){	//同步.class,意味着对同步类方法调用
                    if(singleton == null){
                        singleton = new Singleton();
                    }
                }
            }
            return singleton;
        }
    }
    
  4. Spring 等如何在 API 设计中使用设计模式

    • BeanFactoryApplicationContext应用了工厂模式。
    • 在 Bean 的创建中,Spring 也为不同 scope 定义的对象,提供了单例和原型等模式实现。
    • AOP 领域则是使用了代理模式、装饰器模式、适配器模式等。
    • 各种事件监听器,是观察者模式的典型应用。
    • 类似 JdbcTemplate 等则是应用了模板模式。
  5. 银弹即“silver bullet”,在投资中可以理解为“万金油”策略或圣杯,人人都想得到但却并不存在

  6. 门面模式(Facade)形象上来讲就是在原系统之前放置了一个新的代理对象,只能通过该对象才能使用该系统,不再允许其它方式访问该系统。该代理对象封装了访问原系统的所有规则和接口方法,提供的API接口较之使用原系统会更加的简单。举例:JUnitCore是JUnit类的 Facade模式的实现类,外部使用该代理对象与JUnit进行统一交互,驱动执行测试代码。

十五、synchronized和ReentrantLock有什么区别

  1. synchronized 是 Java 内建的同步机制,所以也有人称其为 Intrinsic Locking,它提供了互斥的语义和可见性,当一个线程已经获取当前锁时,其他试图获取的线程只能等待或者阻塞在那里。在 Java 5 以前,synchronized 是仅有的同步手段,在代码中, synchronized 可以用来修饰方法,也可以使用在特定的代码块儿上,本质上 synchronized 方法等同于把方法全部语句用 synchronized 块包起来。

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

  3. 将两次赋值过程用 synchronized 保护起来,使用 this 作为互斥单元,就可以避免别的线程并发的去修改 sharedState。

    synchronized (this) {
    	int former = sharedState ++;
    	int latter = sharedState;
    	// …
    }
    

    如果用 javap 反编译,可以看到类似片段,利用 monitorenter/monitorexit 对实现了同步的语义:

    11: astore_1
    12: monitorenter
    13: aload_0
    14: dup
    15: getfield  	#2              	// Field sharedState:I
    18: dup_x1
    …
    56: monitorexit
    
  4. 再来看看 ReentrantLock,它是表示当一个线程试图获取一个它已经获取的锁时,这个获取动作就自动成功,这是对锁获取粒度的一个概念,也就是锁的持有是以线程为单位而不是基于调用次数。Java 锁实现强调再入性是为了和 pthread 的行为进行区分。

  5. 再入锁可以设置公平性(fairness),我们可在创建再入锁时选择是否是公平的。

    ReentrantLock fairLock = new ReentrantLock(true);
    

    这里所谓的公平性是指在竞争场景中,当公平性为真时,会倾向于将锁赋予等待时间最久的线程。公平性是减少线程“饥饿”(个别线程长期等待锁,但始终无法获取)情况发生的一个办法。如果使用 synchronized,我们根本无法进行公平性的选择,其永远是不公平的,这也是主流操作系统线程调度的选择。通用场景中,公平性未必有想象中的那么重要,Java 默认的调度策略很少会导致 “饥饿”发生。与此同时,若要保证公平性则会引入额外开销,自然会导致一定的吞吐量下降。所以,我建议只有当你的程序确实有公平性需要的时候,才有必要指定它。

  6. 为保证锁释放,每一个 lock() 动作,我建议都立即对应一个 try-catch-finally,典型的代码结构如下,这是个良好的习惯。

    ReentrantLock fairLock = new ReentrantLock(true);// 这里是演示创建公平锁,一般情况不需要。
    fairLock.lock();
    try {
    	// do something
    } finally {
     	fairLock.unlock();
    }
    
  7. ReentrantLock 相比 synchronized,因为可以像普通对象一样使用,所以可以利用其提供的各种便利方法,进行精细的同步操作,甚至是实现 synchronized 难以表达的用例,如:

    • 带超时的获取锁尝试。
    • 可以判断是否有线程,或者某个特定线程,在排队等待获取锁。
    • 可以响应中断请求。
  8. 条件变量(java.util.concurrent.Condition),如果说 ReentrantLock 是 synchronized 的替代选择,Condition 则是将 wait、notify、notifyAll 等操作转化为相应的对象,将复杂而晦涩的同步操作转变为直观可控的对象行为。条件变量最为典型的应用场景就是标准类库中的 ArrayBlockingQueue 等。Condition一定要从ReentrantLock中获取。

  9. 通过 signal/await 的组合,完成了条件判断和通知等待线程,非常顺畅就完成了状态流转。注意,signal 和 await 成对调用非常重要,不然假设只有 await 动作,线程会一直等待直到被打断(interrupt)。

  10. ReentrantLock是Lock的实现类,是一个互斥的同步器,在多线程高竞争条件下,ReentrantLock比synchronized有更加优异的性能表现。

  11. 用法比较:Lock使用起来比较灵活,但是必须有释放锁的配合动作;Lock必须手动获取与释放锁,而synchronized不需要手动释放和开启锁;Lock只适用于代码块锁,而synchronized可用于修饰方法、代码块等。

  12. 特性比较:

    • ReentrantLock的优势体现在:具备尝试非阻塞地获取锁的特性:当前线程尝试获取锁,如果这一时刻锁没有被其他线程获取到,则成功获取并持有锁
    • 能被中断地获取锁的特性:与synchronized不同,获取到锁的线程能够响应中断,当获取到锁的线程被中断时,中断异常将会被抛出,同时锁会被释放
    • 超时获取锁的特性:在指定的时间范围内获取锁;如果截止时间到了仍然无法获取锁,则返回
  13. 在使用ReentrantLock类的时,一定要注意三点:

    • 在finally中释放锁,目的是保证在获取锁之后,最终能够被释放
    • 不要将获取锁的过程写在try块内,因为如果在获取锁时发生了异常,异常抛出的同时,也会导致锁无故被释放。
    • ReentrantLock提供了一个newCondition的方法,以便用户在同一锁的情况下可以根据不同的情况执行等待或唤醒的动作。
  14. 所有的Lock都是基于AQS来实现了。AQS和Condition各自维护了不同的队列,在使用lock和condition的时候,其实就是两个队列的互相移动。如果我们想自定义一个同步器,可以实现AQS。它提供了获取共享锁和互斥锁的方式,都是基于对state操作而言的。ReentranLock这个是可重入的,其实它内部自定义了同步器Sync,这个又实现了AQS,同时又实现了AOS,而后者就提供了一种互斥锁持有的方式。其实就是每次获取锁的时候,看下当前维护的那个线程和当前请求的线程是否一样,一样就可重入了。

  15. ReentrantLock 加锁的时候通过cas算法,将线程对象放到一个双向链表中,然后每次取出链表中的头节点,看这个节点是否和当前线程相等。是否相等比较的是线程的ID。

  16. ReentrantLock是基于双向链表的对接和CAS实现的,感觉比Object增加了很多逻辑,怎么会比Synchronized效率高?如果从单个线程做的事来看,也许并没有优势,不管是空间还是时间,但ReentrantLock这种所谓cas,或者叫lock-free,方式的好处,在于高竞争情况的扩展性,而原来那种频繁的上下文切换则会导致吞吐量迅速下降

    img

版权声明:本文为快打球去吧原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://www.cnblogs.com/nostopcoding/p/16502992.html