Java Core Technology Review (二)


前言

从五个方面来分析 Java:

  • Java 基础:基本特性和机制;
  • Java 并发:并发编程、Java 虚拟机;
  • Java 框架:数据库框架、Spring 生态、分布式框架等等;
  • Java 安全:常见的安全问题和处理方法;
  • Java 性能:掌握工具、方法论。

Java 基础

5、String、StringBuffer、StringBuilder

基础分析

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

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

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

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

总结:

  • 所以的应用开发都离不开操作字符串,理解字符串的设计和实现以及相关工具的使用,对写出高质量代码很有帮助,我们至少需要明白 String 是 Immutable 的,字符串操作可能会产生大量临时字符串对象,以及线程安全方面的区别;

如果继续深入,还可以从不同的角度去分析:

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

字符串设计和实现

String 是不可变类的典型实现,原生的保证了基础线程安全,因为我们无法对它的内部数据进行任何修改,这种便利甚至体现在拷贝构造函数中,由于不可变,Immutable 对象在拷贝时不需要额外复制数据。

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

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

另外,这个内部数组应该创建成多大的呢?如果太小,拼接的时候可能要重新创建足够大的数组;如果太大,又会浪费空间。目前的实现是,构建时初始字符串长度加 16(这意味着,如果没有构建对象时输入最初的字符串,那么初始值就是 16)。我们如果确定拼接会发生非常多次,而且大概是可预计的,那么就可以指定合适的大小,避免多次扩容的开销。扩容会产生多重开销,因为要抛弃原数组,创建新的(可以简单理解为倍数)数组,还要进行 arraycopy。

在没有线程安全问题的情况下,全部拼接是应该都用 StringBuilder 实现吗?其实也并不是这样,因为 Java 还是非常智能的,在编译期间会做一些优化处理,不同的 JDK 编译时采取的措施也不一样:

比如 JDK 8 中 javac 编译器会使用 StringBuilder 做字符拼接优化,有时还会使用字符串常量池,JDK 9 对字符串的操作优化更加统一,提供了 StringConcatFactory,作为一个统一的入口。

javac 自动生成的代码未必是最优化的,但是普通场景也够使用了,可以酌情选择。

字符串缓存

据相关研究,把常见应用进行堆转储(Dump Heap),然后分析对象组成,发现平均 25% 的对象是字符串,并且其中约半数是重复的。如果能够避免创建重复字符串,可以有效降低内存消耗和对象创建开销。

String 在 Java 6 以后提供了 intern() 方法,目的是提示 JVM 把相应字符串缓存起来,已备重复使用。在我们创建字符串对象并调用 intern()方法的时候,如果已经有缓存的字符串,就会返回缓存里的实例,否则将其缓存起来。一般来说,JVM 会将所有的类似 “abc” 这样的文本字符串,或者字符串常量之类的缓存起来。

看起来很理想,但是实际却不太一样,一般使用 Java 6 这种历史版本,大量使用 intern 方法,被缓存的字符串会存储到 PermGen 中,这就是臭名昭著的 “永久代”,这个空间是有限的,也基本不会被 FullGC 之外的垃圾收集照顾到,所以,如果使用不当,OOM 就会光顾。

在后续版本中,这个缓存被放置在堆中,这样就极大避免了永久代占满的问题,甚至永久代在 JDK 8 中被 MetaSpace(元数据区)取代了。而且,默认缓存大小也在不断扩大中,从最初的 1009 到 7u40 到 60013。

可以使用下面的参数打印出来验证一下:

-XX:+PrintStringTableStatistics

也可以使用下面的 JVM 参数手动调整大小,但是绝大部分情况下不需要调整,除非已经确定它的大小确实会影响了操作效率:

-XX:StringTableSize=N

Intern 是一种显式地排重机制,但是它也有一定的副作用,因为需要开发者写代码时明确调用,一是不方便需要显式调用,二是开发时也没法预计字符串的重复情况。

在 Oracle JDK 8u20 之后,推出了一个新的特性,就是 G1 GC 下的字符串排重。它是通过将相同数据的字符串指向同一份数据来做到的,是 JVM 底层的改变,并不需要 Java 类库做什么修改。

目前这个功能默认是关闭的,需要使用以下参数开启,并且需要指定使用 G1 GC:

-XX:+UseStringDeduplication

这几个方面,只是 Java 底层对字符串优化的一角,在运行时,字符串的一些基础操作会直接利用 JVM 内部的 Intrinsic 机制,往往运行的就是特殊优化的本地代码,而根本就不是 Java 代码生成的字节码。

Intrinsic 可以简单理解为一种利用 native 方式 hard-coded 的逻辑,算是一种特殊的内联,很多优化还是需要直接使用特定的 CPU 指令,具体参考:http://hg.openjdk.java.net/jdk/jdk/file/44b64fc0baa3/src/hotspot/share/classfile/vmSymbols.hpp

搜索 String 查看相关 Intrinsic 的定义。

可以通过以下参数查看 intrinsic 发生的状态:

-XX:+PrintCompilation -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining

String 自身的演化

在历史版本中,String 内部是使用 char 数组来存数据的,这样非常直接。但是 Java 中的 char 是两个 bytes 大小,拉丁系语言的字符,根本就不需要太宽的 char,这样无区别的实现就造成了一定的浪费。

其实在 Java 6 的时候,Oracle JDK 就提供了压缩字符串的特性,但是这个特性的实现并不是开源的,而且在实践中暴露出一些问题,所以在新版 JDK 中将它移除了。

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

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

当然,在极端的情况下,字符串也出现了一些能力的退化,比如最大字符串的大小。原来 char 数组的实现,字符串的长度就是数组本身的长度限制,但是换成了 byte 数组,同样数组长度下,存储能力退化了一倍!(一个 char 相当于两个 byte)还好这是存在于理论中的极限。

在同用的性能测试和产品实验中,可以非常明显地看到紧凑字符串带来的优势,即更小的内存占用、更快的操作速度。

总结

  • String、StringBuffer、StringBuilder 的主要设计和实现特点;
  • 字符串缓存的 intern 机制;
  • 非代码侵入性的虚拟机层面排重;
  • Java 9 中紧凑字符串的改进;
  • JVM 底层优化机制 intrinsic
String str1 = "123";  // 以直接量赋值的形式是存放到字符串常量池中
String str2 = new String("123");   // 通过 new 方式, 会在堆上创建 String 对象

str1 == str2 // false 两个引用指向的内存地址不一样
str1.equals(str2) // true equals 本质还是引用的比较, 但是 String 重写了该方法变成了值比较

String str3 = str2.intern();   // 将 str2 指向的堆上对象尝试放入常量池, 但是池中已经存在了 123 对象, 所以返回常量池对象的引用

str3 == str1; // true 都是指向常量池
str3 == str2; // false 前者是常量池,后者是堆

// 注意 hashcode 是通过函数计算得到的,常量池中的字符串的 hashcode 被缓存
// 堆上字符串对象每次获取 hashcode 都需要重新计算
// 有时候会存在 hashcode 一样的字符串:如 "通话"、"种地"(哈希冲突)

6、动态代理的原理

编程语言通常有各种不同的分类角度,动态类型和静态类型就是其中一种分类角度,简单区分就是语言类型信息是在运行时检查,还是编译器检查。

与其类似的还有一个对比,就是所谓的强类型和弱类型,就是不同类型变量赋值的时候,是否愮显示地(强制)进行类型转换。

那么,如何分类 Java 语言呢?通常认为,Java 是静态的强类型语言,但是因为提供了类似反射等机制,也具备了部分动态语言的能力。

Q:简而言之,谈谈 Java 反射机制,动态代理是基于什么原理?

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

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

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

深入:

  • 反射机制的了解和掌握;
  • 动态代理解决了什么问题?在业务系统中的应用场景?
  • JDK 动态代理在设计和实现上与 cglib 等方式有什么不同?如何取舍?

反射机制演进

对于 Java 语言的反射机制本身,在 java.langjava.lang.reflect 包下有相关的抽象。Class、Feild、Method、Constructor 等等,这些完全就是我们去操作类和对象的元数据对于。反射各种典型用例的编程,基础部分可以参考:https://docs.oracle.com/javase/tutorial/reflect/index.html

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

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

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

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

module MyEntities {
	// Open for reflection
	opens com.mycorp to java.persistence;
}

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

--illegal-access={ permit | warn | deny }

动态代理

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

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

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

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 动态代理的一个简单例子,只是加了一个 print,在生产系统中,我们可以轻松扩展类似逻辑进行诊断、限流等等。

首先实现 InvocationHandler,然后,以接口 Hello 为纽带,为被调用目标构建代理对象,进而应用程序就可以使用代理对象间接运行调用目标的逻辑,代理为应用插入额外逻辑(这里是 pringln)提供了便利的入口。

从 API 设计和实现角度,这种实现任然具有局限性,因为它是以接口为中心的,相当于添加了一种对于被调用者没有太大意义的限制。我们实例化的是 Proxy 对象,而不是真正的被调用类型,这在实践中还是可能会带来各种不变和能力退化。

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

Cglib 动态代理采取的是创建目标类的子类的方式,因为是子类化,我们可以达到近似使用被调用者本身的效果。在 Spring 编程中,框架通常会处理这种情况,当然我们也可以 显式指定

那么我们在开发中如何选择?下面简单比对两种方式各自的优势:

JDK Proxy 的优势:

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

基于类似 cglib 框架的优势:

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

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

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

动态代理应用非常广泛,虽然最初多是因为 RPC 等使用进入我们视线,但是动态代理的使用场景远远不仅如此,它完美符合 Spring AOP 等切面编程。

简单来说它可以看作是对 OOP 的一个补充,因为 OOP 对于跨越不同对象或类的分散、纠缠逻辑表现力不够,比如在不同模块的特定阶段做一些事情,类似日志、用户鉴权、全局性异常处理、性能监控,甚至事务处理等,你可以参考下面这张图:

AOP 通过(动态)代理机制可以让开发者从这些繁琐事项中抽身出来,大幅度提高了代码的抽象程度和复用度。从逻辑上讲,我们在软件设计和实现中的类似代理,如 Facade、Observer 等很多设计目的,都可以通过动态代理优雅地实现。

总结

代理模式(通过代理静默地解决一些业务无关的问题,比如远程、安全、事务、日志、资源关闭……让应用开发者可以只关心他的业务):

  • 静态代理:事先写好代理类,可以手工编写,也可以用工具生成。缺点是每个业务类都要对应一个代理类,非常不灵活。
  • 动态代理:运行时自动生成代理对象。缺点是生成代理代理对象和调用代理方法都要额外花费时间。
  • JDK 动态代理:基于Java 反射机制实现,必须要实现了接口的业务类才能用这种办法生成代理对象。新版本也开始结合 ASM 机制。
  • cglib 动态代理:基于 ASM 机制实现,通过生成业务类的子类作为代理类。

扩展:字节码操作、运行时拦截、加载期编织 、Java agent等

7、int 和 Integer 的区别

Java 虽然号称是面向对象的语言,但是原始数据类型仍然是重要的组成元素,所以在面试中,经常考察原始数据类型和包装类等 Java 语言特性。

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

A: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 里的两个基本要素:原始数据类型、包装类。扩展到自动装箱、自动拆箱,进而考察封装类的一些设计和实践。

  • Java 的不同阶段:编译阶段、运行时,自动装箱/拆箱发生在哪个阶段?
  • 用静态工厂方法 valueOf 会使用到缓存机制,那么自动装箱的时候,缓存机制起作用吗?
  • 为什么我们需要原始数据类型,Java的对象似乎也很高效,应用中具体会产生哪些差异?
  • Integer 类的设计要点

理解自动装箱/拆箱

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

我么可以看一段程序反编译后的结果:

Integer i1 = 1;
int i2 = i1++;

1 invokestatic #2 <java/lang/Integer.valueOf : (I)Ljava/lang/Integer;>
8 invokevirtual #3 <java/lang/Integer.intValue : ()I>

可以看到这里面分别调用了静态方法 valueOf 和实例方法 intValue

这种缓存机制并不只是 Integer 才有的,同样存在于其他的一些包装类,比如:

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

自动装箱/自动拆箱似乎很酷,在编程实践中,有什么需要注意的吗?

原则上,建议避免无意中的装箱、拆箱行为,尤其是在性能敏感的场合,创建 10 万个 Java 对象和 10 万个整数的开销可不是一个数量级的,不管是内存使用还是处理速度,光是对象头 的空间占用就已经是数量级的差距了。

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

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

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

class CompactCounter {
    private volatile long counter;
    
    private satic fnal AtomicLongFieldUpdater<CompactCounter> updater = AtomicLongFieldUpdater.newUpdater(CompactCounter.class, "counter");
    
    public void increase() {
        updater.incrementAndGet(this);
    }
}

源码分析

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

(1)首先,继续深挖缓存,Integer的缓存范围虽然默认是-128到127,但是在特别的应用场景,比如我们明确知道应用会频繁使用更大的数值,这时候应该怎么办呢?

缓存上限值实际是可以根据需要调整的,JVM提供了参数设置:

-XX:AutoBoxCacheMax=N

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

private static class IntegerCache {
    static final int low = -128;
    static final int high;
    static final Integer cache[];

    static {
        // high value may be configured by property
        int h = 127;
        String integerCacheHighPropValue =
            VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
        
        // ......

        // range [-128, 127] must be interned (JLS7 5.1.7)
        assert IntegerCache.high >= 127;
    }

    private IntegerCache() {}
}

(2)在之前分析字符串的时候,提到字符串是不可变的,保证了基本的信息安全和并发编程中的线程安全。如果我们去看包装类中的成员变量 “value”,就会发现不管是 Integer 还是 Boolean 等等,这个变量都被声明为 “private final”,所以,它们同样是不可变类型。

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

(3)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 实现的选择),但是总体来说,并没有行为差 异,应用移植还是可以做到宣称的“一次书写,到处执行”,应用开发者更多需要考虑的是容量、能力等方面的差异。

原始类型线程安全

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

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

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

Java 原始数据类型和引用类型局限性

最后再从 Java 平台发展的角度来看看,原始数据类型、对象的局限性和演进

  • 原始数据类型和 Java 泛型并不能配合使用

这是因为 Java 的泛型某种程度上可以算作伪泛型,它完全是一种编译期的技巧,Java 编译期会自动将类型转换为对应的特定类型,这就决定了使用泛型,必须保证相应类型可以转换为 Object。

  • 无法高效地表达数据,也不便于表达复杂的数据结构,比如 vector 和 tuple

我们知道 Java 的对象都是引用类型,如果是一个原始数据类型数组,它在内存里是一段连续的内存,而对象数组则不然,数据存储的是引用,对象往往是分散地存储在堆的不同位 置。这种设计虽然带来了极大灵活性,但是也导致了数据操作的低效,尤其是无法充分利用现代 CPU 缓存机制。

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

针对这些方面的增强,目前正在OpenJDK领域紧锣密鼓地进行开发,有兴趣的话你可以关注相关工程:http://openjdk.java.net/projects/valhalla/

补充: Java 对象组成

在 HotSpot 虚拟机中,对象在内存中存储的布局由三部分组成:对象头(Header)、对象实例(Instance Data)、对齐填充(Padding)。

(1)HotSpot 虚拟机的对象头中包括两部分信息:

  • 第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等,这部分数据的长度在 32 位和 64 位的虚拟机(未开启压缩指针)中分别为 32bit 和 64bit,官方称它为 “Mark Word”;
  • 第二部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息并不一定要经过对象本身。另外,如果对象是一个 Java 数组,那么在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通 Java 对象的元数据信息确定 Java 对象的大小,但是从数组的元数据中却无法确定数组的大小。

(2)实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型字段的内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录下来。

(3)对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于 HotSpot VM 的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说,就是对象的大小必须是 8 字节的整数倍。原因是访问未对齐的内存,处理器需要做两次内存访问,而对齐的内存访问仅需一次。

8、对比 Vector、ArrayList、LinkedList

分析

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

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

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

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

分析:

补充一下不同容器类型适合的场景:

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

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

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

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

以掌握典型排序算法为例,我们至少需要熟知:

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

扩展

集合框架整体设计

首先看看集合框架(这里没有列出 Map,java.util.concurrent 下面的相关容器):

我们可以看到 Java 的集合框架,Collection 接口是所有集合的根,然后扩展开提供了三大类集合,分别是:

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

每种集合的通用逻辑,都被抽象到相应的抽象类之中,比如 AbstractList 就集中了各种 List 操作的通用部分。这些集合不是完全孤立的,比如,LinkedList 本身,既是 List,也是 Deque。

如果阅读过源码,你会发现,其实,TreeSet 代码里实际默认是利用 TreeMap 实现的,Java 类库创建了一个 Dummy 对象 “PRESENT” 作为 value,然后所有插入的元素其实是以 键的形式放入了 TreeMap 里面;同理,HashSet 其实也是以 HashMap 为基础实现的

就像前面提到过的,我们需要对各种具体集合实现,至少了解基本特征和典型使用场景,以 Set 的几个实现为例:

  • TreeSet 支持自然顺序访问,但是添加、删除、包含等操作要相对低效(log(n)时间);
  • HashSet 则是利用哈希算法,理想情况下,如果哈希散列正常,可以提供常数时间的添加、删除、包含等操作,但是它不保证有序;
  • LinkedHashSet,内部构建了一个记录插入顺序的双向链表,因此提供了按照插入顺序遍历的能力,与此同时,也保证了常数时间的添加、删除、包含等操作,这些操作性能略低于 HashSet,因为需要维护链表的开销;
  • 在遍历元素时,HashSet 性能受自身容量影响,所以初始化时,除非有必要,不然不要将其背后的 HashMap 容量设置过大。而对于 LinkedHashSet,由于其内部链表提供的方便,遍历性能只和元素多少有关系。

上面提到的集合容器,都不是线程安全的,对于 java.util.concurrent 里面的线程安全容器,之后再去分析。但是,并不代表上面的容器完全不能支持并发编程的场景,在 Collections 工具类中,提供了一系列的 synchronized 方法,比如:

static <T> List<T> synchronizedList(List<T> list)

它的实现,基本就是将每个基本方法,比如 get、set、add 之类,都使用 synchronized 添加基本的同步支持,非常简单粗暴,但也非常实用。注意这些方法创建的线程安全集合,都符合迭代时 fail-fast 行为,当发生意外的并发修改时,尽早抛出 ConcurrentModifcationException 异常,以避免不可预计的行为。

另外一个经常会被考察到的问题,就是理解 Java 提供的默认排序算法,具体是什么排序方式以及设计思路等。

这个问题本身就是有点陷阱的意味,因为需要区分是 Arrays.sort() 还是 Collections.sort() (底层是调用Arrays.sort());什么数据类型;多大的数据集(太小的数据集,复杂排 序是没必要的,Java会直接进行二分插入排序)等。

  • 对于原始数据类型,目前使用的是所谓双轴快速排序(Dual-Pivot QuickSort),是一种改进的快速排序算法,早期版本是相对传统的快速排序;
  • 而对于对象数据类型,目前则是使用 TimSort,思想上也是一种归并和二分插入排序(binarySort)结合的优化排序算法。TimSort 并不是 Java 的独创,简单说它的思路是查找数据集中已经排好序的分区(这里叫 run),然后合并这些分区来达到排序的目的。

另外,Java 8 引入了并行排序算法(直接使用 parallelSort 方法),这是为了充分利用现代多核处理器的计算能力,底层实现基于 fork-join 框架,当处理的数据集比较小的时候,差距不明显,甚至还表现差一点;但是,当数据集增长到数万或百万以上时,提高就非常大了,具体还是取决于处理器和系统环境。

在 Java 8 之中,Java 平台支持了 Lambda 和 Stream,相应的 Java 集合框架也进行了大范围的增强,以支持类似为集合创建相应 stream 或者 parallelStream 的方法实现,我们可以非常方便的实现函数式代码。

阅读 Java 源代码,你会发现,这些 API 的设计和实现比较独特,它们并不是实现在抽象类里面,而是以默认方法的形式实现在 Collection 这样的接口里!这是 Java 8 在语言层面的新特性,允许接口实现默认方法,理论上来说,我们原来实现在类似 Collections 这种工具类中的方法,大多可以转换到相应的接口上。

在Java 9 中,Java 标准类库提供了一系列的静态工厂方法,比如,List.of()Set.of(),大大简化了构建小的容器实例的代码量。根据业界实践经验,我们发现相当一部分集合实例 都是容量非常有限的,而且在生命周期中并不会进行修改,利用新的容器静态工厂方法,一句代码就够了,并且保证了不可变性。

Lis<String> simpleList = List.of("Hello","world");

更进一步,通过各种 of 静态工厂方法创建的实例,还应用了一些我们所谓的最佳实践,比如,它是不可变的,符合我们对线程安全的需求;它因为不需要考虑扩容,所以空间上更加紧凑等。

如果我们去看 of 方法的源码,你还会发现一个特别有意思的地方:我们知道 Java 已经支持所谓的可变参数(varargs),但是官方类库还是提供了一系列特定参数长度的方法,看起来似乎非常不优雅,为什么呢?这其实是为了最优的性能,JVM 在处理变长参数的时候会有明显的额外开销,如果你需要实现性能敏感的 API,也可以进行参考。

任务调度系统中数据结构

Q:实现一个云计算任务调度系统,希望可以保证 VIP 客户的任务被优先处理,可以利用哪些数据结构或者标准的集合类型?

方案一:首先想到的是优先级队列,但是还需要考虑 vip 再分级,即同等级的 vip 的平权问题,所以应该考虑除了直接和 vip 等级相关的优先级队列规则问题,还得考虑同等级多个客户互相不被单一客户大量任务阻塞的问题。

方案二:利用并发中相关数据结构,例如 PriorityBlockingQueue,可以参考银行窗口,例如有三个窗口,就是三个队列,银行柜台就是消费者线程,某一个窗口 vip 优先,没有 vip 时可以去其他队列中窃取任务,有 vip 时不允许其他普通客户进入。

方案三:利用 PriorityBlockingQueueDisruptor 可实现基于任务优先级为调度策略的执行调度系统

技术介绍:https://tech.meituan.com/2016/11/18/disruptor.html

https://juejin.cn/post/6844904020973191181


Author: NaiveKyo
Reprint policy: All articles in this blog are used except for special statements CC BY 4.0 reprint polocy. If reproduced, please indicate source NaiveKyo !
  TOC