跳到主要内容

01-Java基础知识

1. 注意 equals 和 == 的区别

比较值的内容,除了基本类型只能使用 == 外,其他类型都需要使用 equals。

  • 对基本类型,比如 int,long,进行判等,只能使用 ==,比较的是直接值。因为基本类型的值就是其数值。
  • 对引用类型,比如 Integer,Long 和 String,进行判等,需要使用 equals 进行内容判等。因为引用类型的直接值是指针,使用 == 的话,比较的是指针,也就是两个对象在内存中的地址,即比较它们是不是同一个对象,而不是比较对象的内容。

重写equals 应该注意的点

  • 考虑到性能,可以先进行指针判等,如果对象是同一个那么直接返回 true;
  • 需要对另一方进行判空,空对象和自身进行比较,结果一定是 fasle;
  • 需要判断两个对象的类型,如果类型都不同,那么直接返回 false;
  • 确保类型相同的情况下再进行类型强制转换,然后逐一判断所有字段。
  • hashCode 和 equals 要配对实现

关于equals其实还有一个大坑,equals比较的对象除了所谓的相等外,还有一个非常重要的因素,就是该对象的类加载器也必须是同一个,不然equals返回的肯定是false;之前遇到过一个坑:重启后,两个对象相等,结果是true,但是修改了某些东西后,热加载(不用重启即可生效)后,再次执行equals,返回就是false,因为热加载使用的类加载器和程序正常启动的类加载器不同。关于类加载器部分,JDK 9 之前的 Java 应用都是由「启动类加载器」、「扩展类加载器」、「应用程序类加载器」这三种类加载器互相配合来完成加载的,如果有需要还可以加入自定义的类加载器来进行拓展;JDK 9 为了模块化的支持,对双亲委派模式做了一些改动:扩展类加载器被平台类加载器(Platform ClassLoader)取代。平台类加载器和应用程序类加载器都不再继承自 java.net.URLClassLoader,而是继承于 jdk.internal.loader.BuiltinClassLoader。具体细节可以自行搜索。

2. Integer 的内部做了缓存 。

public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}

默认情况下会缓存[-128, 127]的数值,而 128 处于这个区间之外。

private static class IntegerCache {
static final int low = -128;
static final int high;
static {
// high value may be configured by property==
int h = 127;
String integerCacheHighPropValue =
sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
if (integerCacheHighPropValue != null) {
try {
int i = parseInt(integerCacheHighPropValue);
i = Math.max(i, 127);
// Maximum array size is Integer.MAX_VALUE
h = Math.min(i, Integer.MAX_VALUE - (-low) -1);
} catch( NumberFormatException nfe) {
// If the property cannot be parsed into an int, ignore it.
}
}
high = h;
cache = new Integer[(high - low) + 1];
int j = low;
for(int k = 0; k < cache.length; k++)
cache[k] = new Integer(j++);
// range [-128, 127] must be interned (JLS7 5.1.7)
assert IntegerCache.high >= 127;
}
}

可以看到最大值,可以通过配置进行配置。

3. getClass 和 instanceof 的区别

使用 getClass 和 instanceof 这两种方案都是可以判断对象类型的。它们的区别就是,getClass 限制了这两个对象只能属于同一个类,而 instanceof 却允许两个对象是同一个类或其子类。正是因为这种区别,不同的人对这两种方案有不同的喜好,争论也很多。在我看来,你只需要根据自己的要求去选择。补充说明一下,Lombok 使用的是 instanceof 的方案。

4. String 相关

Java 的字符串常量池机制。首先要明确的是其设计初衷是节省内存。当代码中出现双引号形式创建字符串对象时,JVM 会先对这个字符串进行检查,如果字符串常量池中存在相同内容的字符串对象的引用,则将这个引用返回;否则,创建新的字符串对象,然后将这个引用放入字符串常量池,并返回该引用。这种机制,就是字符串驻留或池化。

4.1 String 长度限制

String 内部是以char数组的形式存储,数组的长度是int类型,那么String允许的最大长度就是 Integer.MAX_VALUE 了。又由于java中的字符是以16位存储的,因此大概需要4GB的内存才能存储最大长度的字符串。不过这仅仅是对字符串变量而言,如果是字符串字面量(string literals),如“abc"、"1a2b"之类写在代码中的字符串literals,那么允许的最大长度取决于字符串在常量池中的存储大小,也就是字符串在class格式文件中的存储格式:

CONSTANT_Utf8_info {
u1 tag;
u2 length;
u1 bytes[length];
}

u2 是无符号的16位整数,因此理论上允许的string literal的最大长度是2^16=65536。然而实际测试表明,允许的最大长度仅为65534,超过就编译错误了。

5 “危险”的 Double--BigDecimal

第一,切记,要精确表示浮点数应该使用 BigDecimal。并且,使用 BigDecimal 的 Double 入参的构造方法同样存在精度丢失问题,应该使用 String 入参的构造方法或者 BigDecimal.valueOf 方法来初始化。

第二,对浮点数做精确计算,参与计算的各种数值应该始终使用 BigDecimal,所有的计算都要通过 BigDecimal 的方法进行,切勿只是让 BigDecimal 来走过场。任何一个环节出现精度损失,最后的计算结果可能都会出现误差。

第三,对于浮点数的格式化,如果使用 String.format 的话,需要认识到它使用的是四舍五入,可以考虑使用 DecimalFormat 来明确指定舍入方式。但考虑到精度问题,我更建议使用 BigDecimal 来表示浮点数,并使用其 setScale 方法指定舍入的位数和方式。

第四,进行数值运算时要小心溢出问题,虽然溢出后不会出现异常,但得到的计算结果是完全错误的。我们考虑使用 Math.xxxExact 方法来进行运算,在溢出时能抛出异常,更建议对于可能会出现溢出的大数运算使用 BigInteger 类。

5.1 使用 BigDecimal 表示和计算浮点数,且务必使用字符串的构造方法来初始化 BigDecimal

使用 BigDecimal 表示和计算浮点数,且务必使用字符串的构造方法来初始化 BigDecimal:

System.out.println(new BigDecimal(0.1).add(new BigDecimal(0.2)));
System.out.println(new BigDecimal(1.0).subtract(new BigDecimal(0.8)));
System.out.println(new BigDecimal(4.015).multiply(new BigDecimal(100)));
System.out.println(new BigDecimal(123.3).divide(new BigDecimal(100)));

输出如下:

0.3000000000000000166533453693773481063544750213623046875
0.1999999999999999555910790149937383830547332763671875
401.49999999999996802557689079549163579940795898437500
1.232999999999999971578290569595992565155029296875

可以看到,运算结果还是不精确,只不过是精度高了而已。这里给出浮点数运算避坑第一原则:使用 BigDecimal 表示和计算浮点数,且务必使用字符串的构造方法来初始化 BigDecimal:

System.out.println(new BigDecimal("0.1").add(new BigDecimal("0.2")));
System.out.println(new BigDecimal("1.0").subtract(new BigDecimal("0.8")));
System.out.println(new BigDecimal("4.015").multiply(new BigDecimal("100")));
System.out.println(new BigDecimal("123.3").divide(new BigDecimal("100")));

改进后,就能得到我们想要的输出了:

0.3
0.2
401.500
1.233

BigDecimal 有 scale 和 precision 的概念,scale 表示小数点右边的位数,而 precision 表示精度,也就是有效数字的长度。

BigDecimal 的 toString 方法得到的字符串和 scale 相关,又会引出了另一个问题:对于浮点数的字符串形式输出和格式化,我们应该考虑显式进行,通过格式化表达式或格式化工具来明确小数位数和舍入方式。

double num1 = 3.35;
float num2 = 3.35f;
System.out.println(String.format("%.1f", num1));//四舍五入
System.out.println(String.format("%.1f", num2));

得到的结果居然是 3.4 和 3.3。

这就是由精度问题和舍入方式共同导致的,double 和 float 的 3.35 其实相当于 3.350xxx 和 3.349xxx:

3.350000000000000088817841970012523233890533447265625
3.349999904632568359375

String.format 采用四舍五入的方式进行舍入,取 1 位小数,double 的 3.350 四舍五入为 3.4,而 float 的 3.349 四舍五入为 3.3。

5.2 浮点数的字符串格式化也要通过 BigDecimal 进行。

BigDecimal num1 = new BigDecimal("3.35");
BigDecimal num2 = num1.setScale(1, BigDecimal.ROUND_DOWN);
System.out.println(num2);
BigDecimal num3 = num1.setScale(1, BigDecimal.ROUND_HALF_UP);
System.out.println(num3);

这次得到的结果是 3.3 和 3.4,符合预期。

5.3 用 equals 做判等,注意BigDecimal 的判等方式。

包装类的比较要通过 equals 进行,而不能使用 ==。那么,使用 equals 方法对两个 BigDecimal 判等,使用 equals 方法对两个 BigDecimal 判等,不一定能得到我们想要的结果。比如:

System.out.println(new BigDecimal("1.0").equals(new BigDecimal("1")))

结果当然是 false。==BigDecimal 的 equals 方法的注释中说明了原因,equals 比较的是 BigDecimal 的 value 和 scale,==1.0 的 scale 是 1,1 的 scale 是 0,所以结果一定是 false:

**如果我们希望只比较 BigDecimal 的 value,可以使用 compareTo 方法,**修改后代码如下:

System.out.println(new BigDecimal("1.0").compareTo(new BigDecimal("1"))==0);

BigDecimal 的 equals 和 hashCode 方法会同时考虑 value 和 scale,如果结合 HashSet 或 HashMap 使用的话就可能会出现麻烦。比如,我们把值为 1.0 的 BigDecimal 加入 HashSet,然后判断其是否存在值为 1 的 BigDecimal,得到的结果是 false:

Set<BigDecimal> hashSet1 = new HashSet<>();
hashSet1.add(new BigDecimal("1.0"));
System.out.println(hashSet1.contains(new BigDecimal("1")));//返回false

解决这个问题的办法有两个:

  • 第一个方法是,使用 TreeSet 替换 HashSet。TreeSet 不使用 hashCode 方法,也不使用 equals 比较元素,而是使用 compareTo 方法,所以不会有问题。
Set<BigDecimal> treeSet = new TreeSet<>();
treeSet.add(new BigDecimal("1.0"));
System.out.println(treeSet.contains(new BigDecimal("1")));//返回true
  • 第二个方法是,把 BigDecimal 存入 HashSet 或 HashMap 前,先使用 stripTrailingZeros 方法去掉尾部的零,比较的时候也去掉尾部的 0,确保 value 相同的 BigDecimal,scale 也是一致的:
Set<BigDecimal> hashSet2 = new HashSet<>();
hashSet2.add(new BigDecimal("1.0").stripTrailingZeros());
System.out.println(hashSet2.contains(new BigDecimal("1.000").stripTrailingZeros()));//返回true

5.4 小心数值溢出问题

数值计算还有一个要小心的点是溢出,不管是 int 还是 long,所有的基本数值类型都有超出表达范围的可能性。

比如,对 Long 的最大值进行 +1 操作:

long l = Long.MAX_VALUE;
System.out.println(l + 1);
System.out.println(l + 1 == Long.MIN_VALUE);

输出结果是一个负数,因为 Long 的最大值 +1 变为了 Long 的最小值:

-9223372036854775808
true

**显然这是发生了溢出,而且是默默地溢出,并没有任何异常。**这类问题非常容易被忽略,改进方式有下面 2 种。

方法一是,考虑使用 Math 类的 addExact、subtractExact 等 xxExact 方法进行数值运算,这些方法可以在数值溢出时主动抛出异常。我们来测试一下,使用 Math.addExact 对 Long 最大值做 +1 操作:

try {
long l = Long.MAX_VALUE;
System.out.println(Math.addExact(l, 1));
} catch (Exception ex) {
ex.printStackTrace();
}

执行后,可以得到 ArithmeticException,这是一个 RuntimeException:

java.lang.ArithmeticException: long overflow
at java.lang.Math.addExact(Math.java:809)
at org.geekbang.time.commonmistakes.numeralcalculations.demo3.CommonMistakesApplication.right2(CommonMistakesApplication.java:25)
at org.geekbang.time.commonmistakes.numeralcalculations.demo3.CommonMistakesApplication.main(CommonMistakesApplication.java:13)

方法二是,使用大数类 BigInteger。BigDecimal 是处理浮点数的专家,而 BigInteger 则是对大数进行科学计算的专家。

如下代码,使用 BigInteger 对 Long 最大值进行 +1 操作;如果希望把计算结果转换一个 Long 变量的话,可以使用 BigInteger 的 longValueExact 方法,在转换出现溢出时,同样会抛出 ArithmeticException:

BigInteger i = new BigInteger(String.valueOf(Long.MAX_VALUE));
System.out.println(i.add(BigInteger.ONE).toString());

try {
long l = i.add(BigInteger.ONE).longValueExact();
} catch (Exception ex) {
ex.printStackTrace();
}
9223372036854775808
java.lang.ArithmeticException: BigInteger out of long range
at java.math.BigInteger.longValueExact(BigInteger.java:4632)
at org.geekbang.time.commonmistakes.numeralcalculations.demo3.CommonMistakesApplication.right1(CommonMistakesApplication.java:37)
at org.geekbang.time.commonmistakes.numeralcalculations.demo3.CommonMistakesApplication.main(CommonMistakesApplication.java:11)

可以看到,通过 BigInteger 对 Long 的最大值加 1 一点问题都没有,当尝试把结果转换为 Long 类型时,则会提示 BigInteger out of long range。

5.5 BigDecimal提供了 8 种舍入模式

第一种,ROUND_UP,舍入远离零的舍入模式,在丢弃非零部分之前始终增加数字(始终对非零舍弃部分前面的数字加 1)。 需要注意的是,此舍入模式始终不会减少原始值。

第二种,ROUND_DOWN,接近零的舍入模式,在丢弃某部分之前始终不增加数字(从不对舍弃部分前面的数字加 1,即截断)。 需要注意的是,此舍入模式始终不会增加原始值。

第三种,ROUND_CEILING,接近正无穷大的舍入模式。 如果 BigDecimal 为正,则舍入行为与 ROUND_UP 相同; 如果为负,则舍入行为与 ROUND_DOWN 相同。 需要注意的是,此舍入模式始终不会减少原始值。

第四种,ROUND_FLOOR,接近负无穷大的舍入模式。 如果 BigDecimal 为正,则舍入行为与 ROUND_DOWN 相同; 如果为负,则舍入行为与 ROUND_UP 相同。 需要注意的是,此舍入模式始终不会增加原始值。

第五种,ROUND_HALF_UP,向“最接近的”数字舍入。如果舍弃部分 >= 0.5,则舍入行为与 ROUND_UP 相同;否则,舍入行为与 ROUND_DOWN 相同。 需要注意的是,这是我们大多数人在小学时就学过的舍入模式(四舍五入)。

第六种,ROUND_HALF_DOWN,向“最接近的”数字舍入。如果舍弃部分 > 0.5,则舍入行为与 ROUND_UP 相同;否则,舍入行为与 ROUND_DOWN 相同(五舍六入)。

第七种,ROUND_HALF_EVEN,向“最接近的”数字舍入。这种算法叫做银行家算法,具体规则是,四舍六入,五则看前一位,如果是偶数舍入,如果是奇数进位,比如 5.5 -> 6,2.5 -> 2。

第八种,ROUND_UNNECESSARY,假设请求的操作具有精确的结果,也就是不需要进行舍入。如果计算结果产生不精确的结果,则抛出 ArithmeticException。

6 空指针异常

针对判空,通过 Optional 配合 Stream 可以避免大多数冗长的 if-else 判空逻辑,实现一行代码优雅判空。另外,要定位和修复空指针异常,除了可以通过增加日志进行排查外,在生产上使用 Arthas 来查看方法的调用栈和入参会更快捷。

业务系统最基本的标准是不能出现未处理的空指针异常,因为它往往代表了业务逻辑的中断,所以我建议每天查询一次生产日志来排查空指针异常,有条件的话建议订阅空指针异常报警,以便及时发现及时处理。

POJO 中字段的 null 定位,从服务端的角度往往很难分清楚,到底是客户端希望忽略这个字段还是有意传了 null,因此我们尝试用 Optional类来区分 null 的定位。同时,为避免把空值更新到数据库中,可以实现动态 SQL,只更新必要的字段。

数据库字段使用 NULL 可能会带来的三个坑(包括 sum 函数、count 函数,以及 NULL 值条件),以及解决方式。

  • MySQL 中 sum 函数没统计到任何记录时,会返回 null 而不是 0,可以使用 IFNULL 函数把 null 转换为 0;
  • MySQL 中 count 字段不统计 null 值,COUNT(*) 才是统计所有记录数量的正确方式。
  • MySQL 中使用诸如 =,<, > 这样的算数比较操作符比较 NULL 的结果总是 NULL,这种比较就显得没有任何意义,需要使用 IS NULL,IS NOT NULL 或 ISNULL() 函数来比较。

总结来讲,null 的正确处理以及避免空指针异常,绝不是判空这么简单,还要根据业务属性从前到后仔细考虑,客户端传入的 null 代表了什么,出现了 null 是否允许使用默认值替代,入库的时候应该传入 null 还是空值,并确保整个逻辑处理的一致性,才能尽量避免 Bug。

7 异常处理问题

首先,不应该用 AOP 对所有方法进行统一异常处理,异常要么不捕获不处理,要么根据不同的业务逻辑、不同的异常类型进行精细化、针对性处理;其次,处理异常应该杜绝生吞,并确保异常栈信息得到保留;最后,如果需要重新抛出异常的话,请使用具有意义的异常类型和异常消息。

第二,务必小心 finally 代码块中资源回收逻辑,确保 finally 代码块不出现异常,内部把异常处理完毕,避免 finally 中的异常覆盖 try 中的异常;或者考虑使用 addSuppressed 方法把 finally 中的异常附加到 try 中的异常上,确保主异常信息不丢失。此外,使用实现了 AutoCloseable 接口的资源,务必使用 try-with-resources 模式来使用资源,确保资源可以正确释放,也同时确保异常可以正确处理。

第三,虽然在统一的地方定义收口所有的业务异常是一个不错的实践,但务必确保异常是每次 new 出来的,而不能使用一个预先定义的 static 字段存放异常,否则可能会引起栈信息的错乱。

第四,确保正确处理了线程池中任务的异常,如果任务通过 execute 提交,那么出现异常会导致线程退出,大量的异常会导致线程重复创建引起性能问题,我们应该尽可能确保任务不出异常,同时设置默认的未捕获异常处理程序来兜底;如果任务通过 submit 提交意味着我们关心任务的执行结果,应该通过拿到的 Future 调用其 get 方法来获得任务运行结果和可能出现的异常,否则异常可能就被生吞了。

7.1 关于在 finally 代码块中抛出异常的坑,如果在 finally 代码块中返回值,你觉得程序会以 try 或 catch 中的返回值为准,还是以 finally 中的返回值为准呢?

以 finally 中的返回值为准。

从语义上来说,finally 是做方法收尾资源释放处理的,我们不建议在 finally 中有 return,这样逻辑会很混乱。这是因为,实现上 finally 中的代码块会被复制多份,分别放到 try 和 catch 调用 return 和 throw 异常之前,所以 finally 中如果有返回值,会覆盖 try 中的返回值。

8 时间相关问题

对于国际化(世界各国的人都在使用)的项目,处理好时间和时区问题首先就是要正确保存日期时间。这里有两种保存方式:

  • 方式一,以 UTC 保存,保存的时间没有时区属性,是不涉及时区时间差问题的世界统一时间。我们通常说的时间戳,或 Java 中的 Date 类就是用的这种方式,这也是推荐的方式。
  • 方式二,以字面量保存,比如年 / 月 / 日 时: 分: 秒,一定要同时保存时区信息。只有有了时区信息,我们才能知道这个字面量时间真正的时间点,否则它只是一个给人看的时间表示,只在当前时区有意义。Calendar 是有时区概念的,所以我们通过不同的时区初始化 Calendar,得到了不同的时间。

在使用时间格式时,推荐使用 Java8 提供的新的时间格式。

这里有个误区是,认为 java.util.Date 类似于新 API 中的 LocalDateTime。其实不是,虽然它们都没有时区概念,但 java.util.Date 类是因为使用 UTC 表示,所以没有时区概念,其本质是时间戳;而 LocalDateTime,严格上可以认为是一个日期时间的表示,而不是一个时间点。

因此,在把 Date 转换为 LocalDateTime 的时候,需要通过 Date 的 toInstant 方法得到一个 UTC 时间戳进行转换,并需要提供当前的时区,这样才能把 UTC 时间转换为本地日期时间(的表示)。反过来,把 LocalDateTime 的时间表示转换为 Date 时,也需要提供时区,用于指定是哪个时区的时间表示,也就是先通过 atZone 方法把 LocalDateTime 转换为 ZonedDateTime,然后才能获得 UTC 时间戳:

Date in = new Date();
LocalDateTime ldt = LocalDateTime.ofInstant(in.toInstant(), ZoneId.systemDefault());
Date out = Date.from(ldt.atZone(ZoneId.systemDefault()).toInstant());

9 使用 final 修饰使用到的变量和不使用 final 修饰使用到的变量有些什么样的优缺点

在 Java 编程中,使用 final 关键字修饰变量可以提供以下优点:

  1. 增强代码可读性:使用 final 表示一个变量在定义之后不会被重新赋值,这可以更清晰地表达变量的意图。这有助于增强代码的可读性和可维护性。
  2. 改进程序性能:由于 JVM 能够优化对不可变(即 final)变量的访问,因此使用 final 可以改善程序的性能。
  3. 追求更好的安全性:使用 final 可以防止在代码执行期间对变量进行更改。这可能会增强系统的安全性,防止出现潜在的问题。

而对于没有使用 final 修饰的变量,主要缺点如下:

  1. 可能影响代码可读性:如果变量被多次重新赋值,则代码的含义可能变得复杂和难以理解,这可能会降低代码的可读性。
  2. 可能引发并发问题:在多线程环境中,未经过同步处理的非 final 变量可能会引发并发问题,例如竞态条件或数据竞争等问题。
  3. 可能导致性能问题:由于 JVM 不能对未经过 final 修饰的变量进行优化,因此可能会对程序的性能产生负面影响。

需要注意的是,是否使用 final 取决于变量的用途和上下文。在某些情况下,如果一个变量只是临时使用,或者它的值需要反复修改,则可能没有必要使用 final 修饰。但是,在某些情况下,使用 final 可以提供明显的优势,并且被认为是一种最佳实践。

补充,针对 JVM 是如何优化对不可变(即 final)变量的访问问题:

JVM 在访问 final 变量时,可以采取一些优化措施来提升程序的性能。下面是一些 JVM 优化 final 变量访问的方法:

  1. 内联操作:JVM 可以在编译时对代码进行内联操作,即将方法调用替换为实际的代码。如果变量被定义为 final,则 JVM 可以确定该变量不会在运行期间改变,因此可以更安全地进行内联操作。
  2. 常量折叠:JVM 可以对编译期常量进行折叠操作,即将相同的常量合并为一个常量。对于 final 变量,JVM 可以在编译时将其解析为常量,并在需要时直接读取值,而无需每次访问变量时都进行解析。
  3. 偏向锁优化:JVM 中的偏向锁机制可以优化 final 变量的访问。当只有一个线程访问一个对象时,JVM 可以将该对象加入到偏向锁状态中,从而避免了在获取锁时的额外开销。由于 final 变量在创建之后不会被修改,因此可以使用偏向锁进行优化。

总的来说,使用 final 修饰变量可以使 JVM 更容易对程序进行优化,提高程序的性能和可靠性。但需要注意的是,这些优化可能并不总是适用于所有情况,具体取决于程序的实际需求和上下文。

💡本文声明

转载请注明出处,谢谢合作!转载本文请声明原文章链接如下:

原文链接: https://zhoujun134.github.io/docs/java/01-Javaji-chu-zhi-shi

作者: Z 不殊

Z 不殊 致力于分享有价值的信息和知识。我们尊重并保护知识产权。本文仅代表作者观点,不代表任何立场。 如果本文有所侵权,请联系作者删除或修改!

Loading Comments...