大家好,我是 V 哥。今天看了阿里云开发者社区关于 Java 的灵魂拷问,一线大厂在用 Java 时,都会考虑哪些问题呢,对于工作多年,又没有大厂经历的小伙伴不妨看看,V 哥总结的这13个为什么,你都会哪些?先赞后看,绝不摆烂。 1. 为什么禁止使用 BigDecimal 的 equals 方法做等
大家好,我是 V 哥。今天看了阿里云开发者社区关于 Java 的灵魂拷问,一线大厂在用 Java 时,都会考虑哪些问题呢,对于工作多年,又没有大厂经历的小伙伴不妨看看,V 哥总结的这13个为什么,你都会哪些?先赞后看,绝不摆烂。
BigDecimal
的
equals
方法在等值比较时存在一些问题,通常不建议直接使用它来判断数值的相等性。下面是主要原因以及推荐的替代方案:
equals
方法比较严格,包含了精度和符号的比较
BigDecimal.equals
不仅比较数值本身,还会比较精度和符号。例如,
BigDecimal
的
equals
方法会认为
1.0
和
1.00
是不同的值,因为它们的
scale
不同(即小数位数不同)。例如:
BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("1.00");
System.out.println(a.equals(b)); // 输出 false
尽管
1.0
和
1.00
数值上是相等的,但
equals
方法会因为精度不同返回
false
。
equals
方法会区分正负零
在
BigDecimal
中,正零 (
0.0
) 和负零 (
-0.0
) 是不相等的,而使用
equals
会导致
0.0
和
-0.0
被视为不相等。例如:
BigDecimal zero1 = new BigDecimal("0.0");
BigDecimal zero2 = new BigDecimal("-0.0");
System.out.println(zero1.equals(zero2)); // 输出 false
这可能会导致误判,因为在大多数业务逻辑中,我们认为
0.0
和
-0.0
是等值的。
compareTo
方法
为了避免这些问题,建议使用
BigDecimal.compareTo
方法。
compareTo
方法仅比较数值的大小,不关注精度和符号。因此,在需要判断两个
BigDecimal
是否等值时,使用
compareTo
更为合理:
BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("1.00");
System.out.println(a.compareTo(b) == 0); // 输出 true
在这种情况下,
1.0
和
1.00
被视为相等,即使它们的精度不同,
compareTo
也会返回
0
。
equals
方法
:它会考虑精度和符号,容易导致误判。
compareTo
方法
:只比较数值,忽略精度和正负零的差异,可以实现更符合业务需求的等值比较。
在使用
BigDecimal
时,不建议直接使用
double
作为构造参数。这是因为
double
类型在 Java 中的表示是基于二进制浮点数的,会引入精度误差,从而导致不准确的结果。例如:
double d = 0.1;
BigDecimal bd = new BigDecimal(d);
System.out.println(bd); // 输出 0.1000000000000000055511151231257827021181583404541015625
二进制浮点数的精度问题
double
使用 IEEE 754 标准表示小数,在二进制系统中,像
0.1
这样的小数无法精确表示,导致它在存储时会变成一个近似值。这个近似值会直接传递给
BigDecimal
的构造方法,从而生成带有误差的
BigDecimal
值。
结果不准确,影响业务计算
在一些金融计算或其他对精度要求高的场景中,直接使用
double
构造
BigDecimal
会带来潜在的误差积累,从而影响最终的结果。例如,在多次计算或累加时,误差可能不断放大。
BigDecimal
BigDecimal bd = new BigDecimal("0.1");
System.out.println(bd); // 输出 0.1
BigDecimal.valueOf(double)
方法
BigDecimal.valueOf(double)
,该方法会将
double
转换为
String
表示,然后构造
BigDecimal
,从而避免精度损失。
BigDecimal bd = BigDecimal.valueOf(0.1);
System.out.println(bd); // 输出 0.1
double
构造
BigDecimal
,以免引入二进制浮点数的精度误差。
BigDecimal.valueOf(double)
以确保精度。
Apache BeanUtils
是一个早期用于 Java Bean 属性复制的工具库,但在现代 Java 开发中通常不推荐使用它来进行属性的拷贝,尤其在性能敏感的场景中。原因主要包括以下几点:
Apache BeanUtils.copyProperties()
使用了大量的反射操作,且每次拷贝都需要对字段、方法进行查找和反射调用。反射机制虽然灵活,但性能较低,尤其是在大量对象或频繁拷贝的场景中,会产生显著的性能瓶颈。
相比之下,
Spring BeanUtils
或
Apache Commons Lang
的
FieldUtils
等工具经过优化,使用了更高效的方式进行属性复制。在性能要求较高的场合,
MapStruct
或
Dozer
等编译期代码生成的方式则可以完全避免运行时反射。
BeanUtils.copyProperties
在属性类型不匹配时会隐式地进行类型转换。例如,将
String
类型的
"123"
转换为
Integer
,如果转换失败,会抛出异常。这种隐式转换在处理数据时,可能带来不易察觉的错误,而且并不总是适合应用场景。
在精确的属性复制需求下,通常希望类型不匹配时直接跳过拷贝,或明确抛出错误,而不是隐式转换。例如,
Spring BeanUtils.copyProperties
不会进行隐式转换,适合严格的属性匹配场景。
Apache BeanUtils
的
PropertyUtils
组件在执行反射操作时存在一定的安全隐患。历史上,
BeanUtils
的
PropertyUtils
曾有安全漏洞,使恶意用户可以通过精心构造的输入利用反射机制执行系统命令或加载恶意类。尽管这些漏洞在现代版本中已得到修复,但该库的架构和实现仍较为陈旧,难以应对更高的安全需求。
BeanUtils.copyProperties
仅支持浅拷贝,即只能复制对象的一级属性,无法递归地对嵌套对象进行复制。如果对象包含了复杂的嵌套结构,使用
BeanUtils.copyProperties
很容易出现意外行为或数据丢失。像
MapStruct
或
Dozer
这样的工具则提供对嵌套对象的深层复制能力,更适合复杂对象的深度拷贝需求。
Spring
BeanUtils.copyProperties()
Spring 的
BeanUtils.copyProperties()
提供了更优的性能和更好的类型安全性。它不做类型转换,且提供了方便的过滤器用于选择性拷贝属性。
MapStruct
MapStruct
是基于注解的对象映射框架,支持编译期生成代码,完全避免了反射的性能开销,且支持复杂对象、嵌套属性的深度拷贝,是性能要求较高的首选。
Dozer
Dozer
支持更灵活的映射配置和深拷贝,适合对象结构复杂的情况。它可以处理嵌套属性映射、类型转换,且具有较好的自定义能力。
Apache BeanUtils.copyProperties
不适合现代 Java 开发的性能、安全性和灵活性要求,推荐使用更高效、安全、灵活的框架(如 Spring
BeanUtils
、
MapStruct
等)来代替。
在日期格式化中,必须使用
y
而不是
Y
来表示年份,这是因为
y
和
Y
在 Java 和其他日期格式化工具中代表不同的含义:
y
表示日历年(Calendar Year)
y
是标准的表示年份的字符,表示的是通常意义上的公历年,比如
2024
表示的就是这一年的年份。使用
y
时,日期格式化工具会准确地格式化出对应的年份数值:
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
System.out.println(sdf.format(new Date())); // 输出: 2024-11-10
Y
表示星期年(Week Year)
Y
表示的是“星期年”或称“ISO周年”(ISO week-numbering year),它是一种基于ISO周数的年份表示方式。这种表示法根据每年的第一个星期一所在的周来计算年份,如果某天属于新一年的第一个完整星期,则会归为新年的星期年。
例如,如果某年的最后几天在下一年开始的第一个星期中,它们可能会被归入下一年的
week year
。同理,如果新年的前几天在上一年的最后一个完整星期内,这些天的星期年可能会归属上一年。这在日期和时间处理中可能导致意外的年份差异。
SimpleDateFormat sdf = new SimpleDateFormat("YYYY-MM-dd");
System.out.println(sdf.format(new Date())); // 可能输出与实际年份不同的值
Y
的潜在问题
使用
Y
表示年份会引发一些日期计算的错误,因为它依赖于周数的计算方式,不是每次都与实际的公历年份一致。例如:
2025
的
week year
,导致使用
YYYY
格式化时得到
2025-12-31
。
Y
表示年份可能会出现错误,因为
week year
与通常理解的日历年并不总是相符。
Y
Y
一般仅用于需要符合 ISO 8601 标准的日期格式,特别是包含 ISO 周数(如“2024-W01-1”表示2024年的第一个星期一)的情况,而在一般情况下,我们都应使用
y
来表示日历年份。
y
来表示常规年份
,避免日期格式化错误。
Y
来表示年份
,除非确实需要按照 ISO 周年的格式来解析和显示年份。
在使用三目运算符时,类型对齐非常重要,因为三目运算符的两个分支会被类型推断成一个共同的类型。若两者类型不同,Java 编译器会进行类型提升或自动转换,这可能导致意外的类型变化和潜在的错误。以下是需要注意的原因和细节:
三目运算符的返回值类型是根据
true
和
false
分支的类型推断出来的。为了得到一致的结果,Java 会自动将不同的类型提升为更高精度的类型。例如,若一个分支返回
int
而另一个分支返回
double
,Java 会将
int
提升为
double
:
int x = 5;
double y = 10.5;
double result = (x > 0) ? x : y; // 返回 double 类型
System.out.println(result); // 输出 5.0
这里返回值
5
被提升为
5.0
。虽然代码在这个例子中不会出错,但在某些情况下,这种自动提升会导致意外的精度损失或类型不匹配的问题。
NullPointerException
在 Java 中,基本类型和包装类型的对齐需要特别小心。三目运算符会尝试将包装类型和基本类型对齐成相同类型,这会导致自动装箱和拆箱,如果某个分支为
null
且需要拆箱,可能会引发
NullPointerException
:
Integer a = null;
int b = 10;
int result = (a != null) ? a : b; // 如果 a 为 null,结果会发生自动拆箱,引发 NullPointerException
由于
a
为
null
,Java 会尝试将其拆箱为
int
,从而抛出
NullPointerException
。为避免这种情况,可以确保类型对齐,或避免对可能为
null
的对象进行拆箱。
如果三目运算符的两种返回类型无法被编译器自动转换为一个兼容类型,代码会直接报错。例如:
int x = 5;
String y = "10";
Object result = (x > 0) ? x : y; // 编译错误:int 和 String 不兼容
在这种情况下,
int
和
String
无法被提升到相同类型,因此会引发编译错误。若确实希望返回不同类型的值,可以手动指定共同的超类型,例如将结果定义为
Object
类型:
Object result = (x > 0) ? Integer.valueOf(x) : y; // 这里 result 为 Object
保持三目运算符返回的类型一致,能让代码更加清晰,便于理解和维护。类型对齐可以避免类型转换和自动提升带来的混乱,使代码更容易预测和理解:
double result = (condition) ? 1.0 : 0.0; // 返回 double
true
和
false
分支的类型相同,避免意外的类型提升。
null
参与三目运算符计算。
Object
或显式转换。
初始化
HashMap
的容量大小是为了提高性能和减少内存浪费。通过设置合适的初始容量,可以减少
HashMap
的扩容次数,提高程序运行效率。以下是详细原因和建议:
HashMap
默认的初始容量为 16,当超过负载因子阈值(默认是 0.75,即达到容量的 75%)时,
HashMap
会自动进行扩容操作,将容量扩大为原来的两倍。扩容涉及到重新计算哈希并将数据重新分布到新的桶中,这个过程非常耗时,尤其在元素较多时,扩容会显著影响性能。
通过设置合适的初始容量,可以避免或减少扩容操作,提高
HashMap
的存取效率。
如果预计要存储大量数据但没有指定容量,
HashMap
可能会多次扩容,每次扩容会分配新的内存空间,并将原有数据复制到新空间中,造成内存浪费。如果在创建
HashMap
时能合理估算其容量,则可以一次性分配足够的空间,从而避免重复分配内存带来的资源浪费。
在并发环境下,频繁扩容可能导致线程不安全,即使是
ConcurrentHashMap
也不能完全避免扩容带来的性能和一致性问题。初始化合适的容量可以减少并发环境下扩容带来的风险。
HashMap
将存储
n
个元素,可以将初始容量设置为
(n / 0.75)
,再向上取整为最接近的 2 的幂次方。
int initialCapacity = (int) Math.ceil(n / 0.75);
Map map = new HashMap<>(initialCapacity);
HashMap
的容量总是以 2 的幂次方增长,因为在进行哈希运算时,可以高效利用按位与操作来计算哈希桶索引。因此,初始容量设为 2 的幂次方会使哈希分布更均匀。
int expectedSize = 1000; // 预估需要存储的键值对数量
int initialCapacity = (int) Math.ceil(expectedSize / 0.75);
HashMap map = new HashMap<>(initialCapacity);
初始化
HashMap
的容量大小有以下好处:
合理初始化
HashMap
容量对于高性能应用尤为重要,尤其在存储大量数据时可以显著提升程序的运行效率。
在 Java 中创建线程池时,不推荐直接使用
Executors
提供的快捷方法(例如
Executors.newFixedThreadPool()
、
Executors.newCachedThreadPool()
等),而推荐使用
ThreadPoolExecutor
构造方法来手动配置线程池。这种做法主要是为了避免
Executors
创建线程池时隐藏的风险,确保线程池配置符合需求。具体原因如下:
newFixedThreadPool()
和
newSingleThreadExecutor()
使用的是
无界队列
LinkedBlockingQueue
。无界队列可以存放无限数量的任务,一旦任务量非常大,队列会迅速占用大量内存,导致
OutOfMemoryError
(OOM)。
newCachedThreadPool()
使用的是
SynchronousQueue
,该队列没有存储任务的能力,每个任务到来时必须立即有一个空闲线程来处理任务,否则将创建一个新线程。当任务到达速度超过线程销毁速度时,线程数量会快速增加,导致
OOM
。
在
newCachedThreadPool()
创建的线程池中,线程数没有上限,短时间内大量请求会导致线程数暴增,耗尽系统资源。
newFixedThreadPool()
和
newSingleThreadExecutor()
虽然限制了核心线程数,但未限制任务队列长度,依然可能耗尽内存。
在业务需求不确定或任务激增的场景下,建议明确限制线程池的最大线程数和队列长度,以更好地控制系统资源的使用,避免因线程数无法控制导致的性能问题。
Executors
创建的线程池默认使用
AbortPolicy
拒绝策略,即当线程池达到饱和时会抛出
RejectedExecutionException
异常。
CallerRunsPolicy
(让提交任务的线程执行任务)或
DiscardOldestPolicy
(丢弃最旧的任务)来平衡任务处理。
手动创建
ThreadPoolExecutor
时,可以指定适合业务需求的拒绝策略,从而更灵活地处理线程池满载的情况,避免异常或系统性能下降。
使用
ThreadPoolExecutor
的构造方法可以手动设置以下参数,以便根据业务需求灵活配置线程池:
这些参数的合理配置可以有效平衡线程池的性能、资源占用和任务处理能力,避免使用默认配置时不符合需求的情况。
建议直接使用
ThreadPoolExecutor
构造方法配置线程池,例如:
int corePoolSize = 10;
int maximumPoolSize = 20;
long keepAliveTime = 60L;
BlockingQueue workQueue = new ArrayBlockingQueue<>(100);
ThreadPoolExecutor executor = new ThreadPoolExecutor(
corePoolSize,
maximumPoolSize,
keepAliveTime,
TimeUnit.SECONDS,
workQueue,
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略
);
使用
Executors
创建线程池会带来不易察觉的风险,可能导致系统资源耗尽或任务堆积,手动配置
ThreadPoolExecutor
可以更好地控制线程池的行为,使其符合实际业务需求和资源限制。因此,为了系统的健壮性和可控性,建议避免使用
Executors
快捷方法来创建线程池。
在使用
ArrayList
的
subList
方法时需要谨慎,因为它有一些潜在的陷阱,容易导致意外的错误和难以排查的异常。以下是
subList
需要小心使用的原因和注意事项:
subList
返回的是视图,而不是独立副本
ArrayList
的
subList
方法返回的是原列表的一部分视图(
view
),而不是一个独立的副本。对
subList
的修改会直接影响原列表,反之亦然:
ArrayList list = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5));
List subList = list.subList(1, 4);
subList.set(0, 10); // 修改 subList
System.out.println(list); // 原列表也受到影响:[1, 10, 3, 4, 5]
这种共享视图的机制在某些场景中可能引发意外的修改,导致数据被意外改变,从而影响到原始数据结构的完整性和正确性。
subList
的结构性修改限制
当对
ArrayList
本身(而非
subList
视图)进行结构性修改(
add
、
remove
等改变列表大小的操作)后,再操作
subList
会导致
ConcurrentModificationException
异常。这是因为
subList
和原
ArrayList
之间共享结构性修改的状态,一旦其中一个发生修改,另一方就会失效:
ArrayList list = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5));
List subList = list.subList(1, 4);
list.add(6); // 修改原列表的结构
subList.get(0); // 抛出 ConcurrentModificationException
这种限制意味着
subList
不适合在列表频繁变化的场景中使用,否则很容易引发并发修改异常。
subList
和
ArrayList
的 removeAll 等操作可能导致错误
subList
生成的视图列表可能会在批量删除操作中出现问题,例如调用
removeAll
方法时,
subList
的行为不一致或发生异常。对于
ArrayList
的
subList
,一些批量修改方法(如
removeAll
、
retainAll
)可能会在删除视图元素后,导致
ArrayList
产生不可预料的状态,甚至引发
IndexOutOfBoundsException
等异常。
如果需要一个独立的子列表,可以通过
new ArrayList<>(originalList.subList(start, end))
来创建一个子列表的副本,从而避免
subList
的共享视图问题:
ArrayList list = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5));
ArrayList subListCopy = new ArrayList<>(list.subList(1, 4)); // 创建副本
list.add(6); // 修改原列表
subListCopy.get(0); // 安全,不会受到影响
使用
ArrayList
的
subList
方法需要注意以下几点:
subList
只是原列表的视图,修改其中一个会影响另一个。
subList
会抛出
ConcurrentModificationException
。
subList
的批量操作可能引发不可预料的错误。
subList
的副本以避免潜在问题。
谨慎使用
subList
可以避免意外的错误,提高代码的健壮性。
在 Java 中,禁止在
foreach
循环中进行元素的
remove
或
add
操作,主要是因为这种操作可能导致
ConcurrentModificationException
异常,或者导致循环行为不符合预期。具体原因如下:
ConcurrentModificationException
异常
当你在
foreach
循环中直接修改集合(例如
remove
或
add
元素),会导致并发修改问题。
foreach
循环底层使用了集合的
Iterator
来遍历元素。大多数集合类(如
ArrayList
、
HashSet
等)都会维护一个
modCount
计数器,表示集合的结构变更次数。当你在遍历时修改集合的结构(如删除或添加元素),
modCount
会发生变化,而
Iterator
会检测到这种结构性修改,从而抛出
ConcurrentModificationException
异常,防止程序在多线程环境中出现意外行为。
例如:
List list = new ArrayList<>(Arrays.asList("a", "b", "c", "d"));
for (String s : list) {
if (s.equals("b")) {
list.remove(s); // 会抛出 ConcurrentModificationException
}
}
在上面的代码中,
foreach
循环遍历
list
时,如果删除了元素
b
,它会修改
list
的结构,从而导致
Iterator
检测到并发修改,抛出异常。
即使没有抛出
ConcurrentModificationException
,在
foreach
循环中修改集合也会导致不可预测的行为。例如,
remove
或
add
操作会改变集合的大小和内容,可能会影响迭代的顺序或导致遗漏某些元素,甚至造成死循环或跳过某些元素。
例如:
List list = new ArrayList<>(Arrays.asList("a", "b", "c", "d"));
for (String s : list) {
if (s.equals("b")) {
list.add("e"); // 修改集合的大小
}
System.out.println(s);
}
在这个例子中,
add
操作会向
list
中添加一个新元素
"e"
,从而修改了集合的结构。因为
foreach
循环的内部实现使用了迭代器,它可能不会考虑到修改后的新元素,导致输出顺序或遍历结果与预期不同。
remove()
方法
如果需要在循环中删除元素,推荐使用
Iterator
显式地进行删除操作。
Iterator
提供了一个安全的
remove()
方法,可以在遍历时安全地删除元素,而不会引发
ConcurrentModificationException
。
例如:
List list = new ArrayList<>(Arrays.asList("a", "b", "c", "d"));
Iterator iterator = list.iterator();
while (iterator.hasNext()) {
String s = iterator.next();
if (s.equals("b")) {
iterator.remove(); // 使用 Iterator 的 remove() 方法
}
}
使用
Iterator.remove()
可以安全地在遍历时删除元素,而不会抛出并发修改异常。
在
foreach
循环中直接进行
remove
或
add
操作是不安全的,主要有以下原因:
ConcurrentModificationException
:直接修改集合会触发迭代器的并发修改检测,导致异常。
Iterator
替代
:使用
Iterator
的
remove()
方法可以避免这些问题,实现安全的元素删除操作。
因此,正确的做法是使用
Iterator
显式地处理元素的删除或修改,而不是直接在
foreach
循环中进行修改。
在很多工程实践中, 禁止工程师直接使用日志系统(如 Log4j、Logback)中的 API ,主要是出于以下几个原因:
直接使用日志系统的 API 可能会导致日志记录逻辑与应用的业务逻辑紧密耦合,使得日志配置和实现的分离变得困难。现代的日志框架(如 Log4j、Logback)允许通过外部配置文件(如
log4j.xml
或
logback.xml
)灵活配置日志级别、输出格式、输出位置等,而不是硬编码到应用代码中。直接使用日志 API 会导致日志的配置与业务代码绑定在一起,不易修改和维护。
建议的做法
:通过使用日志框架的日志抽象接口(如
org.slf4j.Logger
)来记录日志,而不是直接依赖具体的日志实现。这种方式提供了更大的灵活性,日志实现可以在运行时通过配置文件更换而无需修改代码。
如果工程师直接使用日志库的 API,项目在需要切换日志框架(比如从 Log4j 转换到 Logback 或其他框架)时,需要修改大量的代码,增加了系统的耦合度和维护难度。另一方面,使用日志抽象层(如 SLF4J)可以避免这一问题,因为 SLF4J 是一个日志抽象层,底层可以切换具体的日志实现而无需改变业务代码。
示例 :
// 不推荐:直接使用 Log4j 的 API
import org.apache.log4j.Logger;
Logger logger = Logger.getLogger(MyClass.class);
logger.info("This is a log message");
// 推荐:通过 SLF4J 接口来记录日志
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Logger logger = LoggerFactory.getLogger(MyClass.class);
logger.info("This is a log message");
使用 SLF4J 可以在不同的环境中灵活切换日志实现,而无需修改代码。
如果工程师直接使用日志框架的 API,可能会在日志记录时不遵循一致的日志策略。例如,日志的级别、格式、日志输出的内容等可能不统一,导致日志信息混乱、不易追踪。通过统一的日志抽象接口(如 SLF4J)和规范的日志记录策略(通过 AOP 或日志框架自带的特性)可以保持日志的一致性和规范性。
最佳实践 :
DEBUG
、
INFO
、
WARN
、
ERROR
)和标准格式。
日志记录可能对应用的性能产生一定的影响,尤其是在日志记录过于频繁或日志输出内容过多的情况下。通过直接使用日志框架的 API,可能无法灵活控制日志输出的频率、内容或过滤策略,从而造成性能问题。很多日志框架(如 Log4j 和 Logback)提供了高级的配置选项,如异步日志、日志缓存等特性,可以显著提高性能。
推荐做法 :
在团队开发中,直接使用日志框架的 API 会导致不同开发人员在不同模块中记录日志时不遵循统一规范,导致日志格式不统一、信息不一致,甚至产生重复的日志记录。通过日志管理工具类或封装类,可以确保所有开发人员遵循统一的日志记录策略。
示例 :
LoggerFactory
工厂类来生成日志记录对象。
禁止工程师直接使用日志系统(如 Log4j、Logback)中的 API,主要是为了:
最好的做法是通过日志抽象层(如 SLF4J)进行日志记录,同时通过日志管理工具类进行统一的配置和调用,确保日志的高效、规范和灵活性。
在面向对象编程(OOP)中, 继承 是一种常见的代码复用方式,它允许一个类继承另一个类的属性和行为。然而,虽然继承可以提高代码的复用性,但过度或不当使用继承可能会导致代码的复杂性增加,进而带来一些潜在的问题。因此,建议开发者在使用继承时要谨慎,以下是一些关键原因:
继承会导致子类和父类之间形成紧密的耦合关系。子类依赖于父类的实现,这意味着如果父类发生变化,可能会影响到所有继承自该父类的子类,导致修改和维护变得更加困难。这种紧密耦合关系也限制了子类的灵活性,因为它必须遵循父类的接口和实现。
例子 :
class Animal {
void eat() {
System.out.println("Animal is eating");
}
}
class Dog extends Animal {
@Override
void eat() {
System.out.println("Dog is eating");
}
}
如果父类
Animal
做了改动(如修改
eat()
方法的实现),
Dog
类也会受到影响。这样的耦合会增加后期维护的复杂度。
继承可能破坏封装性,因为子类可以直接访问父类的成员(字段和方法),尤其是当父类成员被设置为
protected
或
public
时。这种情况可能导致子类暴露不应被外界访问的细节,破坏了数据的封装性。
例子 :
class Vehicle {
protected int speed;
}
class Car extends Vehicle {
void accelerate() {
speed += 10; // 直接访问父类的 protected 字段
}
}
在这种情况下,
Car
类直接访问了父类
Vehicle
的
speed
字段,而不是通过公共接口来修改它,导致封装性降低。
继承往往会导致不合理的类层次结构,特别是在试图通过继承来表达“是一个”(
is-a
)关系时,实际情况可能并不符合这种逻辑。滥用继承可能会使类之间的关系变得复杂和不直观,导致代码结构混乱。
例子
:
假设我们有一个
Car
类和一个
Truck
类,都继承自
Vehicle
类。如果
Car
和
Truck
共享很多方法和属性,这样的设计可能是合适的。但是,如果
Car
和
Truck
之间差异很大,仅通过继承来构建它们的关系,可能会导致继承层次过于复杂,代码阅读和理解变得困难。
由于子类继承了父类的行为,任何对父类的修改都有可能影响到子类的行为。更糟糕的是,错误或不一致的修改可能在父类中发生,而这些错误可能不会立即暴露出来,直到程序运行到某个特定的地方,才会显现出错误。
例子
:
假设你修改了父类的某个方法,但忘记更新或调整子类中相应的重写方法,这可能会导致难以发现的错误。
继承创建了一个父类与子类之间的固定关系,这意味着如果你想在一个完全不同的上下文中重用一个类,你可能不能通过继承来实现。在某些情况下,组合比继承更为灵活,允许你将多个行为组合到一个类中,而不是通过继承来强行构建类的层次结构。
例子 :
// 组合而非继承
class Engine {
void start() {
System.out.println("Engine started");
}
}
class Car {
private Engine engine = new Engine(); // 通过组合来使用 Engine
void start() {
engine.start();
}
}
通过组合,可以灵活地使用不同的组件,而不需要继承整个类。这样做的优点是更具扩展性和灵活性。
如果你过度依赖继承,你的代码会容易受到父类实现的限制,难以灵活地添加新功能或进行扩展。例如,在继承链中添加新的功能可能会导致一大堆方法的修改和重写,而不通过继承,可以更轻松地将功能作为独立模块来重用。
相比继承, 接口(Interface) 和 组合(Composition) 更符合面向对象设计的原则。接口允许类只暴露所需的功能,而不暴露实现细节,组合则允许你将多个不同的行为组合在一起,使得系统更加灵活和可扩展。通过接口和组合,可以避免继承的许多问题。
推荐设计模式 :
尽管继承是面向对象编程中的一个重要特性,但滥用继承可能带来许多问题,特别是在以下几个方面:
因此, 推荐优先使用组合而非继承 ,并尽可能使用接口来实现灵活的扩展。如果必须使用继承,确保它能够清晰地表达“是一个”的关系,并避免过深的继承层次。
serialVersionUID
是 Java 中用来标识序列化版本的一个静态字段。它的作用是确保在反序列化时,JVM 可以验证序列化的类与当前类的兼容性,以避免版本不兼容导致的错误。尽管
serialVersionUID
可以由开发人员手动定义,
禁止开发人员修改
serialVersionUID
字段的值
的原因如下:
serialVersionUID
的主要作用是保证在序列化和反序列化过程中,类的版本兼容性。它是用来标识类的版本的,如果序列化和反序列化过程中使用的类的
serialVersionUID
不匹配,就会抛出
InvalidClassException
。
serialVersionUID
会导致序列化的数据与当前类不兼容,导致反序列化失败。
serialVersionUID
的值会改变类的版本标识,导致已序列化的数据在反序列化时不能成功读取,特别是在类结构发生改变(例如添加或删除字段)时。
例如:
// 类的第一次版本
public class MyClass implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
// 其他字段和方法
}
// 类的第二次修改版本
public class MyClass implements Serializable {
private static final long serialVersionUID = 2L; // 修改了 serialVersionUID
private String name;
private int age; // 新增字段
// 其他字段和方法
}
如果修改了
serialVersionUID
,而之前序列化的数据是使用版本 1 的类进行序列化的,反序列化时会因为
serialVersionUID
不匹配而导致失败。
Java 会根据类的字段、方法等信息自动生成
serialVersionUID
,这个值是基于类的结构计算出来的。如果开发人员修改了
serialVersionUID
,可能会破坏 Java 自动生成的版本控制机制,从而导致版本控制不一致,增加了维护复杂性。
如果手动修改
serialVersionUID
,容易出现以下几种问题:
serialVersionUID
可能会导致已序列化的数据无法恢复。
serialVersionUID
,可能会在不同的机器或系统间引起序列化不一致。
Java 提供了两种主要的兼容性规则:
serialVersionUID
,则反序列化是可以工作的。
serialVersionUID
,反序列化仍然可以工作。
如果不小心修改了
serialVersionUID
,可能导致以下情况:
serialVersionUID
:Java 会根据类的结构自动生成
serialVersionUID
,这样如果类的结构发生变化,
serialVersionUID
会自动变化,确保不兼容的版本之间不会出现意外的反序列化行为。
serialVersionUID
:手动修改
serialVersionUID
可能导致版本控制不一致,特别是在多人开发、分布式部署的环境中,容易出现反序列化失败的问题。
手动修改
serialVersionUID
可能会导致数据丢失或反序列化时抛出异常。例如,如果开发人员错误地修改了
serialVersionUID
,系统在尝试反序列化时可能会因为
serialVersionUID
不匹配而无法成功加载对象,导致异常的发生。
禁止开发人员修改
serialVersionUID
字段的值,主要是为了:
serialVersionUID
的优势
,保证类的版本一致性和可维护性。
如果确实需要修改
serialVersionUID
,应确保修改后的版本与已经序列化的数据兼容,并遵循合理的版本管理策略。
禁止开发人员使用
isSuccess
作为变量名,主要是为了遵循更好的编程规范和提高代码的可读性、可维护性。这个变量名问题的核心在于其容易引起歧义和混淆。具体原因如下:
在 Java 中,通常使用
is
或
has
开头的变量名来表示布尔值(
boolean
类型)。这类命名通常遵循特定的语义约定,表示某个条件是否成立。例如:
isEnabled
表示某个功能是否启用;
hasPermission
表示是否有权限。
问题 :
isSuccess
看起来像一个布尔值(
boolean
类型),但它实际上可能并不直接表示一个布尔值,而是一个状态或结果。这种命名可能会导致混淆,开发者可能误以为它是布尔类型的变量,而实际上它可能是一个描述状态的对象、字符串或者其他类型的数据。
isSuccess
这个名字表面上表示“是否成功”,但是它缺少具体的上下文,导致语义不够明确。真正表示是否成功的布尔值应该直接使用
boolean
类型的变量,并且使用清晰明确的命名。
例如:
isCompleted
:表示某个任务是否完成。
isSuccessful
:表示某个操作是否成功。
这些命名能更明确地表达布尔变量的含义,避免理解上的歧义。
is
前缀混淆
is
前缀通常用来表示“是否”某个条件成立,适用于返回布尔值的方法或者变量。
isSuccess
这样的命名会让开发人员误以为它是一个布尔值,或者一个
boolean
类型的值,但实际上它可能是一个复杂类型或者其他非布尔类型,造成不必要的混淆。
例如:
boolean isSuccess = someMethod(); // 看起来是布尔值,但实际类型可能不同
这种情况可能导致开发人员产生误解,认为
isSuccess
代表的是布尔值,但它可能是某个表示成功的对象、枚举或者其他数据类型。
为了避免歧义和混淆,开发人员应使用更加明确且符合命名规范的名称。以下是一些命名的改进建议:
isSuccessful
或
wasSuccessful
。
operationResult
或
statusCode
,以表明它是一个描述操作结果的变量。
清晰且具有意义的命名能够帮助团队成员或未来的开发者更快地理解代码的意图。如果变量名过于模糊(如
isSuccess
),就可能让人对其实际含义产生疑问,尤其是在阅读较大或复杂的代码时。良好的命名能够提升代码的可读性和可维护性。
isSuccess
这样的命名不清晰,容易与布尔类型的变量产生混淆,进而影响代码的可读性。
isSuccessful
或
wasSuccessful
,更清晰地表达变量的意义。
以上是 V 哥精心总结的13个 Java 编程中的小小编码问题,也是V 哥日常编码中总结的学习笔记,分享给大家,如果内容对你有帮助,请不要吝啬来个小赞呗,关注威哥爱编程,Java 路上,你我相伴前行。
湘ICP备2022002427号-10湘公网安备:43070202000427号
© 2013~2019 haote.com 好特网