Integer缓存池

这里引申出一个经典问题,看下面代码

为什么第一个输出的是true,第二个输出的是false?

Integer a = 100的这种直接赋值操作,是调⽤Integer.valueOf(100)方法,从Integer.valueOf()源码可以看到,返回的是Integer对象,但这里的实现并不是简单的new Integer,而是先判断 i 这个值是否在IntegerCache范围内,如果在,直接返回IntegerCache中的值,如果不在则new Integer

从源码可以看到,默认Integer cache 的下限是-128,上限默认127。当赋值100给Integer时,刚好在这个范围内,所以从cache中取对应的Integer并返回,所以a和b返回的是同一个对象,所以 比较是相等的,当赋值200给Integer时,不在cache 的范围内,所以会new Integer并返回,当然 比较的结果是不相等的。

扩展:Byte,Short,Integer,Long 这 4 种包装类默认创建了数值 [-128,127] 的相应类型的缓存数据,Character 创建了数值在 [0,127] 范围的缓存数据,Boolean 直接返回 True or False

1、2、3都好理解,缓存范围是 [-128,127],1、2都在范围内,返回的是缓存中的对象,因此输出true,3不在范围内,返回的是新 new 的Integer,因此输出false。

那为什么4输出的是true呢? 128 在缓存范围外,按道理会 new 出一个Integer对象,为什么输出true呢?

  • 首先Integer.parseInt方法返回的是int 基本数据类型,不是对象,也就是说 Integer.parseInt(“128”) = 128

  • 当进行比较(==)运算时,会进行自动拆箱,也就是说 Integer.valueOf(128) 生成的 Integer 会自动拆箱成128,那么比较两个相等的额数值自然是true的

注意:使用==运算符时,需要一边是基本数据类型才会自动拆箱,如果两边都是引用数据类型,是不会自动拆箱的。

当基础类型与它们的包装类有如下几种情况时,编译器会自动进行装箱或拆箱:

  • 赋值操作(装箱或拆箱)

  • 进行加减乘除混合运算 (拆箱)

  • 进行>,<,>=,<=,== 比较运算(拆箱)

  • 调用equals进行比较(装箱)

  • ArrayList、HashMap等集合类添加基础类型数据时(装箱)

注意:三目运算符 condition ? 表达式 1:表达式 2 中,高度注意表达式 1 和 2 在类型对齐时,可能抛出因自动拆箱导致的 NPE 异常

  1. 表达式 1 或 表达式 2 的值只要有一个是原始类型。

  2. 表达式 1 或 表达式 2 的值的类型不一致,会强制拆箱升级成表示范围更大的那个类型。

缓存机制存在的原因:将频繁被使用的对象缓存起来,可以提升读取的效率,这是一个典型的用空间换时间的例子(其实缓存机制都是这个原理),而Java开发者认为[-128,127]是比较常使用的范围。

BigDecimal

《阿里巴巴 Java 开发手册》中提到:“浮点数之间的等值判断,基本数据类型不能用 == 来比较,包装数据类型不能用 equals 来判断”。“为了避免精度丢失,可以使用 BigDecimal 来进行浮点数的运算”。

浮点数的运算竟然还会有精度丢失的风险吗?确实会!

示例代码:

为什么浮点数 floatdouble 运算的时候会有精度丢失的风险呢?

这个和计算机保存浮点数的机制有很大关系。我们知道计算机是二进制的,而且计算机在表示一个数字时,宽度是有限的,无限循环的小数存储在计算机时,只能被截断,所以就会导致小数精度发生损失的情况。这也就是解释了为什么浮点数没有办法用二进制精确表示。

就比如说十进制下的 0.2 就没办法精确转换成二进制小数:

关于浮点数的更多内容,建议看一下计算机系统基础(四)浮点数这篇文章。

BigDecimal 介绍

BigDecimal 可以实现对浮点数的运算,不会造成精度丢失。

通常情况下,大部分需要浮点数精确运算结果的业务场景(比如涉及到钱的场景)都是通过 BigDecimal 来做的。

想要解决浮点数运算精度丢失这个问题,可以直接使用 BigDecimal 来定义浮点数的值,然后再进行浮点数的运算操作即可。

BigDecimal 常见方法

创建

在使用 BigDecimal 时,为了防止精度丢失,推荐使用它的BigDecimal(String val)构造方法或者 BigDecimal.valueOf(double val) 静态方法来创建对象。

《阿里巴巴 Java 开发手册》对这部分内容也有提到,如下图所示。

加减乘除

  • add 方法:两个 BigDecimal 对象相加

  • subtract 方法:两个 BigDecimal 对象相减

  • multiply 方法:两个 BigDecimal 对象相乘

  • divide 方法:两个 BigDecimal 对象相除。

这里需要注意的是,在使用 divide 方法的时候尽量使用 3 个参数版本,并且RoundingMode 不要选择 UNNECESSARY,否则很可能会遇到 ArithmeticException(无法除尽出现无限循环小数的时候),其中 scale 表示要保留几位小数,roundingMode 代表保留规则。

保留几位小数 setScale

通过 setScale方法设置保留几位小数以及保留规则。保留规则如上,不需要记,IDEA 会提示。

保留规则非常多,这里列举几种:

等值比较问题

《阿里巴巴 Java 开发手册》中提到: BigDecimal 使用 equals() 方法进行等值比较出现问题的代码示例:

这是因为BigDecimal的 equals() 方法不仅仅会比较值的大小(value)还会比较精度(scale),而 compareTo() 方法比较的时候会忽略精度。

1.0 的 scale 是 1,1 的 scale 是 0,因此 a.equals(b) 的结果是 false。 compareTo() 方法可以比较两个 BigDecimal 的值: a.compareTo(b) : 返回 -1 表示 a 小于 b,0 表示 a 等于 b, 1 表示 a 大于 b

BigDecimal 存在的性能问题

由于其精确性和灵活性,BigDecimal 在某些场景下同样可能会带来性能问题。

BigDecimal的性能问题主要源于以下几点:

  1. 内存占用:BigDecimal 对象的内存占用较大,尤其是在处理大数字时。每个 BigDecimal 实例都需要维护其精度和标度等信息,这会导致内存开销增加。

  2. 不可变性:BigDecimal 是不可变类,每次进行运算或修改值时都会生成一个新的 BigDecimal 实例。这意味着频繁的操作可能会导致大量的对象创建和垃圾回收,对性能造成一定的影响。

  3. 运算复杂性:由于 BigDecimal 要求精确计算,它在执行加、减、乘、除等运算时会比较复杂。这些运算需要更多的计算和处理时间,相比原生的基本类型,会带来一定的性能损耗。

性能问题验证:

运行结果:

性能优化策略

BigDecimal 性能问题优化策略,可以考虑以下几点优化策略:

  1. 避免频繁的对象创建:尽量复用 BigDecimal 对象,而不是每次运算都创建新的实例。可以使用 BigDecimal 的 setScale() 方法设置精度和舍入模式,而不是每次都创建新的对象。

  2. 使用原生类型替代:对于一些不需要精确计算的场景,可以使用原生类型(如 int、double、long)来进行运算,以提高性能。只在最后需要精确结果时再转换为 BigDecimal。

  3. 使用适当的缓存策略:对于频繁使用的 BigDecimal 对象,可以考虑使用缓存来避免重复创建和销毁。例如,使用对象池或缓存来管理常用的 BigDecimal 对象,以减少对象创建和垃圾回收的开销。

  4. 考虑并行计算:对于大规模的计算任务,可以考虑使用并行计算来提高性能。Java 8 提供了 Stream API 和并行流(parallel stream),可以方便地实现并行计算。

需要根据具体的应用场景和需求来权衡精确性和性能,选择合适的处理方式。在对性能要求较高的场景下,可以考虑使用其他更适合的数据类型或算法来替代 BigDecimal。在需要精度计算的情况下,也不能因为BigDecimal存在一定的性能问题二选择弃用,顾此失彼。

BigDecimal 工具类分享

网上有一个使用人数比较多的 BigDecimal 工具类,提供了多个静态方法来简化 BigDecimal 的操作。源码:

小结

浮点数没有办法用二进制精确表示,因此存在精度丢失的风险。不过,Java 提供了BigDecimal 来操作浮点数。BigDecimal 的实现利用到了 BigInteger (用来操作大整数), 所不同的是 BigDecimal 加入了小数位的概念。

String

Java中的String是不可变对象

在面向对象及函数编程语言中,不可变对象(英语:Immutable object)是一种对象,在被创造之后,它的状态就不可以被改变。至于状态可以被改变的对象,则被称为可变对象(英语:mutable object)。– 来自百度百科

Java8 String源码

显然String字符串内部是使用char[]数组来存储。

而这个char[]数组是用 private final来修饰的,private就体现着面向对象的封装特性,并且String没有提供供外部访问的方法,这就意味着这个属性无法被外部访问;final则意味着这个属性无法修改,无法重新指向其他对象。且String 类没有提供/暴露修改这个字符串的方法。

因此,String是不可变对象

不可变的优点

  • 线程安全。同一个字符串实例可以被多个线程共享,因为字符串不可变,本身就是线程安全的。

  • 支持hash映射。因为String的hash值经常会使用到,比如作为 Map 的键,不可变的特性也就使得hash值不会变,不需要重新计算。

  • 字符串常量池优化。String对象创建之后,会缓存到字符串常量池中,下次需要创建同样的对象时,可以直接返回缓存的引用。

一定不可变吗

事实上,可以通过反射来改变String中的值

显然修改后的还是同一个地址和hash值

查看源码可以看到,计算hashcode后hash值是由一个常量缓存下来的,所以通过反射修改后hashCode并不会变,除非进行重新计算。

注意:用反射修改String的值破坏了String的immutable特征,可能会带来各种问题,以上只是提供一个思路,不建议这么做。

不可变类都建议参考String类一样,写个变量缓存hashcode,从而防止高并发下的计算

String能存储多少字符

能存储多少字符,通过以下步骤来看:

  1. 首先String的length方法返回是int。所以理论上长度一定不会超过int的最大值。

  2. 编译器对字符串字面量长度的限制源自Java编译器(如javac)在处理常量池时的实现。编译器源码如下,限制了字符串长度大于等于65535就会编译不通过:

Java中的字符常量都是使用UTF 8编码的,UTF 8编码使用1~4个字节来表示具体的Unicode字符。所以有的字符占用一个字节,而平时所用的大部分中文都需要3个字节来存储。

  • 对于s1,一个字母d的UTF8编码占用一个字节,65534个字母占用65534个字节,长度是65534,长度和存储都没超过限制,所以可以编译通过。

  • 对于s2,一个中文占用3个字节,21845个正好占用65535个字节,而且字符串长度是21845,长度和存储也都没超过限制,所以可以编译通过。

  • 对于s3,一个英文字母d加上21845个中文”自“占用65536个字节,超过了存储最大限制,编译失败。

当然,这个限制是特定于编译器的实现,而不是Java语言本身的限制。

  1. JVM规范对常量池有所限制。

量池中的每一种数据项都有自己的类型。Java中的UTF-8编码的Unicode字符串在常量池中以CONSTANTUtf8类型表示。CONSTANTUtf8的数据结构如下:

重点关注长度为 length 的那个bytes数组,这个数组就是真正存储常量数据的地方,而 length 就是数组可以存储的最大字节数,而不是字符数。length 的类型是u2,u2是无符号的16位整数,因此理论上允许的的最大长度是2^16-1=65535。所以上面byte数组的最大长度可以是65535。

当然,考虑到UTF-8是一种变长编码,一个字符可能需要1到4个字节来表示(取决于字符的具体值)。因此,如果你的字符串包含大量使用多个字节编码的字符,那么它能包含的实际字符数将会少于65535。

  1. 运行时限制

String 运行时的限制主要体现在 String 的构造函数上。下面是 String 的一个构造函数:

上面的count值就是字符串的最大长度。在Java中,int的最大长度是2^31-1。所以在运行时,String 的最大长度是2^31-1。

但是这个也是理论上的长度,实际的长度还要看JVM的内存。来看下,最大的字符串会占用多大的内存。

所以在最坏的情况下,一个最大的字符串要占用4GB的内存。如果JVM不能分配这么多内存的话,会直接报错的。

总结:因此,主要的还是看编译器对常量池的限制,使得byte数组的最大长度不能超过65535;以及JVM的内存限制

补充:JDK9以后对String的存储进行了优化。底层不再使用char数组存储字符串,而是使用byte数组。对于LATIN1字符的字符串可以节省一倍的内存空间。详情请看 Java9 - string字符串的变化

String, StringBuffer 和 StringBuilder的区别

可变性

  • String不可变

  • StringBuffer 和 StringBuilder 可变

线程安全

  • String 不可变,因此是线程安全的

  • StringBuilder不是线程安全的

  • StringBuffer 是线程安全的,内部使用 synchronized 进行同步

StringBuffer的append方法

循环拼接字符串建议StringBuilder

JDK 8下的字符串拼接实现

编译查看字节码

javap输出的字节码:

反编译后的Java代码

可以看出,+ 号操作符其实就是一种语法糖,让字符串的拼接变得更简便了。可以看到编译器自动将 + 转换成了 StringBuilder.append() 方法,拼接之后再调用 StringBuilder.toString() 方法转换成字符串。

但这是单次拼接,如果要拼接大量字符串呢?例如使用 for 循环模拟频繁的字符串拼接操作时,使用 + 的话,在每一次循环中,都将重复下列操作:

  • 新建 StringBuilder 对象

  • 调用 StringBuilder.append() 方法

  • 调用 StringBuilder.toString() 方法,该方法会通过 new String() 创建字符串

如果是几万次循环下来,可以看看创建了多少中间对象,别人要么以空间换时间,要么以时间换空间。这家伙倒好,即浪费时间,又浪费空间。所以,在频繁拼接字符串的情况下,尽量避免使用 +

StringBuilder源码

String源码,存放字符串的地方

StringBuilder自身没有自定义存储的容器,而是继承了其父类的容器

不一样的地方就在于 String 的value是不可变的,而StringBuilder 的value是可变的

String拼接字符串案例

以上操作可以看成是

  • 创建s1的时候其实就是创建了第一个不可变的char[]数组,创建s2的时候创建了第二个不可变的char[]数组

  • 创建str的时候其实就是另外又创建了一个数组,再将s1和s2的数据复制到str中

StringBuilder拼接字符串案例

输出

StringBuilder特征:StringBuilder初始化容量是16(无参构造)

  • 追加之前会计算一次容量,大于所需容量则会重新创建一个char[]数组,计算规则是 newCapacity = (value.length << 1) + 2; 也就是原来长度*2 + 2

  • StringBuilder在运算的时候每次会计算容量是否足够,如果所需容量不小于自身容量,那么就会重新分配一个自身容量两倍 +2 的char[]

因此,如果再一次追加的时候容量足够,就无需创建新数组,也就省去了很多创建char[]的次数.

小结

  • 在非循环体内

    • 可以看出,在JDK 8中,在非循环体内(少数拼接)使用"+“实现字符串拼接和使用StringBuilder是一样的,用”+“做拼接代码更简洁,推荐使用”+"
  • 拼接次数较多,如循环体内拼接

  • String之所以慢是因为,大部分cpu资源都被浪费在分配资源,新建StringBuilder对象,拷贝资源的部分了,相比StringBuilder有更多的内存消耗。

  • StringBuilder快就快在,相比String,他在运算的时候分配内存次数小,所以拷贝次数和内存占用也随之减少,当有大量字符串拼接时,StringBuilder创建char[]的次数会少很多。

  • 由于GC的机制,即使原来的char[]没有引用了,那么也得等到GC触发的时候才能回收,String运算过多的时候就会产生大量垃圾,消耗内存。

因此:

  • 如果目标字符串需要大量拼接的操作,那么这个时候应当使用StringBuilder。

  • 反之,如果目标字符串操作次数极少,或者是常量,那么就直接使用String。

着重注意

这是JDK8版本的结论,在JDK 9之后,JDK官方已经对字符串拼接做了优化,使用"+“做字符串拼接会比StringBuilder快,详情可以看这篇文章,JDK9的新特性。

但这也是在非循环体内,少数拼接的情况,当多大量拼接,还是建议使用StringBuilder

String.intern()

调用字符串对象的intern方法,会将该字符串对象尝试放入到串池中

  • 如果串池中没有该字符串对象,则放入成功

  • 如果有该字符串对象,则放入失败

无论放入是否成功,都会返回串池中的字符串对象

注意:此时如果调用intern方法成功,堆内存与串池中的字符串对象是同一个对象;如果失败,则不是同一个对象

例1:

例2:

自定义一个String类

包名不为java.lang

可以正常使用,但在使用时有有用到java.lang下的String,那就需要区别报名使用,即其中一个使用全类名表示,一般生产中不会去自定义一个与JDK类库中同名的类,这里只作为拓展了解即可~

包名为java.lang.String

String类下写main方法

输出:

原因在于双亲委派模型,先从父类加载器寻找能不能加载此类,如果没有则再到子类;因此在加载String类时,会最终委派给Bootstrap ClassLoader去加载,加载的是rt.jar包中的那个java.lang.String,而rt.jar包中的String类是没有main方法的,因此报错误

同包下新建一个类写main方法

输出:

限制包名,不能自定义这个包名,与java类库冲突,安全管理器不通过,这里不管用不用到String都会有这个报错

原因:java.lang 是java 自带类库包,是属于rt.jar 包下的文件,而rt.jar 是通过启动类加载器(Bootstrap ClassLoader)加载的,由于双亲委派,因此java.lang 包肯定早于自定义的java.lang 包的加载,就会冲突。

调用方法不在java.lang包中

无输出

原因,由于双亲委派,这里加载的String包是rt.jar中的java.lang.String类。因此这里并没有用到自定义的String类,因为不会加载到自定义的String(即便改自定义String的包名也叫java.lang)

小结

可以自定义包名不为java.lang的String类,并区别包名正常使用

自定义包名为java.lang的String类

  • String类下写main方法:由于双亲委派模型,在加载String类时,会最终委派给Bootstrap ClassLoader去加载,加载的是rt.jar包中的那个java.lang.String,而rt.jar包中的String类是没有main方法的,因此报错误

  • 启动类也在java.lang包下:这里与是否用到String类无关,会报 Prohibited package name: java.lang错误。由于双亲委派,java.lang 包肯定早于自定义的java.lang 包的加载,就会冲突.

  • 调用方法不在java.lang包中:此时由于双亲委派模型的存在,并不会加载到自定义的String类

数组是不是对象

什么是对象? 对象是类的一个实例,有状态和行为

Java对象:

  • 软件的对象也有行为和状态

  • 软件对象的状态称之为属性

  • 方法操作对象内部状态的改变,对象的相互调用也是通过方法来完成

而java中的数组具有java中其他对象的一些基本特点。比如封装了一些数据,可以访问属性,也可以调用方法。 因此,数组是对象

证明

可以通过代码验证数组是对象的事实

显然,数组继承与Object,是对象

同理,二维数组也是对象

为什么使用Arrays.sort时不能自定义比较器

Arrays.sort()默认是升序排序,如果要降序排序,需要自定义比较器

报错显示:需要的是int类型,但提供的是T类型的

这是因为Arrays.sort方法有多个重载版本,其中针对基本类型数组(如int[])的版本不接受自定义比较器。你尝试传入一个自定义比较器给int[]数组的Arrays.sort方法,因此会导致编译错误。

具体来说,Arrays.sort有以下几种主要的重载方法:

  1. Arrays.sort(int[] arr):用于排序int数组,按自然顺序排序,不接受比较器。

  2. Arrays.sort(T[] arr, Comparator&lt;? super T&gt; c):用于排序泛型对象数组,按自定义比较器排序。

因此如果试图将一个自定义比较器传入int数组的Arrays.sort方法,这是不被允许的,因为基本类型数组的排序方法不接受比较器。

一维数组自定义排序可以用如下方法:

Object通用方法

equals和hashcode

==

“==” 是运算符

如果比较的对象是基本数据类型,则比较的是其存储的值是否相等;

如果比较的是引用数据类型,则比较的是所指向对象的地址值是否相等(是否是同一个对象)。

equals

作用是 用来判断两个对象是否相等。通过判断两个对象的地址是否相等(即,是否是同一个对象)来区分它们是否相等。源码如下:

equals 方法不能用于比较基本数据类型,如果没有对 equals 方法进行重写,则相当于“==”,比较的是引用类型的变量所指向的对象的地址值。

一般情况下,类会重写equals方法用来比较两个对象的内容是否相等。比如String类中的equals()是被重写了,比较的是对象的值。

hashcode

hashcode特性体现主要在它的查找效率上,O(1)的复杂度,在Set和Map这种使用哈希表结构存储数据的集合中。hashCode方法的就大大体现了它的价值,主要用于在这些集合中确定对象在整个哈希表中存储的区域。

如果两个对象相同,则这两个对象的equals方法返回的值一定为true,两个对象的hashCode方法返回的值也一定相同。(equals相同,hashcode一定相同,因为重写的hashcode就是计算属性的hashcode值)

如果两个对象返回的HashCode的值相同,但不能够说明这两个对象的equals方法返回的值就一定为true,只能说明这两个对象在存储在哈希表中的同一个桶中。

只重写了equals方法,未重写hashCode方法

在Java中equals方法用于判断两个对象是否相等,而HashCode方法在Java中主要由于哈希算法中的寻域的功能(也就是寻找数据应该存储的区域的)。在类似于set和map集合的结构中,Java为了提高在集合中查询匹配元素的效率问题,引入了哈希算法,通过HashCode方法得到对象的hash码,再通过hash码推算出数据应该存储的位置。然后再进行equals操作进行匹配,减少了比较次数,提高了效率。

  • 当只重写了equals方法,未重写hashCode方法时,equals方法判断两个对象是否相等时,返回的是true(第三个输出),这是因为我们重写equals方法时,是对属性的比较;但判断两个对象的hashCode值是否相等时,返回的是false(第二个输出),在没有重写hashCode方法的情况下,调用的是Object的hashCode方法,返回的是本对象的hashCode值,两个对象不一样,因此hashCode值不一样。

  • 在set和map中,首先判断两个对象的hashCode方法返回的值是否相等,如果相等然后再判断两个对象的equals方法,如果hashCode方法返回的值不相等,则直接会认为两个对象不相等,不进行equals方法的判断。因此在set添加对象时,因为hashCode值已经不一致,判断出p1和p2是两个对象,都会添加进set集合中,因此返回集合中数据个数为 2 (第四个输出)

重写hashCode方法:重写hashcode方法时,一般也是对属性值进行hash

重写了hashCode后,其是对属性值的hash,p1和p2的属性值一致,因此p1.hashCode() == p2.hashCode()为true,再进行equals方法的判断也为true,认为是一个对象,因此set集合中只有一个对象数据。

为什么重写hashCode一定也要重写equals方法?

如果两个对象的hashCode相同,它们是并不一定相同的,因为equals方法不相等而hashCode方法返回的值却有可能相同的,比如两个不同的对象hash到同一个桶中

hashCode方法实际上是通过一种算法得到一个对象的hash码,这个hash码是用来确定该对象在哈希表中具体的存储区域的。返回的hash码是int类型的所以它的数值范围为 [-2147483648 - +2147483647] 之间的,而超过这个范围,实际会产生溢出,溢出之后的值实际在计算机中存的也是这个范围的。比如最大值 2147483647 + 1 之后并不是在计算机中不存储了,它实际在计算机中存储的是-2147483648。在java中hash码也是通过特定算法得到的,所以很难说在这个范围内情况下不会不产生相同的hash码的。也就是说常说的哈希碰撞,因此不同对象可能有相同的hashCode的返回值。

因此equals方法返回结果不相等,而hashCode方法返回的值却有可能相同!

为什么重写equals一定也要重写hashCode方法?

这个是针对set和map这类使用hash值的对象来说的

只重写equals方法,不重写hashCode方法:

  • 有这样一个场景有两个Person对象,可是如果没有重写hashCode方法只重写了equals方法,equals方法认为如果两个对象的name相同则认为这两个对象相同。这对于equals判断对象相等是没问题的。

  • 对于set和map这类使用hash值的对象来说,由于没有重写hashCode方法,此时返回的hash值是不同的,因此不会去判断重写的equals方法,此时也就不会认为是相同的对象。

重写hashCode方法不重写equals方法

  • 不重写equals方法实际是调用Object方法中的equals方法,判断的是两个对象的堆内地址。而hashCode方法认为相等的两个对象在equals方法处并不相等。因此也不会认为是用一个对象

  • 因此重写equals方法时一定也要重写hashCode方法,重写hashCode方法时也应该重写equals方法。

总结:对于普通判断对象是否相等来说,只equals是可以完成需求的,但是如果使用set,map这种需要用到hash值的集合时,不重写hashCode方法,是无法满足需求的。尽管如此,也一般建议两者都要重写,几乎没有见过只重写一个的情况

扩展:解决哈希冲突的三种方法

解决哈希冲突的三种方法

clone()

clone方法是Java中拷贝对象的方法

在Java中拷贝对象其实是指复制一个与原对象一样的新对象出来,但是在Java中 赋值 = 是复制对象引用,如果我们想要得到一个对象的副本,使用赋值操作是无法达到目的的。

看下面例子:

显然,a1==a2返回为true,a1和a2指向的是同一个引用

并且对对象a2的属性值进行修改后,a1的属性值也跟着改变,因此这不是拷贝,要实现拷贝,需要用到Object对象的clone()方法,实现对对象中各个属性的复制,但它的可见范围是protected的

  • 所以实体类使用clone()方法的前提是:实现Cloneable接口,这是一个标记接口,自身没有方法,这算是一种约定。调用clone方法时,会去判断有没有实现Cloneable接口,没有实现Cloneable的话会抛异常CloneNotSupportedException。

  • 覆盖clone()方法,可见性提升为public。

拷贝了一个新对象,与原对象引用不同,因此返回false,并且修改新对象的属性值,旧对象的属性值不改变

浅拷贝

当类中含有引用类型的属性时,新对象和旧对象的引⽤类型属性指向的是同⼀个对象,即为浅拷贝

如上,改变 a2中的引用类型 person.name = “新对象"后,a1的的引用类型 person.name 也发生了改变。

也就是说,新对象和旧对象的引⽤类型属性指向的是同⼀个对象,因此当改变新对象的引用类型(person)的属性值时,旧对象的引用类型的属性值也被改变。因此只是浅拷贝

这里可能就有疑问了,String类型也是引用类型,为什么进行了深拷贝,即如上所示,改变a2的属性值category,但却没有改变a1的category? String类型有点特殊,它本身没有实现Cloneable接口,故根本无法克隆,只能传递引用(注意:Java只有值传递,只是这里传递是原来引用地址值)。在clone()后,克隆后的对象开始也是指向的原来引用地址值(刚克隆完检查a1.category == a2.category 为true),但是一旦String的值发生改变(String作为不可更改的类——immutable class,在新赋值的时候,会创建了一个新的对象)就改变了克隆后对象指向的地址,让它指向了一个新的String地址,不会影响原对象的指向和值,原来的String对象还是指向的它自己的的地址。这样String在拷贝的时候就表现出了深拷贝的特点

深拷贝

当类中含有引用类型的属性时,新对象和旧对象的引⽤类型属性指向的不是同⼀个对象,即为深拷贝

要实现深拷贝,在clone方法中不仅调用了super.clone,而且需要调用Person对象的clone方法(Person也要实现Cloneable接口并重写clone方法),从而实现了深拷贝。

可以看到,新对象的引用类型 person 不会再受到旧对象的影响。

但是,在EffectiveJava中,反对使用clone方法来进行克隆,详情关注谨慎重写 clone 方法

关键字

final

数据:声明数据为常量,可以是编译时常量,也可以是在运行时被初始化后不能被改变的常量。

  • 对于基本类型,final 使数值不变;

  • 对于引用类型,final 使引用不变,也就不能引用其它对象,但是被引用的对象本身是可以修改的。

方法:声明方法不能被子类重写。

  • private 方法隐式地被指定为 final,如果在子类中定义的方法和基类中的一个 private 方法签名相同,此时子类的方法不是重写基类方法,而是在子类中定义了一个新的方法。

类:声明类不允许被继承。

static

静态变量

  • 静态变量: 又称为类变量,也就是说这个变量属于类的,类所有的实例都共享静态变量,可以直接通过类名来访问它;静态变量在内存中只存在一份。

  • 实例变量: 每创建一个实例就会产生一个实例变量,它与该实例同生共死。

静态方法

  • 静态方法在类加载的时候就存在了,它不依赖于任何实例。所以静态方法必须有实现,也就是说它不能是抽象方法(abstract)。

  • 只能访问所属类的静态字段和静态方法,方法中不能有 this 和 super 关键字。

静态语句块

  • 静态语句块在类初始化时运行一次。

静态内部类

  • 非静态内部类依赖于外部类的实例,而静态内部类不需要。

  • 静态内部类不能访问外部类的非静态的变量和方法。

静态导包

  • 在使用静态变量和方法时不用再指明 ClassName,从而简化代码,但可读性大大降低。

初始化顺序

静态属性,静态代码块。

普通属性,普通代码块。

构造方法。

静态方法和变量能否被继承

父类A:

子类B:

子类C:

测试:

static小结

  • 子类会继承父类的静态方法和静态变量,但是无法对静态方法进行重写

  • 子类中可以直接调用父类的静态方法和静态变量

  • 子类可以直接修改(如果父类中没有将静态变量设为private)静态变量,但这是子类自己的静态变量。

  • 子类可以拥有和父类同名的,同参数的静态方法,但是这并不是对父类静态方法的重写,是子类自己的静态方法,子类只是把父类的静态方法隐藏了。

  • 当父类的引用指向子类时,使用对象调用静态方法或者静态变量,是调用的父类中的静态方法或者变量(这比较好理解,因为静态方法或变量是属于类的,而引用指向的是一个对象,对象中并不会包含静态的方法和属性)。也就是说,失去了多态。

  • 当子类的引用指向子类时,使用对象调用静态方法或者静态变量,就是调用的子类中自己的的静态方法或者变量了。

注意

静态变量尤其要注意并发问题。因为静态变量在Java中是类级别的变量,它们被所有类的实例共享。由于静态变量是共享资源,当多个线程同时访问和修改静态变量时,就会引发并发问题。

transient

Java语言的关键字,变量修饰符,如果用transient声明一个实例变量,当对象存储时,它的值不需要维持。

也就是说被transient修饰的成员变量,在序列化的时候其值会被忽略,在被反序列化后, transient 变量的值被设为初始值, 如 int 型的是 0,对象型的是 null。

Java为什么是值传递?

形参&实参

方法的定义可能会用到 参数(有参的方法),参数在程序语言中分为:

  • 实参(实际参数,Arguments):用于传递给函数/方法的参数,必须有确定的值。

  • 形参(形式参数,Parameters):用于定义函数/方法,接收实参,不需要有确定的值。

值传递&引用传递

程序设计语言将实参传递给方法(或函数)的方式分为两种:

  • 值传递:方法接收的是实参值的拷贝,会创建副本。

  • 引用传递:方法接收的直接是实参所引用的对象在堆中的地址,不会创建副本,对形参的修改将影响到实参。

很多程序设计语言(比如 C++、 Pascal )提供了两种参数传递的方式,不过,在 Java 中只有值传递。

为什么 Java 只有值传递?

为什么说 Java 只有值传递呢? 通过 3 个例子来给大家证明。

案例 1:传递基本类型参数

代码:

输出:

解析:在 swap() 方法中,ab 的值进行交换,并不会影响到 num1num2。因为,ab 的值,只是从 num1num2 的复制过来的。也就是说,a、b 相当于 num1num2 的副本,副本的内容无论怎么修改,都不会影响到原件本身。

通过上面例子,我们已经知道了一个方法不能修改一个基本数据类型的参数,而对象引用作为参数就不一样,请看案例 2。

案例 2:传递引用类型参数 1

代码:

输出:

看了这个案例很多人肯定觉得 Java 对引用类型的参数采用的是引用传递。实际上,并不是的,这里传递的还是值,不过,这个值是实参的地址罢了!

也就是说 change 方法的参数拷贝的是 arr (实参)的地址,因此,它和 arr 指向的是同一个数组对象。这也就说明了为什么方法内部对形参的修改会影响到实参。

为了更强有力地反驳 Java 对引用类型的参数采用的不是引用传递,我们再来看下面这个案例!

案例 3:传递引用类型参数 2

输出:

解析:swap 方法的参数 person1person2 只是拷贝的实参 xiaoZhangxiaoLi 的地址。因此, person1person2 的互换只是拷贝的两个地址的互换罢了,并不会影响到实参 xiaoZhangxiaoLi

引用传递是怎么样的?

看到这里,相信你已经知道了 Java 中只有值传递,是没有引用传递的。 但是,引用传递到底长什么样呢?下面以 C++ 的代码为例,让你看一下引用传递的庐山真面目。

输出结果:

分析:可以看到,在 incr 函数中对形参的修改,可以影响到实参的值。要注意:这里的 incr 形参的数据类型用的是 int& 才为引用传递,如果是用 int 的话还是值传递哦!

为什么 Java 不引入引用传递呢?

引用传递看似很好,能在方法内就直接把实参的值修改了,但是,为什么 Java 不引入引用传递呢?

注意:以下为个人观点看法,并非来自于 Java 官方:

  1. 出于安全考虑,方法内部对值进行的操作,对于调用者都是未知的(把方法定义为接口,调用方不关心具体实现)。你也想象一下,如果拿着银行卡去取钱,取的是 100,扣的是 200,是不是很可怕。

  2. Java 之父 James Gosling 在设计之初就看到了 C、C++ 的许多弊端,所以才想着去设计一门新的语言 Java。在他设计 Java 的时候就遵循了简单易用的原则,摒弃了许多开发者一不留意就会造成问题的“特性”,语言本身的东西少了,开发者要学习的东西也少了。

小结

Java 中将实参传递给方法(或函数)的方式是 值传递

  • 如果参数是基本类型的话,很简单,传递的就是基本类型的字面量值的拷贝,会创建副本。

  • 如果参数是引用类型,传递的就是实参所引用的对象在堆中地址值的拷贝,同样也会创建副本。

序列化和反序列化

  • 序列化:把对象转换为字节序列的过程称为对象的序列化.

  • 反序列化:把字节序列恢复为对象的过程称为对象的反序列化.

什么时候会用到

当只在本地 JVM 里运行下 Java 实例,这个时候是不需要什么序列化和反序列化的,但当出现以下场景时,就需要序列化和反序列化了:

  • 当需要将内存中的对象持久化到磁盘,数据库中时

  • 当需要与浏览器进行交互时

  • 当需要实现 RPC 时

但是当我们在与浏览器交互时,还有将内存中的对象持久化到数据库中时,好像都没有去进行序列化和反序列化,因为我们都没有实现 Serializable 接口,但一直正常运行?

先给出结论:只要我们对内存中的对象进行持久化或网络传输,这个时候都需要序列化和反序列化.

理由:服务器与浏览器交互时真的没有用到 Serializable 接口吗? JSON 格式实际上就是将一个对象转化为字符串,所以服务器与浏览器交互时的数据格式其实是字符串,我们来看来 String 类型的源码:

String 类型实现了 Serializable 接口,并显示指定 serialVersionUID 的值.

然后再来看对象持久化到数据库中时的情况,Mybatis 数据库映射文件里的 insert 代码:

实际上并不是将整个对象持久化到数据库中,而是将对象中的属性持久化到数据库中,而这些属性(如Date/String)都实现了 Serializable 接口。

为什么要实现 Serializable 接口?

在 Java 中实现了 Serializable 接口后, JVM 在类加载的时候就会发现我们实现了这个接口,然后在初始化实例对象的时候就会在底层实现序列化和反序列化。如果被写对象类型不是String、数组、Enum,并且没有实现Serializable接口,那么在进行序列化的时候,将抛出NotSerializableException。源码如下:

为什么要显示指定 serialVersionUID 的值?

如果不显示指定 serialVersionUID,JVM 在序列化时会根据属性自动生成一个 serialVersionUID,然后与属性一起序列化,再进行持久化或网络传输. 在反序列化时,JVM 会再根据属性自动生成一个新版 serialVersionUID,然后将这个新版 serialVersionUID 与序列化时生成的旧版 serialVersionUID 进行比较,如果相同则反序列化成功,否则报错.

如果显示指定了 serialVersionUID,JVM 在序列化和反序列化时仍然都会生成一个 serialVersionUID,但值为显示指定的值,这样在反序列化时新旧版本的 serialVersionUID 就一致了.

当然了,如果类写完后不再修改,那么不指定serialVersionUID,不会有问题,但这在实际开发中是不可能的,类会不断迭代,一旦类被修改了,那旧对象反序列化就会报错。 所以在实际开发中,都会显示指定一个 serialVersionUID。

static 属性为什么不会被序列化?

因为序列化是针对对象而言的,而 static 属性优先于对象存在,随着类的加载而加载,所以不会被序列化.

看到这个结论,是不是有人会问,serialVersionUID 也被 static 修饰,为什么 serialVersionUID 会被序列化? 其实 serialVersionUID 属性并没有被序列化,JVM 在序列化对象时会自动生成一个 serialVersionUID,然后将显示指定的 serialVersionUID 属性值赋给自动生成的 serialVersionUID。

常见序列化的方式

序列化只是定义了拆解对象的具体规则,那这种规则肯定也是多种多样的,比如现在常见的序列化方式有:JDK 原生、JSON、ProtoBuf、Hessian、Kryo等。

  • JDK 原生

作为一个成熟的编程语言,JDK自带了序列化方法。只需要类实现了Serializable接口,就可以通过ObjectOutputStream类将对象变成byte[]字节数组。

JDK 序列化会把对象类的描述信息和所有的属性以及继承的元数据都序列化为字节流,所以会导致生成的字节流相对比较大。

另外,这种序列化方式是 JDK 自带的,因此不支持跨语言。

简单总结一下:JDK 原生的序列化方式生成的字节流比较大,也不支持跨语言,因此在实际项目和框架中用的都比较少。

  • ProtoBuf

谷歌推出的,是一种语言无关、平台无关、可扩展的序列化结构数据的方法,它可用于通信协议、数据存储等。序列化后体积小,一般用于对传输性能有较高要求的系统。

  • Hessian

Hessian 是一个轻量级的二进制 web service 协议,主要用于传输二进制数据。

在传输数据前 Hessian 支持将对象序列化成二进制流,相对于 JDK 原生序列化,Hessian序列化之后体积更小,性能更优。

  • Kryo

Kryo 是一个 Java 序列化框架,号称 Java 最快的序列化框架。Kryo 在序列化速度上很有优势,底层依赖于字节码生成机制。

由于只能限定在 JVM 语言上,所以 Kryo 不支持跨语言使用。

  • JSON

上面讲的几种序列化方式都是直接将对象变成二进制,也就是byte[]字节数组,这些方式都可以叫二进制方式。

JSON 序列化方式生成的是一串有规则的字符串,在可读性上要优于上面几种方式,但是在体积上就没什么优势了。

另外 JSON 是有规则的字符串,不跟任何编程语言绑定,天然上就具备了跨平台。

总结一下:JSON 可读性强,支持跨平台,体积稍微逊色。

JSON 序列化常见的框架有:fastJSONJacksonGson 等。

序列化技术的选型

上面列举的这些序列化技术各有优缺点,不能简单地说哪一种就是最好的,不然也不会有这么多序列化技术共存了。

既然有这么多序列化技术可供选择,那在实际项目中如何选型呢?

我认为需要结合具体的项目来看,比较技术是服务于业务的。你可以从下面这几个因素来考虑:

  • 协议是否支持跨平台:如果一个大的系统有好多种语言进行混合开发,那么就肯定不适合用有语言局限性的序列化协议,比如 JDK 原生、Kryo 这些只能用在 Java 语言范围下,你用 JDK 原生方式进行序列化,用其他语言是无法反序列化的。

  • 序列化的速度:如果序列化的频率非常高,那么选择序列化速度快的协议会为你的系统性能提升不少。

  • 序列化生成的体积:如果频繁的在网络中传输的数据那就需要数据越小越好,小的数据传输快,也不占带宽,也能整体提升系统的性能,因此序列化生成的体积就很关键了。

时间类库相关

在 Java 中,处理日期和时间的方式经历了演变。在 Java 8 之前,主要使用 java.util.Date 类来表示日期和时间,但它存在一些问题,如不可变性、线程安全性等。Java 8 引入了新的日期时间 API,位于 java.time 包中,提供了更加强大、易用和安全的日期时间处理方式。

LocalDate、LocalTime、LocalDateTime

说个题外话:如果在国际化应用中使用 LocalDate 时,需要明确理解其不包含时区信息的特点。这意味着,如果直接使用 LocalDate.now() 来获取“当前日期”,实际上会使用系统默认的时区来确定当前的日期。这对于那些严格依赖于用户所在地的具体日期的应用来说,可能会引入一些问题。

例如,假设服务器位于美国东部时间区(EST),而用户位于新西兰(NZST)。当美国东部时间是4月1日的晚上11点时,在新西兰已经是4月2日的下午3点。使用 LocalDate.now() 得到的日期将基于服务器的时区,而不是用户的时区,这在某些情况下可能不是期望的行为。

Instant时间戳

Instant类是为了方便计算机理解的而设计的,它表示一个持续时间段上某个点的单一大整型数,实际上它是以Unix元年时间(传统的设定为UTC时区1970年1月1日午夜时分)开始所经历的秒数进行计算(最小计算单位为纳秒)。

Duration与Period

Duration是用于比较两个LocalTime对象或者两个Instant之间的时间差值。