一、软件工程
背景:2018 年中美贸易战开始,华为被美国抵制,理由是可能含有间谍软件,窃听国家机密。(安全)
公司已经明确,把网络安全和隐私保护作为公司的最高纲领。我们要在每一个ICT基础设施产品和解决方案中,都融入信任、构建高质量,关键内容包括: 安全性(Security)。产品有良好的抗攻击能力,保护业务和数据的机密性、完整性和可用性。 韧性(Resilience)。系统受攻击时保持有定义的运行状态,包括降级,以及遭遇攻击时快速恢复的能力。 隐私性(Privacy)。遵从隐私保护既是法律法规的要求,也是价值观的体现。用户应该能够适当地控制他们的数据的使用方式。信息的使用政策应该是对用户透明的。用户应该根据自己的需要来控制何时接收以及是否接收信息。用户的隐私数据要有完善的保护能力和机制。 可靠性和可用性(Reliability& Availability)。产品能在生命周期内长期保障业务无故障运行,具备快速恢复和自我管理的能力,提供可预期的、一致的服务。 全面提升软件工程能力和实践,关乎公司未来的生存和发展,与我们每一个人都息息相关。在此,我希望全体员工、特别是软件工程师们主动参与进来,从自己做起,踏踏实实,共同打造可信的高质量产品。
金三角:时间、范围、成本三要素决定产品质量(产品)
我们各级管理者和全体员工都不得以进度、功能、特性等为理由来降低可信的要求,确保可信的要求在执行过程中不变形。
编程规范(开发)
我们要从最基础的编码质量做起,视高质量代码为尊严和个人声誉。代码就像是高楼大厦的一砖一瓦,没有高质量的代码,可信的产品就是空中楼阁。我们要优化并遵循公司各种编程规范,遵从架构与设计原则,熟练使用各种编程库和API,编写出简洁、规范、可读性强、健壮安全的代码。
软件架构(架构设计)
我们要深刻理解架构的核心要素,基于可信导向来进行架构与设计。在确保可信的前提下,要在性能、功能、扩展性等方面做好权衡;慎重地定义我们的模块与接口,真正做到高内聚与低耦合;我们要遵循权限和攻击面最小化等安全设计原则,科学设计模块之间的隔离与接口,提升安全性;低阶架构与设计要遵循高阶的架构与设计原则,在充分理解原有架构与设计的情况下,持续优化;我们要熟悉各种设计模式,重用公共成熟组件和服务,避免重复劳动。
持续重构(技术债务)
我们要重构腐化的架构及不符合软件工程规范和质量要求的历史代码。我们知道,再好的架构,其生命力也是有限的。随着时间的推移、环境的变化以及新技术、新功能特性的引入,架构也会腐化。面对腐化了的架构,要毫不犹豫地去重构它。同时主动以可信设计原则为导向,去重构不符合软件工程规范和质量要求的历史代码,提升软件架构的生命力。
技术选型
我们要深入钻研软件技术,尤其是安全技术。软件技术是我们打造产品的基本工具,技术是否先进,技术选择是否合理,将决定我们软件的高度;我们要深入学习架构与设计、编码、测试、安全、可用性、性能、维护性、体验等技术,并科学运用这些技术。
统一认知
我们要遵守过程的一致性。遵守适用的法律法规、遵循业界共识的标准、规范,确保规范到实现的一致性、代码到二进制的一致性。架构要符合架构原则,设计要遵循设计模式,代码要符合编程规范,最终做到需求与实现一致,达成各项对客户的承诺。我们只有脚踏实地做好每一步,才能真正打造出可信的高质量产品。
主动学习、持续分享
我们要开放透明、积极和勇于揭示问题并主动推动改进。软件开发是一种创造性和艺术性的工作,需要充分发挥我们的聪明才智和潜力。我们要改变只重视功能结果、不重视代码质量的行为习惯,要严格遵守软件工程规范;改变被动的修修补补;改变碎片化知识获取,主动去学习提升并贡献经验、代码,形成共享知识库
完善考核
我们要完善并增强透明、可回溯和可审计的全流程管理机制,以可信的视角,从初始设计、完整构建到产品生命周期管理,全面提升软件工程能力和实践。我们将全面强化以Committer角色为核心的代码审核和提交机制,代码经过更加严格和系统的审核才能合入版本。为此我们将建立一支更高水平的Committer角色群体,负责软件架构的看护、代码的审核和提交,整体保障合入代码的高质量。我们要变革考核机制,要让架构设计好、代码写得好的人脱颖而出,对编程能力不满足要求的人给予帮助和培训。但任何人如果编写的代码长时间不能合入版本,将会被团队抛弃。
二、基础内容
2.1 命名
大家都是有经验的开发了,一些特别基本的命名规范还是了解的,比如驼峰、常量大写用下划线分割等等,所以我这里说的命名主要是强调如何才能做到见名知意,让命名更加有意义,让命名能让开发甚至业务一眼就能看懂,当然这还需要理解统一词汇的重要性。
2.1.1 含糊不清
有意义的命名
我们看如下一段方法,想要完成的行为就是把订单更新为充电中,但我们无法直接从方法名理解方法意图。
java
public void dealOrder(long orderId){
Order order = orderMapper.getOrderById();
order.setStatus(OrderStatus.CHARGING);
orderMapper.update(order);
}
让我们给方法名加上具体目的
java
public void dealOrderStatus(long orderId){
Order order = orderMapper.getOrderById();
order.setStatus(OrderStatus.CHARGING);
orderMapper.update(order);
}
实际上,只有从业务含义出发才更能提现出方法的含义。
java
public void startCharging(long orderId){
Order order = orderMapper.getOrderById();
order.setStatus(OrderStatus.CHARGING);
orderMapper.update(order);
}
魔法数值
java
// 魔法数值
ThreadUtils.sleep(5000);
// 定义有意义的名称
public static final long FIVE_SECOND = 5 * 1000;
ThreadUtils.sleep(FIVE_SECOND);
名不符实
两个方法的含义模糊不清,混在一起更无法区分,更重要的是方法名与方法内实际做的事不相符。 调整前
java
public static void main(String args[]){
recharge();
}
public void recharge(){
check();
init();
start();
}
public void start(){
// 业务逻辑
}
调整后
java
public static void main(String args[]){
rechargePrepare();
startRecharge();
}
public void rechargePrepare(){
check();
init();
}
public void startRecharge(){
// 业务逻辑
}
差不多的含义
java
public Order queryOrderInfo();
public Order queryOrderData();
public Order queryOrderList();
public Order getOrderData();
public Order getOrderInfo();
public Order getOrderList();
多此一举
java
double amountMoney;// 直接命名成double money
double incomeAmount;// 直接命名成double income
2.1.2 随意的简化命名
我们知道命名不宜过长,所以我们习惯用简写,我也认为在足以表达其含义的情况下,命名越短越好,所谓言简意赅,但我们往往容易忽略上下文环境,随意地简化命名,从而带来不好的可读性。 简写一般不要具备业务含义
java
Document doc;
Response resp;
Request req;
Number num;
Second sec;
含有业务含义时,请注意简写的使用
java
CacheKey ck;
Object obj ---> Object className;
String str ---> String userName;
double d ---> double amount;
2.1.3 命名统一
这是一个非常简单的道理,统一规约是非常重要的,它能够提高代码的可读性,可维护性,可搜索性,减少人员的沟通成本,以及一些不必要的麻烦。 下面这些命名不存在谁对谁错,问题在于统一,saveOrder
或者insterOrder
都可以,保持一致就行,根据关键字联想最郁闷的就是不知道关键字。
java
saveOrder、deleteOrder、updateOrder、queryOrder
insertOrder、clearOrder、modifyOrder、selectOrder
2.1.4 常见命名规则
布尔值命名
按需执行的方法
异步、回调方法
对象生命周期方法
数据操作
2.1.5 头疼的英语
没办法,起个中文名都难,更何况是英语,这可能是很多人都曾遇到过的窘境,的确,有些需求描述让我们不太容易直接翻译成英文,更何况如果是英文不好的情况下,可能更麻烦。。。 这方面我建议可以多阅读一些优秀的源码,看看他们的命名习惯,也可以在github上找一些优秀的代码看看,多参考国人写的代码,会显得更亲近。 当然,我相信,实在不知道要如何翻译时,只能寻求翻译软件,但希望你能正确的区分应该使用动词还是名词。
2.2 长函数、长参数
2.2.1 长函数
我知道有很多公司都有对一个方法的行数限制要求,比如不能超过50行、甚至20行,我觉得这样不分场合的硬性要求并不合理,我们应该搞清楚长函数会带来哪些问题?由问题为出发点,去检查方法的行数,有时候10行可能都写得不好,有时候即使50行了但也没问题,像Sping、MyBatis源码中超过50行的方法也有很多。 其实大多数造成长函数的原因就是封装、设计得不够,导致代码的可维护性变差,对重复的逻辑没有封装,就像看C语言的代码一样,面向过程,平铺直叙地完成整个业务逻辑。
几行代码也并不一定表示没问题。
java
public static void longMethod(String userId, BigDecimal price) {
// 判断用户是否是VIP
User user = userDao.getUserByUserId(userId);
int memberAttr = user.getUserMemberAttr();
double discountPrice;
// VIP用户打8折,其他用户打9折
if (memberAttr == 1) {
discountPrice = price.multiply(new BigDecimal(0.8)).doubleValue();
} else {
discountPrice = price.multiply(new BigDecimal(0.9)).doubleValue();
}
}
抽象出通用逻辑
java
// 根据用户ID获取用户会员属性
private static int getUserMemberAttr(String userId) {
User user = userDao.getUserByUserId(userId);
return user.getUserMemberAttr();
}
java
// 根据会员属性计算折扣价
private static double getDiscountPrice(int memberAttr, BigDecimal price) {
double discountPrice;
if (memberAttr == 1) {
discountPrice = price.multiply(new BigDecimal(0.8)).doubleValue();
} else {
discountPrice = price.multiply(new BigDecimal(0.9)).doubleValue();
}
return discountPrice;
}
最后具体业务逻辑应该是这样
java
public static void longMethod(String userId, BigDecimal price) {
// 获取用户会员属性
int memberAttr = getUserMemberAttr(userId);
// 根据会员属性获取折扣价
double discountPrice = getDiscountPrice(memberAttr, price);
}
还有很多方法较长的原因是因为不是出自同一人之手,意思就是,前人写的代码不了解逻辑、业务不敢随意乱动,只能继续叠加功能,那我给的建议就是一点一点的优化,不要急于一次性全部优化完,不然可能很容易就产生bug,慢慢来,不要急。
美国童子军有一条简单的军规:让营地比你来时更干净。
2.2.2 长参数列表
这一条规范和长函数很像,一般也会这样规定,比如参数不能超过5个,道理是一样的,太多的参数不利于代码维护,还容易出错,别人在阅读的时候也不容易理解。
很简单的例子
java
@Data
@Builder
class Order {
private String orderId;
private BigDecimal amount;
private String orderChannel;
}
@Data
@Builder
class User {
private String userName;
private String userId;
private String userAccount;
private String phone;
private String address;
}
长参数列表方法
java
public static void longParam(String userName,
String userId,
String userAccount,
String phone,
String address) {
User user = new User();
user.setUserName(userName);
user.setUserId(userId);
user.setUserAccount(userAccount);
user.setPhone(phone);
user.setAddress(address);
// ...
}
可以封装成对象使用
java
public static void longParam(User user) {
// ...
}
混合类型的长参数列表
java
public static void longParam(String userAccount,
String userName,
String orderId,
BigDecimal amount,
String orderChannel) {
}
封装成一个混合类型的对象
java
@Data
class RequestParam{
private String userAccount;
private String userName;
private String orderId;
private BigDecimal amount;
private String orderChannel;
}
使用时再一个个取出来
java
public static void longParam(RequestParam requestParam){
User user = new User();
user.setUserAccount(requestParam.getUserAccount());
user.setUserName(requestParam.getUserName());
Order order = new Order();
order.setOrderId(requestParam.getOrderId());
order.setOrderChannel(requestParam.getOrderChannel());
order.setAmount(requestParam.getAmount());
}
如果感觉别扭,当然可以进行优化,比如像下面这样: 给RequestParam
分别封装一个构建Order
和User
对象的方法。
java
@Data
class RequestParam {
private String userAccount;
private String userName;
private String orderId;
private BigDecimal amount;
private String orderChannel;
public Order newOrder() {
return Order.builder()
.amount(amount)
.orderChannel(orderChannel)
.orderId(orderId)
.build();
}
public User newUser() {
return User.builder()
.userAccount(userAccount)
.userName(userName)
.build();
}
}
在使用时就可以像这样
java
public static void longParam(RequestParam requestParam){
User user = requestParam.newUser();
Order order = requestParam.newOrder();
}
将多个参数封装成对象的方式还有个非常重要的原因就是可以有效地提升方法的兼容性,在方法作为接口对外暴露时,一旦添加或者删除了新的参数,如果没有封装成对象,那么调用者是一定要改的,但如果封装成了一个对象,那么老的远程调用者就很有可能不需要做任何修改。
2.3 日志规范
关于日志方面的规范其实并不多,也没那么重要,毕竟日志打印这个行为很简单,很单纯,阿里开发手册上有两条关于日志的强制性要求,一个是要求日志文件至少保留15天,一个是有关敏感信息、安全等相关记录的,根据国家法律要求至少保留六个月,日志要保留这么长时间,那自然对于日志的管理就很重要,所以很自然地就有对于日志的命名、分类、日期、拆分等就有了一系列的要求,对于这些我们就不过多说明了。
2.3.1 过多打印
有一些人,可能是为了问题排查方便,50行的代码里有一半都是在打印日志,好像已经养成了一种习惯,在任何方面调用前后都要用日志记录一下方法的入参、出参,我们知道打印日志是存在一定的性能消耗的,可能100ms的方法,有50ms都是因为打印日志造成的。其次增加了日志的存储成本。
打印了日志,又抛出了异常,会造成重复打印两次日志。
java
try{
// ...
}catch(Exception e){
log.error("xxxx",e);
throw new MyException(e);
}
2.3.2 重复打印
add方法调用方和被调用方都打印了相同的内容
java
public void method() {
log.info("方法method执行开始");
int a = 1, b = 2;
log.info("调用add方法,入参:{},{}", a, b);
int c = add(a, b);
log.info("add方法返回:{}", c);
log.info("方法method执行结束");
}
private int add(int a, int b) {
log.info("方法add执行开始,入参:{},{}", a, b);
int c = a + b;
log.info("add方法返回:{}", c);
return c;
}
map打印的时候已经包含了aId,bId
java
public static void methodA() {
Map<String, String> map = new HashMap<>();
String str1 = "1";
String str2 = "2";
map.put("a", str1);
map.put("b", str2);
log.info("str1:{},str2:{}", str1, str2);
log.info("map:{}", map);
}
可以合一条打印的,没必要打印三次
java
public static void methodB() {
String str1 = "1";
String str2 = "2";
String str3 = "3";
log.info("str1:{}", str1);
log.info("str2:{}", str2);
log.info("str3:{}", str3);
}
2.3.3 批量打印
在循环体内打印日志
java
public static void methodC() {
for (int i = 0; i < 100; i++) {
log.info("循环内打印日志: " + i);
}
}
打印大集合数据
java
public static void methodD() {
int[] arr = new int[1000];
Arrays.fill(arr, 1);
log.info(Arrays.toString(arr));
}
2.3.4 日志含方法
不要把map
或者对象转成String
,直接用toString
方法输出即可,JSON
转String
本身就有一定的性能消耗,有时候还容易引起报错。
java
public static void methodE() {
Map<String, String> map = new HashMap<>();
map.put("a", "1");
map.put("b", "2");
map.put("c", "3");
log.info("没必要把map或者对象通过JSON转成String再打印: {}" , JSON.toJSONString(map));
}
注意不要因为日志输出而引起报错
java
public static void methodE() {
User user = null;
log.info("调用user中的任何方法都会出现空指针异常: {}", user.toString());
log.info("直接打印user即可: {}", user);
}
2.3.5 讲故事
这个问题典型的表现就是把各种代码逻辑都以日志的形式呈现了出来。
java
private static void method1(String name) {
User user = new User();
log.info("把user对象的name设置成:{}", name);
user.setName(name);
log.info("当name等于小王时,执行methodA,否则执行methodB");
if (Objects.equals("小王", name)) {
methodA();
} else {
methodB();
}
}
2.3.6 日志跟踪
为了方便日志跟踪,最少给日志配上时间戳、级别、线程名,或者其他能够有效识别的信息。
比如配置上traceId
和spanId
方便跟踪。
java
<pattern>%date %level [%X{traceId}/%X{spanId}] [%thread] %logger{10} [%file:%line] %msg%n</pattern>
2.3.7 输出堆栈
异常堆栈信息对于问题排查是非常重要的。
下面这种打印方式,e
被当作普通对象传入参数中,只会按照e.getMessage
方法输出,并不会打印全部堆栈信息。
java
log.error("fee config calculate failure, error message:{}, request param:{}", e, request.toString());
应该修改为
java
log.error("fee config calculate failure, error message:{}, request param:{}", e.getMessage(), request.toString(), e);
2.3.8 其他基本要求
- 请按照正确的日志级别进行输出,
debug、info、warn、error
应该要区分清楚。 - 使用占位符的方式输出日志,占位符比使用String拼接要高效。
- 日志输出尽量使用英文,可以有效地减少日志存储大小,也更加符合国际化标准。
- 大量地输出无效日志,不利于系统性能提升,也不利于快速定位错误点。记录日志时请思考:这些日志真的有人看吗?看到这条日志你能做什么?能不能给问题排查带来好处?
- 注意日志输出的级别,
error
级别只记录系统逻辑出错、异常或者重要的错误信息。 - 不要直接使用日志系统的
(log4j、logback)API
,请使用日志框架SLF4J
中的API。
2.4 线程
2.4.1 线程池
池化思想、复用思想这是提升性能的一种有效手段,常见的有线程池、连接池、对象池,但是他们都存在一个相同的问题就是池子的容量,在JDK1.5时提供的几种线程池,默认情况下都没有控制,newFixedThreadPool、newSingleThreadExecutor
属于几乎无限大队列数,newCachedThreadPool、newScheduledThreadPool
属于几乎无限大线程数,所以一般我们要根据实际情况自己构建合理的线程池,另外了为了方便排查问题,要给线程池起一个有意义的名称。
2.4.2 线程安全
提起线程就一定绕不开线程安全的问题,现在几乎都在使用Spring框架,我们知道Spring Bean对象默认都是单例的,是线程不安全的,但是由于三层架构的方式,一般我们注入的Spring Bean实例,像Contorller、Service、Dao
这些都是无状态的,自然也就不存在线程安全的问题,所以搞清楚是否线程安全的关键,不仅仅是判断资源是否共享,还要看看共享的资源是否是有状态的。
JDK在此方面也提供了很多线程安全的类,我们应该清楚它们的使用场景。
时间
使用 Instant
代替 Date,LocalDateTime
代替 Calendar,DateTimeFormatter
代替 SimpleDateFormat
。
数值
JDK1.5提供了很多Atomic开头的类,这些类大多数都是通过cas的方式实现原子操作。
集合
关于集合的线程安全类有很多,主要差别在于性能和使用场景上。
Map
java
// 这两种都是通过使用synchronized关键字来实现,效率都不高
Collections.synchronizedMap(new HashMap<>());
Map m = new Hashtable();
// ConcurrentHashMap一开始采用分段锁实现,之后在JDK对synchronized优化后,又改成synchronized+分段锁实现
Map map = new ConcurrentHashMap();
// 不可变的方式,禁止写的操作,也算是从根源上杜绝线程不安全的可能。。。如果创建后需要禁止写入,则可以使用这种方式
Collections.unmodifiableMap(new HashMap<>());
// 有序的、线程安全的Map
new ConcurrentSkipListMap<>();
List
前两种方式与map一样都是通过synchronized
关键字来实现, 这里要特别注意CopyOnWriteArrayList
的使用场景,对于读多写少的场景CopyOnWriteArrayList
效率非常高,但如果是读少写多的情况下,CopyOnWriteArrayList
的效率则十分低下。
java
Collections.synchronizedList(new ArrayList<>())
Collections.synchronizedList(new ArrayList<>());
List list = new Vector();
List safeList = new CopyOnWriteArrayList();
Collections.unmodifiableList(new ArrayList<>());
Set
java
// 两种方式几乎没有什么区别newKeySet时JDK1.8时提供的,newSetFromMap是JDK1.6时提供的,本质都是通过ConcurrentHashMap实现的
ConcurrentHashMap.newKeySet();
Collections.newSetFromMap(new ConcurrentHashMap<>());
// 写时复制的set,同样需要注意使用场景,只有读多写少的情况才适用
new CopyOnWriteArraySet();
//不可变的
Collections.unmodifiableSet(new HashSet<>());
//有序的set集合,综合了读写性能,如果读写都差不多的情况下,可以使用它
new ConcurrentSkipListSet();
2.4.3 线程并发
除了线程安全之外,就要考虑并发方面的问题了,并发有可能造成一段代码的处理能力急剧下滑,如何利用多线程的并行处理能力解决并发效率问题,也是非常关键的地方。
常见的解决方式包括:无锁化(cas)、分段锁(每个线程分别锁一小段,减少冲突)、读写锁、写时复制、伪共享等。
其实现在的JDK已经对synchronized
进行了大量的优化,效率也并没有想象的那么差了,并且synchronized
在保证线程安全方面足够的简单,在涉及到资金相关操作时,更加稳妥,不容易出错。
悲观锁遵循一锁、二判、三更新、四释放的原则。
分段锁
并发造成性能下滑的主要原因就是共享资源的竞争,那么分段锁就是为了减少共享资源的竞争,把一份大的共享资源分成若干份,然后让每个线程各自持有一份,这样自然就减少了冲突。
随机数
Random
在多线程并发下效率会比较低,其通过cas的方式保证了线程安全,但在高并发下很有可能会失败,造成频繁的重试。
java
protected int next(int bits) {
long oldseed, nextseed;
AtomicLong seed = this.seed;
do {
oldseed = seed.get();
nextseed = (oldseed * multiplier + addend) & mask;
} while (!seed.compareAndSet(oldseed, nextseed));
return (int)(nextseed >>> (48 - bits));
}
所以就有了ThreadLocalRandom
,它的优化方式主要就是分段,通过让每个线程拥有独立的存储空间,这样既保证了线程安全,同时效率也不会太差。
java
public static ThreadLocalRandom current() {
if (U.getInt(Thread.currentThread(), PROBE) == 0)
localInit();
return instance;
}
static final void localInit() {
int p = probeGenerator.addAndGet(PROBE_INCREMENT);
int probe = (p == 0) ? 1 : p; // skip 0
long seed = mix64(seeder.getAndAdd(SEEDER_INCREMENT));
Thread t = Thread.currentThread();
U.putLong(t, SEED, seed);
U.putInt(t, PROBE, probe);
}
public int nextInt() {
return mix32(nextSeed());
}
final long nextSeed() {
Thread t; long r; // read and update per-thread seed
U.putLong(t = Thread.currentThread(), SEED,
r = U.getLong(t, SEED) + GAMMA);
return r;
}
ConcurrentHashMap
ConcurrentHashMap也是采用分段锁的思想,只不过不是简单地让每个线程独立拥有一份完整的Map,而是按照map中的table capacity(默认16)来决定,也就是说每个线程会锁1/16的数据段,这样一来并发就差不多提升了16倍。
读写锁
读写锁主要根据大多数业务场景都是读多写少的情况,在读数据时,无论多少线程同时访问都不会有安全问题,所以在读数据的时候可以不加锁,不过一旦有些请求时就需要加锁了。
读 读 不冲突
读 写 冲突
写 写 冲突
例如:ReentrantReadWriteLock
,由一把读锁、一把写锁组成。
写时复制
写时复制最大的优势在于,在写数据的过程时,不影响读,可以理解为读的是数据的副本,而只有当数据真正写完后才会替换副本,当副本特别大、写数据过程比较漫长时,写时复制就特别有用了。
例如:CopyOnWriteArrayList
java
public E get(int index) {
return elementAt(getArray(), index);
}
final Object[] getArray() {
return array;
}
public boolean add(E e) {
synchronized (lock) {
Object[] es = getArray();
int len = es.length;
es = Arrays.copyOf(es, len + 1);
es[len] = e;
setArray(es);
return true;
}
}
final void setArray(Object[] a) {
array = a;
}
写时复制有两个缺点,可以看到在add
方法时使用了synchronized
关键字,也就是说当存在大量的写入操作时,效率实际上是非常低的,另一个问题就是需要copy一份一模一样的数据,可能会造成内存的异常波动。
伪共享
当多线程访问的数据位于同一个缓存行时,就会互相影响彼此的效率。
假设A线程操作数据C,B线程操作数据D,C、D数据位于同一缓存行,那么当A线程修改了C数据时,由于缓存一致性协议的规定,就会造成缓存行失效,那么当B线程读取D数据时,就必须重新加载缓存,尽管B线程之前并没有对D进行过任何操作,同理B线程的操作同样会影响着A线程。
知道了原因之后我们就可以进行优化
java
public class CacheLinePadding {
private static class Padding {
//打开这个注释再执行,效率会提升
//public volatile long p1, p2, p3, p4, p5, p6, p7;
}
// @Contended JDK8提供了这个注解,等同于使用Padding类的效果
private static class T extends Padding {
//x变量8个字节,加上Padding中的变量,刚好64个字节,独占一个缓存行。
public volatile long x = 0L;
}
public static T[] arr = new T[2];
static {
arr[0] = new T();
arr[1] = new T();
}
public static void main(String[] args) throws Exception {
Thread t1 = new Thread(() -> {
for (long i = 0; i < 100000000; i++) {
arr[0].x = i;
}
});
Thread t2 = new Thread(() -> {
for (long i = 0; i < 100000000; i++) {
arr[1].x = i;
}
});
final long start = System.nanoTime();
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println((System.nanoTime() - start) / 100000);
}
}
2.4.4 ThreadLoad
ThreadLocal
也是一种常用的保证线程安全、并能够保证并发量的方式,只不过在使用时需要注意内存泄漏的风险,只要了解内部的引用关系,自然就能理解。
2.5 圈复杂度
圈复杂度是一种衡量代码复杂度的标准,其数量上表现为独立路径的条数,也可以理解为覆盖所有的可能情况最少使用的测试用例个数,圈复杂度高说明程序代码的判断逻辑复杂,可能质量低,且难于测试和维护。 在sonar规范中,经常有关于圈复杂度的扫描,比如一个方法的圈复杂度不得超过5或者10,一般来说,代码里每出现一次if、for、while、case、catch
等这样的语句,复杂度就加1,很明显,圈复杂度的目的就是为了减少代码里控制语句的滥用,接下来我们就来看看如何可以减少圈复杂度。
2.5.1 提炼方法
这个和之前提到的长函数类似,如何存在通用的方法可以提炼出来,一些条件控制语句多数也可以提炼出来,总之就是一个大函数变多个小函数。
2.5.2 if、else
一提到if、else
的优化必然会想到策略模式,策略模式主要解决的就是在多种算法相似的情况下,使用if、else
所带来的高复杂度,不过除了使用策略模式之外,还有一些其他的小技巧,我相信在平时开发过程中无论是有意识还是无意识,应该都有用到过,下面我们就一起来看看。
卫语句
第一种方式就使用卫语句。
这是很正常的逻辑,嵌套了if控制语句
java
public void test() {
if(条件1成立){
if(条件2成立){
执行xxx逻辑;
}
}
}
优化后,嵌套消失了。
java
public void test() {
if(!条件1成立){
return;
}
if(!条件2成立){
return;
}
执行xxx逻辑;
}
这是一种经典的重构方法:通过卫语句来替代嵌套的条件表达式 减少了嵌套,也就减少了层次的缩进,这样在阅读理解上会更加轻松,减少了代码的复杂度。
去else
java
public void test() {
if (10 < amount && amount < 20) {
执行xxx逻辑;
} else if (21 < amount && amount < 30) {
执行xxx逻辑;
} else if (31 < amount && amount < 40) {
执行xxx逻辑;
} else {
执行xxx逻辑;
}
}
去除else后
java
public void test1() {
if (10 < amount && amount < 20) {
执行xxx逻辑;
return;
}
if (21 < amount && amount < 30) {
执行xxx逻辑;
return;
}
if (31 < amount && amount < 40) {
执行xxx逻辑;
return;
}
执行xxx逻辑;
}
这也是一种常见的减少复杂层级的方式。
策略模式
最后我们再来看看策略模式的方法。
下面这段逻辑描述的是,如果memberAttr
是VIP,我们就根据用户VIP级别给到不同的折扣。
java
public BigDecimal test(String memberAttr) {
BigDecimal amount = BigDecimal.valueOf(10d);
if ("VIP".equals(memberAttr)) {
String level = getVipLevel();
if ("1".equals(level)) {
return amount.multiply(BigDecimal.valueOf(0.9));
}
if ("2".equals(level)) {
return amount.multiply(BigDecimal.valueOf(0.8));
}
return amount.multiply(BigDecimal.valueOf(0.7));
}
return amount;
}
这是最简单的实现方式。
如果用卫语句方式来优化
java
public BigDecimal test(String memberAttr, String userId) {
BigDecimal amount = BigDecimal.valueOf(10d);
if (!"VIP".equals(memberAttr)) {
return amount;
}
String level = getVipLevel(userId);
if ("1".equals(level)) {
amount = amount.multiply(BigDecimal.valueOf(0.9));
} else if ("2".equals(level)) {
amount = amount.multiply(BigDecimal.valueOf(0.8));
} else {
amount = amount.multiply(BigDecimal.valueOf(0.7));
}
return amount;
}
再加上去else的方式
java
public BigDecimal test(String memberAttr, String userId) {
BigDecimal amount = BigDecimal.valueOf(10d);
if (!"VIP".equals(memberAttr)) {
return amount;
}
String level = getVipLevel(userId);
if ("1".equals(level)) {
return amount.multiply(BigDecimal.valueOf(0.9));
}
if ("2".equals(level)) {
return amount.multiply(BigDecimal.valueOf(0.8));
}
return amount.multiply(BigDecimal.valueOf(0.7));
}
最后对比一下策略模式。
java
public BigDecimal method1(String memberAttr, String userId) {
BigDecimal amount = BigDecimal.valueOf(10d);
if (!"VIP".equals(memberAttr)) {
return amount;
}
String level = getVipLevel(userId);
DiscountStrategy discountStrategy = DiscountStrategyFactory.getDiscountStrategy(level);
return discountStrategy.discount(amount);
}
2.5.3 switch
刚才的方法,我们可以换成switch
写法
java
public BigDecimal test(String memberAttr, String userId) {
BigDecimal amount = BigDecimal.valueOf(10d);
if (!"VIP".equals(memberAttr)) {
return amount;
}
String level = getVipLevel(userId);
switch (level) {
case "1":
return amount.multiply(BigDecimal.valueOf(0.9));
case "2":
return amount.multiply(BigDecimal.valueOf(0.8));
default:
return amount.multiply(BigDecimal.valueOf(0.7));
}
}
假设现在又出现了一种超级VIP,那么它可能是这样的。
java
public BigDecimal test(String memberAttr, String userId) {
BigDecimal amount = BigDecimal.valueOf(10d);
if (!"VIP".equals(memberAttr)) {
return amount;
}
String level = getSuperVipLevel(userId);
switch (level) {
case "1":
return amount.multiply(BigDecimal.valueOf(0.8));
case "2":
return amount.multiply(BigDecimal.valueOf(0.7));
default:
return amount.multiply(BigDecimal.valueOf(0.6));
}
}
折扣力度发生了变化,但是也是根据用户级别来区分的,如果再出现一些根据用户级别来进行业务逻辑处理的场景,那我们就应该考虑抽象、多态的方式了,否则一处变动,你需要找到所有使用switch的地方,并修改它。
2.5.4 循环嵌套
for循环嵌套不太好优化,因为可能会和选择的算法有关,比如下面这个经典的求两个数组交集。
第一版,for循环嵌套,复杂度高,性能也差,是最不应该采取的方式。
java
public int[] intersection(int[] nums1, int[] nums2) {
Set<Integer> intersectionSet = new HashSet<>();
for (int i = 0; i < nums1.length; i++) {
for (int j = 0; j < nums2.length; j++) {
if (nums1[i] == nums2[j]) {
intersectionSet.add(nums1[i]);
}
}
}
return intersectionSet.stream().mapToInt(Integer::valueOf).toArray();
}
第二版,借用API,循环嵌套没了,使用retainAll
函数实现交集,性能优于第一版。
java
public static int[] intersection1(int[] nums1, int[] nums2) {
Set<Integer> set1 = new HashSet<>();
Set<Integer> set2 = new HashSet<>();
for(int num : nums1){
set1.add(num);
}
for(int num : nums2){
set2.add(num);
}
set1.retainAll(set2);
return set1.stream().mapToInt(Integer::valueOf).toArray();
}
第三版,利用contains
函数,实际上就是利用O(1)时间复杂度的hash函数,性能上要优于第二版,但圈复杂度确实上升了。
java
public static int[] intersection2(int[] nums1, int[] nums2) {
Set<Integer> set1 = new HashSet<>();
Set<Integer> intersectionSet = new HashSet<>();
for(int num : nums1){
set1.add(num);
}
for(int num : nums2){
if(set1.contains(num)){
intersectionSet.add(num);
}
}
return intersectionSet.stream().mapToInt(Integer::valueOf).toArray();
}
第四版,排序+双指针,这是最优解的算法,不过复杂度大大上升。
java
public static int[] intersection3(int[] nums1, int[] nums2) {
Arrays.sort(nums1);
Arrays.sort(nums2);
int p1 = 0, p2 = 0, index = 0;
int[] nums = new int[nums1.length + nums2.length];
while (p1 < nums1.length && p2 < nums2.length) {
if (nums1[p1] == nums2[p2]) {
if (index == 0 || nums1[p1] != nums[index - 1]) {
nums[index++] = nums1[p1];
}
p1++;
p2++;
} else if (nums1[p1] < nums2[p2]) {
p1++;
} else {
p2++;
}
}
return Arrays.copyOfRange(nums, 0, index);
}
2.5.5 其他建议
尽量不要在条件判断中附加其他的业务逻辑,条件判断尽量清晰明了。
java
if(长的高 && 长的帅 && 有钱){
...
}
boolean 高富帅 = 长的高 && 长的帅 && 有钱
if(高富帅){
...
}
尽量避免反逻辑
java
// 下面两个方法都表示当x<100时,执行,但反逻辑让人理解起来更麻烦。
if(x < 100){
...
}
if(!x >= 100){
...
}
长行代码学会换行
java
StringBuilder sb = new StringBuilder();
sb.append("a").append("b").append("c").append("d").append("e");
// "."号换行
sb.append("a").append("b")
.append("c")
.append("d")
.append("e");
new User(String userName, String account, String age, String sex, String phone, String email)
// ","号换行
new User(String userName, String account, String age
, String sex, String phone, String email)
2.5.6 消除重复代码
通用处理逻辑封装
业务逻辑中可能存在大量类似的参数校验
java
public void method1(String id) throws Exception {
if (Objects.isNull(id)) {
throw new Exception("id不能为空");
}
}
public void method2(Object obj) throws Exception {
if (Objects.isNull(obj)) {
throw new Exception("入参对象不能为空");
}
}
public void method3(List<?> list) throws Exception {
if (CollectionUtil.isEmpty(list)) {
throw new Exception("list集合不能为空");
}
}
统一处理后
java
public class Assert {
public static void notEmpty(String param) throws BizException {
if (StringUtils.isEmpty(param)) {
throw new BizException(ErrorCodes.PARAM_IS_EMPTY, "param is empty");
}
}
public static void notNull(Object o) throws BizException {
if (Objects.isNull(o)) {
throw new BizException(ErrorCodes.PARAM_IS_NULL, "object is null");
}
}
public static void notEmpty(Collection collection) throws BizException {
if (CollectionUtils.isEmpty(collection)) {
throw new BizException(ErrorCodes.COLLECTION_IS_NULL_OR_EMPTY, "collection is null or empty");
}
}
}
public void method_1(String id) throws BizException {
Assert.notEmpty(id);
}
public void method_2(Object obj) throws BizException {
Assert.notNull(obj);
}
public void method_3(List<?> list) throws BizException {
Assert.notEmpty(list);
}
同样,统一异常处理,也可使用@ControllerAdvice
注解完成,其他的类似日志、方法出入参打印、耗时统计等,都建议通过AOP方式统一处理。
2.6 数据结构与算法
数据结构一般被分为:逻辑结构与存储结构(内存)
逻辑结构:线性结构、树形结构、图形结构
存储结构:顺序存储、链式存储
2.6.1 逻辑结构
线性结构
线性结构包含:数组、链表、栈、队列,其特点是由一系列数据类型相同的数据元素组成的有序集合。 线性结构的优点是操作简单、速度快,随机访问能力强,但缺点是一旦数据量增加后,要么访问的速度变慢,要么插入或者删除的速度变慢,因此线性结构通常用于处理简单的数据集合,例如Java语言中的List、LinkedList、Queue
等容器集合。
树形结构
树这种数据结构就比线性结构要复杂得多了,树一般由多个节点按照层次结构连接而成,常见的树有二叉树、平衡树、红黑树、B树和B+树等,树形结构通常用来解决大数据量之下的数据检索以及操作的问题。
图形结构
图形结构和前两种结构相比,又是一种更复杂的数据结构了,它是由节点和边组成的,节点表示图形中的一个点,边表示两个节点之间的关系。在图形数据结构中,每个节点都有一个唯一的标识符,称为节点ID,而边则用于描述节点之间的关系,如边的权重和边的方向等。
图形数据结构通常用于表示具有网络或连通性的实体或概念,例如社交网络中的好友关系、城市地图等,可以说,凡是多对多的关系,都可以看作是图形结构。
2.6.2 存储结构
顺序存储
顺序存储是将数据按照一定的顺序排列存储在存储器中。这种方式的优点是访问速度快,适合存储结构简单的数据。但是,当需要插入或删除数据时,需要移动后面的数据,时间复杂度较高。
数组、栈、队列、树(树这种结构既可以用顺序存储也可以用链式存储,比如最为典型的就是堆这样的树形结构,它就是用数组来存储的)都可以用顺序存储来完成。
链式存储
链式存储是将数据存储在存储器中的不同位置,每个位置上存储的是一个指针,指向存储在其他位置的数据。这种方式的优点是插入和删除数据的效率高,时间复杂度为O(1)。但是,访问某个数据需要从头开始遍历,时间复杂度较高。
链表、树、图都可以用链式存储来完成。
结合这些数据结构的特点,希望你能在增删改查的场景中进行正确的选择。
2.6.3 算法的魅力
来一道小学生难道的数学题:计算1+2+3+...+100之和
。
一般玩家会这样做
java
int sum = 0;
for(int i = 1; i<=100; i++){
sum += i;
}
高级玩家,会先找到规律,比如:0+100+1+99+2+98+3+97+....+49+51+50
, 然后根据这样的规律,对代码逻辑进行优化。
java
int end = 100, half = end / 2, sum;
if ((end & 1) == 0) {
sum = end * half + half;
} else {
sum = end * (half + 1);
}
大神玩家,同样是规律,但找到的规律更容易用代码来表现。
1 + 2 + 3 + ... + 98 + 99 + 100
100 + 99 + 98 + .... + 3 + 2 + 1
java
int start = 1, end = 100;
int sum = (start + end) * end / 2;
优秀的算法可以有效地降低时间复杂度,后两种解法直接从O(n)降到O(1),如果要加到一万,一千万,一亿,节省的计算量相当可观。
2.6.4 复杂度分析
复杂度一般分为:时间复杂度、空间复杂度。
大多数场景中更注重的是时间复杂度,根据其执行效率,一般又可分为如下几种:
常量阶:O(1)
java
int i = 1, j = 2;
int sum = i + j;
对数阶:O(logn)
java
int i = 1;
while(i < n){
i = i * 2;
}
线性阶:O(n)
java
int i = 1;
while(i < n){
i++;
}
线性对数阶:O(nlogn)
快排、堆排序、归并排序的平均时间复杂度就是O(nlogn)。
平方阶:O(n^2)
java
for(int i = 0; i < n; i++){
for(int j = 0; j < n; j++){
}
}
插入、选择、冒泡都是O(n^2)的时间复杂度。
立方阶:O(n^3)
java
for(int i = 0; i < n; i++){
for(int j = 0; j < n; j++){
for(int k = 0; k < n; k++){
}
}
}
指数阶:O(2^n)
常见的求子集问题,就是指数阶复杂度。
阶乘阶:O(n!)
全排列问题
注意:在谈一个算法时间复杂度时,一般考虑的都是最坏时间复杂度
2.6.5 数组与链表
概念
数组
数组是一种线性表数据结构,它用一组连续的内存空间,来存储一组具有相同类型的数据。 链表
链表也是一种线性的数据结构,与数组不同的是,链表并不需要连续的内存空间,链表是通过指针的方式把每一个数据串联起来,对于单向链表来说,会记录一个后继指针next,而对于双向链表来说,除了后继指针next之外,还有一个前继指针pre。
随机访问
数组
我们都知道数组可以做到O(1)的随机访问,那原因就在于连续的内存空间和相同的数据类型。假设我们定义一个数组大小为5,并且存储的是int类型的数据,那么在内存中就会为其分配一块连续的空间,假设地址为:10~99(按照int类型占4个字节来计算),那么现在要访问数组中随便一个数据就可以通过这个公式来实现:arr[i] = 10 + i * 4
链表
链表中要想随机访问一个元素,就不能像数组一样做到O(1)的时间复杂度了,链表并不要求内存连续,所以没办法通过计算直接找到数据在内存中的位置,对于链表来说只能从头节点或者尾节点开始挨个遍历,因此其时间复杂度是O(n)的。
插入、删除
数组
正是因为内存连续性的要求,也导致了数组在插入和删除时相对低效,假设我们要在数组第一个位置插入一个元素,那么就会造成从第一个位置开始,之后的所有位置都需要往后挪一位,所以其时间复杂度为O(n),但如果只是往最后一个位置插入一个元素,那其时间复杂度还是O(1),或者插入元素后不必保证原始数组中的顺序不变,那我们也可以做赋值操作,先把待插入位置上的元素查找出来,并添加到数组最后,然后再用待插入的元素覆盖原位置上的元素即可,这样操作下来其时间复杂度也是O(1)的。
删除和插入一样的问题,如果删除数组末尾的元素,时间复杂度也是O(1)的,但如果删除的是数组中第一个位置的元素,那也需要把之后的每一个元素都往前挪一位,但实际上在某些场景中,删除也可以稍加改动,比如每次删除时并不真正的删除,只有先做一个标记位,待到真正空间不足时,再一次性删除,这样就减少了每次删除都需要移动数据的问题。
链表
这里有个误区,每当问到链表和数组的区别时,就有人会说链表的插入、删除快,数组的插入、删除慢,这种说法是不严谨的,链表只有在头节点或者尾节点插入、删除时可以实现O(1)的时间复杂度,如果是在某个指定的位置插入、删除时,就不一样了,链表必须从头或者尾开始挨个遍历,直到找到目标值,而这个查找的过程时间复杂度是O(n)的。
实际场景中需要注意的地方
- 数组虽然简单,但对于内存有连续性的要求,如果一次性申请太大的数组空间,可能由于连续性空间不足,导致需要进行额外的内存碎片处理(甚至因为始终找不到一块连续的空间,而提示内存不足),而链表则没有这个问题。
- 数组是一种固定大小的存储结构,一旦申请大小就固定了,如果大小不够,就需要重新申请更大的空间,然后自己把原来的数据拷贝过去,缩小也是同样的道理,但对于链表来说则没有这个问题,链表天然地支持动态扩容与缩容。
- 链表中每个节点都需要额外的记录指向下一个节点的指针(双向链表还需要记录指向前一个节点的指针),所以会额外消耗这部分内存空间,而数组则没有这个方面的消耗。
- 由于链表每个节点在内存中没有连续性的要求,所以如果频繁地对链表进行插入、删除则有可能造成内存碎片严重。
- 由于数组内存连续性的特点,可以充分利用pagecache的预读性,实现更高效的访问。
2.7 异常
异常处理应该算是一种我们非常熟悉的话题了,Java中对于异常的处理也非常便捷、灵活,但往往越是简单的东西,越容易忽视它,恰巧异常也存在很多容易忽视的陷阱,一起来看看吧!
2.7.1 异常类型
Throwable
Throwable是所有异常的错误的父类,printStackTrace()
方法就是由Throwable提供的。
Error
Error表示程序遇到了无法处理的问题,出现了严重的错误,常见的比如:OutOfMemoryError,StackOverflowError
Exception
程序本身可以处理的异常,Exception类本身又分为两类:运行时异常和编译时异常。
运行时异常
RuntimeException
类及其子类产生的异常,编译时不会进行检查,只有在程序运行时才会产生,也可以通过try-catch
来进行处理,但通常不需要我们这样做,因为运行时异常一般都是我们代码本身编写存在问题,应该在处理逻辑上进行修正。 常见的有:NullPointerException,ArrayIndexOutBoundException,ClassCastException
编译时异常
Exception
下除了RuntimeException
类型的其他异常都是编译时异常,这类异常在编译时就会进行检查,并强制要求对其进行处理,否则无法通过编译。 常见的有:ClassNotFoundException、IOException
2.7.2 异常使用的误区
忽视异常
绝大多数情况下都不应该像如下这样忽视异常的存在,因为这样会让你无法发现问题。
java
try{
doSomething();
}catch(Exception e){
// 什么也不做
}
当然也有例外
如果选择了忽略异常,那么最好在catch中通过注释的方式给出原因,并且变量名使用ignored
java
try {
} catch (ParseException ignored) {
}
标准化异常
统一语言、统一认知一直是我们强调的,让异常标准化也算其实现手段之一,得益于标准化的好处,当你看到如下这些异常时,会感到非常的熟悉:NullPointerException、IllegalArgumentException、IllegalStateException、ClassCastException、IllegalFormatConversionException、IndexOutOfBoundsException
如果没有这些标准化的异常分类,实际上所有的异常都可以归为IllegalStateException
(非法状态)或者IllegalArgumentException
(非法参数)。
比如:TreeMap中的Key不允许为null,HashTable中的value不允许为null
以上两个案例,实际上都可以按照IllegalArgumentException
(非法参数)来处理,但是作者并没有这样做,IndexOutOfBoundsException
异常也一样,并没有用IllegalArgumentException
来替代。
常见的一些标准异常:
java
IllegalArgumentException
IndexOutOfBoundsException
NullPointerException
ClassCastException
IllegalFormatConversionException
UnsupportedOperationException
正确的使用异常
一种基于异常的循环控制,这种做法的原因是有人认为JVM底层就是这样终止的。
java
List<User> userList = new ArrayList<>();
userList.add(new User("a"));
userList.add(new User("b"));
userList.add(new User("c"));
userList.add(new User("d"));
try {
int i = 0;
while (true) {
User user = userList.get(i++);
System.out.println(user.getName());
}
} catch (IndexOutOfBoundsException e) {
// 什么也不做
}
比如,正常你应该会写成像下面这样,那JVM又是怎么判断数据边界的呢?
java
for (User user : userList) {
System.out.println(user.getName());
}
为了省去每次的边界检查,所以采用异常捕获的方式,这明显是错误的,实际上测试对比后,后者比前者快很多,原因主要在于以下两点:
- 写在try-catch中的代码,JVM一般不会对其进行优化。
- 而数组的遍历,经过JVM优化后不会造成多余的边界检查。
基于上述这个案例,也告诫我们在做设计时,不要企图让你的调用者通过异常控制的方式来完成正常的流程。
再来看一个案例
java
Iterator<User> iterator = userList.iterator();
while(iterator.hasNext()){
User user = iterator.next();
}
假如Iterator没有提供hasNext
方法,那可能你只能通过try-catch
的方式来解决了。
java
Iterator<User> iterator = userList.iterator();
try{
while(true){
User user = iterator.next();
}
} catch(NoSuchElementException e){
}
让异常保持原子性
这条原则的含义是指,当调用某行代码产生异常时,应该使当前对象仍能保持在异常前的数据状态。
通常有下面几种方式:
让异常前置
举一个list集合移除元素的例子,其中rangeCheck
方法中对当前集合的size做了检查,如果index >= size
则抛出异常
java
public E remove(int index) {
rangeCheck(index);
modCount++;
E oldValue = elementData(index);
int numMoved = size - index - 1;
if (numMoved > 0)
System.arraycopy(elementData, index+1, elementData, index,
numMoved);
elementData[--size] = null; // clear to let GC do its work
return oldValue;
}
private void rangeCheck(int index) {
if (index >= size)
throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}
其实通过这个简单的rangeCheck
方法就能让异常保持原子性,因为它使得modCount
在修改之前就已经抛出了异常,假设你没有提前做rangeCheck
检查,那么你在调用E oldValue = elementData(index)
这一行时,仍然会遇到IndexOutOfBoundsException
异常,但modCount
状态却已经被修改了,你不得不再去维护它的状态。
不可变对象
很多场景中不可变对象总是安全的,异常也不例外。
临时拷贝
如果你每次操作的都是新拷贝出来的对象,那么即使失败了,也并没有对原数据产生影响。 补偿 通过手动补偿的方式来保证失败后状态的正确性,就有点像如何解决分布式事务的问题,在遇到失败后,主动调用一段事先准备好的回滚逻辑,使数据回到失败前的状态。
2.7.3 其他建议
- 处于事务中的流程,如果
catch
了异常,要注意事务的回滚。 - 尽量避免在循环体中
try-catch
异常。 - 不要用异常来控制流程
- 使用
try-with-resources
替代try-catch-finally
2.8 事务
2.8.1 事务的传播
java
REQUIRED:支持当前事务,如果当前不存在则新开启一个事务(默认配置)
SUPPORTS:支持当前事务,如果当前不存在事务则以非事务方式执行
MANDATORY:支持当前事务,如果当前不存在事务则抛出异常
REQUIRES_NEW:创建一个新事务,如果当前已存在事务则挂起当前事务
NOT_SUPPORTED:以非事务方式执行,如果当前已存在事务则挂起当前事务
NEVER:以非事务方式执行,如果当前已存在事务则抛出异常
NESTED:如果当前存在事务,则在嵌套事务中执行,否则开启一个新事务
REQUIRED
当调用T1Service中的func
方法时,除了更新t1表数据外,还会调用t2Service的func
方法,更新t2表。
java
@Service
public class T1Service {
@Resource
private TestMapper testMapper;
@Resource
private T2Service t2Service;
@Transactional
public void func() {
testMapper.updateT1();
t2Service.func();
}
}
@Service
public class T2Service {
@Resource
private TestMapper testMapper;
@Transactional
public void func() {
testMapper.updateT2();
int i = 1 / 0;
}
}
@Transactional
默认的传播方式就是REQUIRED,所以当方法执行到int i = 1 / 0
时,会抛出异常,t1、t2表中的数据都不会被修改。
SUPPORTS
t2Service的func
方法现在没有事务了,t2Service的func
方法配置上@Transactional(propagation = Propagation.SUPPORTS)
,当执行int i = 1 / 0
时,t1、t2两张表数据都不会回滚,但如果配置成@Transactional(propagation = Propagation.REQUIRED)
,则t2表数据会被回滚。
java
@Service
public class T1Service {
@Resource
private TestMapper testMapper;
@Resource
private T2Service t2Service;
// @Transactional
public void func() {
testMapper.updateT1();
t2Service.func();
}
}
@Service
public class T2Service {
@Resource
private TestMapper testMapper;
/**
* 数据不会回滚,因为当前没有事务,SUPPORTS会以非事务方式执行
*/
@Transactional(propagation = Propagation.SUPPORTS)
public void func() {
testMapper.updateT2();
int i = 1 / 0;
}
}
MANDATORY
当t1Service没有事务时,把t2Service的func
方法,配置为@Transactional(propagation = Propagation.MANDATORY)
java
// t1Service
public void func() {
testMapper.updateT1();
t2Service.func();
}
// t2Service
@Transactional(propagation = Propagation.MANDATORY)
public void func() {
testMapper.updateT2();
int i = 1 / 0;
}
抛出异常
java
org.springframework.transaction.IllegalTransactionStateException: No existing transaction found for transaction marked with propagation 'mandatory'
at org.springframework.transaction.support.AbstractPlatformTransactionManager.getTransaction(AbstractPlatformTransactionManager.java:362) ~[spring-tx-5.3.14.jar:5.3.14]
at org.springframework.transaction.interceptor.TransactionAspectSupport.createTransactionIfNecessary(TransactionAspectSupport.java:595) ~[spring-tx-5.3.14.jar:5.3.14]
at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:382) ~[spring-tx-5.3.14.jar:5.3.14]
at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119) ~[spring-tx-5.3.14.jar:5.3.14]
at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186) ~[spring-aop-5.3.14.jar:5.3.14]
at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:753) ~[spring-aop-5.3.14.jar:5.3.14]
at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:698) ~[spring-aop-5.3.14.jar:5.3.14]
REQUIRES_NEW
毫无疑问,t2的数据不会被更新,当没有事务时,REQUIRES_NEW会自己创建一个事务
java
// t1Service
public void func() {
testMapper.updateT1();
t2Service.func();
}
// t2Service
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void func() {
testMapper.updateT2();
int i = 1 / 0;
}
与REQUIRED有什么区别呢? 现在把抛出异常的地方放到t1Service中
java
// t1Service
@Transactional
public void func() {
testMapper.updateT1();
t2Service.func();
int i = 1 / 0;
}
// t2Service
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void func() {
testMapper.updateT2();
}
此时执行后,t2的数据不会回滚,t1的数据会回滚,因为t2和t1不是一个事务。
NOT_SUPPORTED
NOT_SUPPORTED的效果就是无论异常是在t1Service还是t2Service
都不会回滚t2的数据。
java
// t1Service
@Transactional
public void func() {
testMapper.updateT1();
t2Service.func();
int i = 1 / 0;
}
// t2Service
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void func() {
testMapper.updateT2();
int i = 1 / 0;
}
NEVER
很明显,如果存在事务,直接抛出异常
java
// t1Service
@Transactional
public void func() {
testMapper.updateT1();
t2Service.func();
}
// t2Service
@Transactional(propagation = Propagation.NEVER)
public void func() {
testMapper.updateT2();
}
java
org.springframework.transaction.IllegalTransactionStateException: Existing transaction found for transaction marked with propagation 'never'
at org.springframework.transaction.support.AbstractPlatformTransactionManager.handleExistingTransaction(AbstractPlatformTransactionManager.java:413) ~[spring-tx-5.3.14.jar:5.3.14]
at org.springframework.transaction.support.AbstractPlatformTransactionManager.getTransaction(AbstractPlatformTransactionManager.java:352) ~[spring-tx-5.3.14.jar:5.3.14]
at org.springframework.transaction.interceptor.TransactionAspectSupport.createTransactionIfNecessary(TransactionAspectSupport.java:595) ~[spring-tx-5.3.14.jar:5.3.14]
at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:382) ~[spring-tx-5.3.14.jar:5.3.14]
at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119) ~[spring-tx-5.3.14.jar:5.3.14]
at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186) ~[spring-aop-5.3.14.jar:5.3.14]
at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:753) ~[spring-aop-5.3.14.jar:5.3.14]
at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:698) ~[spring-aop-5.3.14.jar:5.3.14]
如果把t1Service
中的事务去掉,则没问题,但t2Service
抛出异常后,也不会回滚
java
// t1Service
public void func() {
testMapper.updateT1();
t2Service.func();
}
// t2Service
@Transactional(propagation = Propagation.NEVER)
public void func() {
testMapper.updateT2();
int i = 1 / 0;
}
NESTED
NESTED应该是几种事务传播方式中最难理解的,如果不注意,NESTED和REQUIRED功能看起来则差不多,都可以理解为有事务则加入,没有则新启一个,但实际上NESTED比REQUIRED要更加灵活。 先来看第一个案例,在t1Service中调用t2Service时
,对异常进行了捕获,并且也没有抛出。
java
// t1Service
@Transactional
public void func() {
testMapper.updateT1();
try {
t2Service.func();
} catch (Exception e) {
e.printStackTrace();
}
}
// t2Service
@Transactional(propagation = Propagation.REQUIRED)
public void func() {
testMapper.updateT2();
int i = 1 / 0;
}
当t2Service
配置为REQUIRED时,t1、t2都进行了回滚,因为是同一个事务,但如果t2Service
配置为NESTED就不一样了,此时t1则不会回滚。
java
// t1Service
@Transactional
public void func() {
testMapper.updateT1();
try {
t2Service.func();
} catch (Exception e) {
e.printStackTrace();
}
}
// t2Service
@Transactional(propagation = Propagation.NESTED)
public void func() {
testMapper.updateT2();
int i = 1 / 0;
}
特别说明
NESTED和REQUIRES_NEW的区别
现在有人可能觉得NESTED和REQUIRES_NEW有点相似,但实际上要注意NESTED和REQUIRES_NEW是很大的区别的。
现在我们分别给t1Service和t2Service
加上一个TransactionSynchronizationManager.getCurrentTransactionName()
输出看看效果。
java
// t1Service
@Transactional
public void func() {
testMapper.updateT1();
System.out.println(TransactionSynchronizationManager.getCurrentTransactionName());
try {
t2Service.func();
} catch (Exception e) {
e.printStackTrace();
}
}
// t2Service
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void func() {
testMapper.updateT2();
System.out.println(TransactionSynchronizationManager.getCurrentTransactionName());
int i = 1 / 0;
}
java
输出结果
com.demo.transaction.service.T1Service.func
com.demo.transaction.service.T2Service.func
把REQUIRES_NEW替换为NESTED,可以看出使用NESTED后,实际上还是同一个事务。
java
com.demo.transaction.service.T1Service.func
com.demo.transaction.service.T1Service.func
NESTED实现方式
这就是NESTED不同之处,两个方法同一个事务,居然没有一起回滚,这就叫嵌套事务,子事务回滚不会影响到主事务,实际上利用的是savepoint
功能,就好像下面这样
java
-- 主事务
savepoint;
-- 执行主事务代码
-- 子事务
savepoint;
-- 执行子事务代码
-- 子事务提交
commit;
-- 执行主事务代码
-- 主事务提交
commit;
所以,如果是在主事务中抛出异常,那么子事务也会被回滚,就像下面这样。
java
// t1Service
@Transactional
public void func() {
testMapper.updateT1();
t2Service.func();
int i = 1 / 0;
}
// t2Service
@Transactional(propagation = Propagation.NESTED)
public void func() {
testMapper.updateT2();
}
2.8.2 事务应用
通过Spring来开启事务管理非常简单,默认支持两种方式,一种为编程式事务、一种为声明式事务,大多数人对声明式事务都比较熟悉,我们就先从它开始说起。 声明式事务 开启声明式事务非常简单,直接通过@Transactional
注解即可
java
@Transactional
public void func() {
}
除了常规的支持自定义事务的隔离级别、传播属性之外,还可以设置事务的超时时间,回滚的异常类型。
java
public @interface Transactional {
@AliasFor("transactionManager")
String value() default "";
@AliasFor("value")
String transactionManager() default "";
String[] label() default {};
Propagation propagation() default Propagation.REQUIRED;
Isolation isolation() default Isolation.DEFAULT;
int timeout() default TransactionDefinition.TIMEOUT_DEFAULT;
String timeoutString() default "";
boolean readOnly() default false;
Class<? extends Throwable>[] rollbackFor() default {};
String[] rollbackForClassName() default {};
Class<? extends Throwable>[] noRollbackFor() default {};
String[] noRollbackForClassName() default {};
}
编程式事务
声明式事务的最大问题就在于,粒度控制问题,声明式事务最细的粒度也是方法级别的,这很容易导致长事务问题的产生,所以我们一般使用编程式事务替代。 Spring提供的TransactionTemplate、PlatformTransactionManager
都支持编程式事务的实现,TransactionTemplate
是在原始的事务管理类上又封装了一次,调用其核心方法execute
实现整个事务的管理。
java
@Override
@Nullable
public <T> T execute(TransactionCallback<T> action) throws TransactionException {
Assert.state(this.transactionManager != null, "No PlatformTransactionManager set");
if (this.transactionManager instanceof CallbackPreferringPlatformTransactionManager) {
return ((CallbackPreferringPlatformTransactionManager) this.transactionManager).execute(this, action);
}
else {
TransactionStatus status = this.transactionManager.getTransaction(this);
T result;
try {
result = action.doInTransaction(status);
}
catch (RuntimeException | Error ex) {
// Transactional code threw application exception -> rollback
rollbackOnException(status, ex);
throw ex;
}
catch (Throwable ex) {
// Transactional code threw unexpected exception -> rollback
rollbackOnException(status, ex);
throw new UndeclaredThrowableException(ex, "TransactionCallback threw undeclared checked exception");
}
this.transactionManager.commit(status);
return result;
}
}
PlatformTransactionManager
则更灵活一点,就定义了三个关键方法,一看就明白了
java
public interface PlatformTransactionManager extends TransactionManager {
TransactionStatus getTransaction(@Nullable TransactionDefinition definition)
throws TransactionException;
void commit(TransactionStatus status) throws TransactionException;
void rollback(TransactionStatus status) throws TransactionException;
}
TransactionTemplate演示
execute中入参为TransactionCallback
,这是一个函数式接口,只定义了一个方法doInTransaction
, 其可以传入TransactionCallbackWithoutResult
不带返回参数的。
java
@Resource
private TransactionTemplate transactionTemplate;
public void func() {
transactionTemplate.execute(new TransactionCallbackWithoutResult() {
@Override
protected void doInTransactionWithoutResult(TransactionStatus status) {
testMapper.updateT1();
t2Service.func();
int i = 1 / 0;
}
});
}
或者直接传入TransactionCallback
带返回参数的也可以。
PlatformTransactionManager演示
java
@Resource
private PlatformTransactionManager platformTransactionManager;
public void func() {
TransactionStatus status = platformTransactionManager.getTransaction(new DefaultTransactionDefinition());
try {
testMapper.updateT1();
t2Service.func();
int i = 1 / 0;
platformTransactionManager.commit(status);
} catch (Exception e) {
e.printStackTrace();
platformTransactionManager.rollback(status);
}
}
事务使用的注意事项
滥用@Transactional
不过,也因为它的简单灵活,而经常导致被滥用的情况发生。 千万不要直接把注解加在Service上,这会导致整个Service中的方法,只要调用到数据库,就都会被进行事务管理,从而影响数据库和Web服务的QPS。
java
@Service
@Transactional // 不要加在Service上
public class DemoService {
}
长事务、过早起开事务
简单来说,就是在整个方法的生命周期内,真正需要事务管理的方法可能只占用了200毫秒,而其他业务流程占用了2秒,但是由于事务是对整个方法生效,从而导致一个数据库连接被占用2秒多。
java
@Transactional
public void func() {
// 两个select花费了2秒
select1();
select2();
// 两个save只花费了200毫秒
save1();
save2();
}
解决方式也很简单,把长事务拆分为短事务即可。
java
public void func() {
select1();
select2();
manager.save();
}
@Transactional
public void save() {
save1();
save2();
}
锁的粒度
要想开启事务,就要先持有锁,因此锁的范围就很重要,InnoDB之所以能够取代MyISAM,不单单只是因为InnoDB支持事务,更重要的是因为它还支持行级锁,这在高并发的业务场景中,是非常关键的。 所以,我们平时在写代码时,一定要注意避免表级锁的产生。
数据库死锁
看到此类的异常,不用多想,基本上就是并发事务导致的,一个事务还未结束,另一个事务想再获取锁时就会遇到这个问题。
java
Deadlock found when trying to get lock; try restarting transaction
解决的方式就前面提到的两点:
- 避免长事务。
- 缩小锁的粒度。 当然,如果并发真的高到,单行更新时也存在冲突,那就只能通过锁,或者改批量同步的方式了。
2.8.3 事务的失效场景
异常未抛出
被捕获的异常一定要抛出,否则是不会回滚的。
java
// t1Service
@Transactional
public void func() {
try {
testMapper.updateT1();
t2Service.func();
int i = 1 / 0;
} catch (Exception e) {
// 异常捕获了,未抛出,导致异常事务不会回滚。
e.printStackTrace();
}
}
// t2Service
@Transactional
public void func() {
testMapper.updateT2();
}
异常与rollback不匹配
@Transactional
默认情况下,只会回滚RuntimeException和Error
及其子类的异常,如果是受检异常或者其他业务类异常是不会回滚事务的。
java
@Transactional
public void func() throws Exception {
try {
testMapper.updateT1();
t2Service.func();
int i = 1 / 0;
} catch (Exception e) {
// 默认情况下非运行时异常不会回滚
throw new Exception();
}
}
修改方式也很简单,@Transactional
支持通过rollbackFor
指定回滚异常类型
java
// 改成rollbackFor = Exception.class即可
@Transactional(rollbackFor = Exception.class)
public void func() throws Exception {
try {
testMapper.updateT1();
t2Service.func();
int i = 1 / 0;
} catch (Exception e) {
throw new Exception();
}
}
方法内部直接调用
func2方法是由func调用,虽然func2方法上加了@Transactional
注解,但事务不会生效,testMapper.updateT2()
执行的方法并不会回滚
java
public void func() {
testMapper.updateT1();
func2();
}
@Transactional
public void func2() {
testMapper.updateT2();
int i = 1 / 0;
}
修改方式也很简单,通过注入的方式调用即可
java
@Service
public class T1Service {
@Resource
private TestMapper testMapper;
// 注入T1Service对象
@Resource
private T1Service t1Service;
public void func() {
testMapper.updateT1();
// 通过注入的方式调用自身的方法
t1Service.func2();
}
@Transactional
public void func2() {
testMapper.updateT2();
int i = 1 / 0;
}
}
小插曲,SpringBoot 2.6.0版本开发,默认禁止循环依赖,所以如果你使用的版本是2.6.0之后的,那么启动会遇到如下报错 As a last resort, it may be possible to break the cycle automatically by setting spring.main.allow-circular-references to true. 修改方式:在配置文件中把允许循环依赖打开即可。 spring.main.allow-circular-references=true
当然,你也可以直接使用AopContext
的方式
java
public void func() {
testMapper.updateT1();
T1Service t1Service = (T1Service) AopContext.currentProxy();
t1Service.func2();
}
@Transactional
public void func2() {
testMapper.updateT2();
int i = 1 / 0;
}
在另一个线程中使用事务
Spring事务管理的方式就是通过ThreadLocal
把数据库连接与当前线程绑定,如果新开启一个线程自然就不是一个数据库连接了,自然也就不是一个事务。
t2Service.func()
方法操作的数据并不会被回滚
java
@Transactional
public void func() {
testMapper.updateT1();
new Thread(() -> t2Service.func()).start();
int i = 1 / 0;
}
注解作用到private级别的方法上
当你写成如下这样时,IDEA直接会给出提示Methods annotated with '@Transactional' must be overridable
原因很简单,private修饰的方式,spring无法为其生成代理。
java
public void func() {
t1Service.func2();
}
@Transactional
private void func2() {
testMapper.updateT1();
int i = 1 / 0;
}
final类型的方法
这个与private
道理是一样的,都是影响了Spring生成代理对象,同样IDEA也会有相关提示。
数据库存储引擎不支持事务
注意,如果你使用的是MySQL数据库,那么常用的存储引擎中只有InnoDB才支持事务,像MyISAM是不支持事务的,其他存储引擎都是针对特定场景下使用的,一般也不会用到,不做讨论。
事务的传播类型
前面已经对事务的传播类型做过介绍了,有的传播类型会以非事务方式执行,有的传播则会新开启一个事务,这些都需要额外注意。
java
REQUIRED:支持当前事务,如果当前不存在则新开启一个事务(默认配置)
SUPPORTS:支持当前事务,如果当前不存在事务则以非事务方式执行
MANDATORY:支持当前事务,如果当前不存在事务则抛出异常
REQUIRES_NEW:创建一个新事务,如果当前已存在事务则挂起当前事务
NOT_SUPPORTED:以非事务方式执行,如果当前已存在事务则挂起当前事务
NEVER:以非事务方式执行,如果当前已存在事务则抛出异常
NESTED:如果当前存在事务,则在嵌套事务中执行,否则开启一个新事务
2.9 注释
程序员最讨厌的四件事:写注释、写文档、别人不写注释、别人不写文档,虽然是个段子,但也侧面反映出写注释的问题确实存在。
2.9.1 到底该不该写注释
在【代码整洁之道】
一书中有个理念就是,注释是为了弥补代码表达能力不足的一种不得已的做法。如果代码能表达清楚,那就没必要写注释,作者甚至认为只要你写了注释,就说明你的代码写得不够好,我觉得作者应该是为了让你能够更加努力地写出好的代码,而不是试图用注释来弥补你的烂代码,这本身和写注释是不冲突的,很明显,如果代码已经非常糟糕了,那么请直接重构它,而不是企图用注释去弥补。
2.9.2 言简意赅
注释一定是对代码逻辑的概括,不需要长篇大论地逐行说明,表明你的意图即可。
2.9.3 准确性
我想你一定遇到过,注释的描述与实际代码执行的逻辑不相符的情况,这真的是非常让你头疼,之所以会出现这种情况,大多数是因为由于需求的迭代,代码逻辑修改后,注释没跟着一起修改,所以,修改代码逻辑时,请一定要注意注释是否也需要一起修改。
三、设计原则
3.1 单一职责
一个类或者模块只负责完成一个职责,说明白点就是方法的功能要单一,上升到微服务就是业务领域的能力要单一。 单一职责概念上很简单,但要真正地去应用却不容易,如何判断一个职责是否单一本身就没有统一的标准,每个人处在不同的认知层次,那么对于单一职责的理解也就不一样,甚至有的时候,只要作用于不同业务场景下,单一职责的范围也会发生变化。
参考可能破坏单一职责的情况:
- 一个类或者一个函数的代码行数过多。
- 一个类或者一个函数依赖其他的地方过多。
- 有多个动机,但修改却是同一个类。
JDBC大家都了解,一般通过JDBC连接并操作数据库都有几步标准的流程
java
1、Connection负责与数据库的交互。
2、Statement负责执行SQL、返回结果。
3、ResultSet负责结果集处理。
上面的业务流程不经意间就可能被一气呵成地写完,我想大多数人平时在写业务代码时一定也是这样,想想JDBC这样设计的好处吧!
3.2 开闭原则
软件实体(模块、类、函数等等)应该是对扩展开放、对修改关闭的,大部分的设计模式都是为了解决代码扩展性的问题,遵从的就是开闭原则,所谓对扩展开放、对修改关闭指的就是当要添加一个新功能时,应该在已有代码的基础上添加新的类或者方法,而不是修改原来的类或者方法。
扩展开放:意味着当有需求变更时,可以对模块、类、函数等进行扩展,使其满足需求。 修改关闭:意味着当要对模块、类、函数等进行扩展时,不需要修改源代码;对于已经完成的类文件不需要重新编辑;对于已经编译打包好的模块,不需要再重新编译。
唯一不变的就是变化,我们应该在设计时让代码尽量能够适应变化,因此要求我们要有良好的抽象意识、封装意识,真正地面向对象开发,而不是面向过程,将可变的部分封装起来,隔离变化,对外提供抽象不变的接口,真正体会到多态的特性所带来的好处,真正做到面向接口编程。
java
// 接口
public interface MQService{}
// 实现类
public class KafkaMQService implements MQService{}
// 实现类
public class RocketMQService implements MQService{}
// 接口
public interface CacheService{}
// 实现类
public class RedisService implements CacheService{}
// 实现类
public class MemcachedService implements CacheService{}
3.3 里式替换原则
子类对象可以替换父类对象,并不影响原来的业务逻辑。里式替换原则强调的是子类无差异替换父类,应当按照协议来设计,父类定义标准协议,子类改变的是内部实现逻辑,但不改变协议本身的约定。
java
class A{
public void sendMessage(String message){
mqService.send(message);
}
}
class B extends A{
@Override
public void sendMessage(String message){
if(message.length() > 200){
// 压缩
byte[] msgBytes = compress(message);
mqService.send(msgBytes);
return;
}
mqService.send(message);
}
}
class Demo{
public void method(A a){
a.sendMessage("abc");
}
}
class Main{
public static void main(String[] args) {
Demo demo = new Demo();
demo.method(new A());
// 子类替换父类
demo.method(new B());
}
}
这样看起来,就是利用了多态的特性,但多态是面向对象语言的一大特性,而里式替换则是一种设计原则,比如:如果B类改成如下这样,就不符合里式替换设计原则了。
java
class B extends A{
@Override
public void sendMessage(String message){
if(message.length() > 200){
// 丢弃
return;
}
mqService.send(message);
}
}
class B extends A{
@Override
public void sendMessage(String message){
if(message.length() > 200){
// 压缩
try{
byte[] msgBytes = compress(message);
mqService.send(msgBytes);
return;
}catch(Exception e){
throw new RuntimeException("...");
}
}
mqService.send(message);
}
}
第一种不符合是因为修改了业务逻辑,第二种不符合是因为违反了父类协议。
建议:如果子类不能直接替换掉父类,那么建议通过依赖、组合等方式替代。
3.4 接口隔离原则
调用者不应该依赖于他不需要的接口,这条原则主要是让我们注意接口的设计,避免大而全的接口,接口的职责划分应该明确,如果调用者每次只使用部分接口、那很有可能这个接口的设计就不太合理。
java
public interface UserService{
User getUserById(String userId);
}
public interface LoginService{
boolean login(User user);
}
// 不应该用一个实现类实现两个接口
public class UserLoginServiceImpl implements UserService, LoginService{
// ...
}
注意:接口隔离原则告诫我们要避免大而全的接口,尽量细化接口的功能,以此提供代码的灵活性,但也要注意不要过于细化,导致接口数量过多,理解高内聚、低耦合的意义,只专注为一个模块提供应有的功能(高内聚),暴露尽可能少的方法(低耦合)。
3.5 依赖倒置原则
高层模块不依赖于底层模块,两者应该通过抽象来互相依赖,抽象不要依赖于具体实现,具体实现要依赖于抽象。 所谓高层模块不依赖底层模块指的就是,调用者不依赖于被调用者,就好像JDBC定义的规范一样,各个数据库厂商在开发设计时和具体的业务逻辑并没有任何依赖关系,而是依赖一种抽象,这个抽象实际上就是JDBC规范,JDBC规范不依赖于具体实现。
Servlet规范定义了Servlet接口
java
public interface Servlet {
public void init(ServletConfig config) throws ServletException;
public ServletConfig getServletConfig();
public void service(ServletRequest req, ServletResponse res)
throws ServletException, IOException;
public String getServletInfo();
public void destroy();
}
虽然定义了接口,但并没有让各个Web Server直接去实现,而是又抽象了HttpServlet
类,现在Web Server直接依赖HttpServlet
类进行扩展。
- Servlet:高层模块
- HttpServlet:抽象
- SpringMVC(DispatcherServlet)、undertow(DefaultServlet):底层模块
3.6 迪米特法则
迪米特法则又叫最少知识原则,该原则的目的是为了减少代码之间的耦合度,减少类与类之间的依赖,减少细节的暴露,使得功能更加的独立。 该原则主要包含如下三个方面:
- 每个单元对其他单元只拥有有限的知识,而且这些单元是与当前单元有紧密联系的.
- 每个单元只能与其朋友交谈,不与陌生人交谈.
- 只与自己最直接的朋友交谈.
3.7 DRY原则
因为描述为:Don't Repeat Yourself
,在编程中可以理解为不要写重复的代码,比如同样的业务需求,存在多处不同的实现逻辑,这就会造成代码的阅读困难、维护困难。
比如:对于list集合去重,实现方式有很多。
通过Set特性实现
java
public static <T> List<T> distinct(List<T> list) {
return new ArrayList<>(new LinkedHashSet<>(list));
}
通过Java8提供的stream特性实现
java
public static <T> List<T> distinct(List<T> list) {
return list.stream().distinct().collect(Collectors.toList());
}
在没有明显的性能差异时,使用哪种方式都可以,但请选择其中一种,不要采用多种方法实现同样的业务逻辑,例子比较简单,你可能还感触不深,试想,如果这是一段复杂的业务逻辑,但却有多种不同的实现方式,肯定会让阅读的人感到很诧异,他肯定会想,这几种实现方式到底有什么不同呢?如果要修改业务逻辑,到底需要改几处呢?还有没有其他被遗漏的地方?
3.8 KISS原则
KISS 是英文 Keep it Simple and Stupid
的缩写,意思就是保持简单和愚蠢,这个原则在很多行业都适用,比如产品设计要保持简单,企业管理要保持简单,化繁为简。 实践在编码中,就是要保持代码的可读性和可维护性,让代码容易读懂,容易修改。
何为简单?
- 尽量不要使用一些冷门的、奇淫技巧的方式来实现你的业务逻辑。(类似:某种高级语法、正则表达式等)
- 善于使用已有的工具,不需要重复造轮子。
- 不要过度优化,比如用位运算替代算术方法,简单的业务逻辑,还要使用设计模式。
总结
设计原则是一块比较难以理解与统一的编程规范,因为它不像其他规范一样,都有明确的准则、模板、公式等,设计原则更多的是理解,不同的人理解可能不一样,一段代码,有些人可能认为已经符合设计原则了,有些人则认为不符合,有些人认为封装的合适,有些人则认为是过度封装,所以对于设计原则更重要的应该是保持团队中的认知统一,让团队中的成员保持统一的衡量尺度,不断地提高团队的整体认知水平,这样才能充分发挥出设计原则的作用。
四、设计模式
留白,相关的文章已经够多了。
五、日常踩坑
5.1 避免不必要的对象创建
为了减少创建对象带来的消耗,无论是时间上还是空间上,只要创建对象的成本很高,就应该思考如何能够避免这样的事情,下面我们来看看常见的做法都有哪些?
5.1.1 不可变的对象
最为典型的案例就是String,我想应该不会有人去通过new的方式再去构建一个String字符串了吧!
java
String str = new String("abc");
String str = "abc";
上面两种方式的区别不用多说,String被设计为不可变对象的好处就在于,在整个JVM内存中,只要遇到相同的字符串字面常量都可以被重用。 String对象的创建成本虽然不高,但却是被频繁使用最多的一种对象了。
5.1.2 静态方法
使用静态对象、静态工厂等方式可以避免重复创建对象。
静态对象
java
Boolean.valueOf("true");
public static Boolean valueOf(String s) {
return parseBoolean(s) ? TRUE : FALSE;
}
public static final Boolean TRUE = new Boolean(true);
public static final Boolean FALSE = new Boolean(false);
静态工厂(单例模式)
java
public class StaticSingleton {
private static class StaticHolder {
public static final StaticSingleton INSTANCE = new StaticSingleton();
}
public static StaticSingleton getInstance() {
return StaticHolder.INSTANCE;
}
}
枚举
java
public enum EnumSingleton {
INSTANCE;
}
5.1.3 视图
视图是返回引用的一种方式。
map的keySet
方法,实际上每次返回的都是同一个对象的引用。
java
map.keySet()
public Set<K> keySet() {
Set<K> ks = keySet;
if (ks == null) {
ks = new KeySet();
keySet = ks;
}
return ks;
}
当然返回的对象是不能修改的,否则就会报错
java
Set<String> sets = map.k
sets.add("xxx");
Exception in thread "main" java.lang.UnsupportedOperationException
at java.util.AbstractCollection.add(AbstractCollection.java:262)
at com.example.demo.Immutable.main(Immutable.java:22)
java.util包下的Collections类,还有很多类似的方法。
5.1.4 自动装箱
java
// 变量定义为Long在我的机器运用需要5秒多,而定义为long则只需要500毫秒左右。
// long sum = 0l;
Long sum = 0l;
for(long i = 0; i<Integer.MAX_VALUE; i++){
sum += i;
}
5.2 内存泄漏
内存泄漏一般都是存在一些隐蔽的引用行为。
5.2.1 过期引用
下面代码是JDK中提供的Stack类,其中调用元素弹出栈的一段逻辑,看看最后一行,主动的将出栈元素设置为null
,作者也还特别注释了其用以,就是为了能够让gc回收它。
java
public synchronized void removeElementAt(int index) {
modCount++;
if (index >= elementCount) {
throw new ArrayIndexOutOfBoundsException(index + " >= " +
elementCount);
}
else if (index < 0) {
throw new ArrayIndexOutOfBoundsException(index);
}
int j = elementCount - index - 1;
if (j > 0) {
System.arraycopy(elementData, index + 1, elementData, index, j);
}
elementCount--;
elementData[elementCount] = null; /* to let gc do its work */
}
5.2.2 长生命周期引用短生命周期
典型的代表就是ThreadLocal
,一旦Key
被回收后,就无法再访问到,但却一直会有另外一条引用链,而这条引用链的生命周期与线程一致,当线程是从线程池中获取时,它将永存。
5.2.3 静态数据、缓存
静态数据也是需要注意的地方,尤其是集合这样的静态数据,本地缓存有时候也就是静态数据,一般都会为其设计过期策略、缓存容量。
5.3 覆盖equals时总要覆盖hashCode
这是一条Object的规范。
- 在一个java应用程序执行期间,只要对象的equals方法没有被修改,那么对同一个对象的多次调用,hashCode都应该返回同一个值,在一个应用程序与另一个应用程序执行过程中,执行hashCode方法返回的方法可以不一致。
java
Whenever it is invoked on the same object more than once during
an execution of a Java application, the {@code hashCode} method
must consistently return the same integer, provided no information
used in {@code equals} comparisons on the object is modified.
This integer need not remain consistent from one execution of an
application to another execution of the same application.
- 如果两个对象equals方法相等,则hashCode方法返回的结果也必须相等。
java
If two objects are equal according to the {@code equals(Object)}
method, then calling the {@code hashCode} method on each of
the two objects must produce the same integer result.
- 如果两个对象equals方法不相等,则hashCode方法返回的结果不一定不相等,但让不相等的对象产生不相等的hashCode值,有可能提高hashTabel的性能。
java
It is <em>not</em> required that if two objects are unequal
according to the {@link java.lang.Object#equals(java.lang.Object)}
method, then calling the {@code hashCode} method on each of the
two objects must produce distinct integer results. However, the
programmer should be aware that producing distinct integer results
for unequal objects may improve the performance of hash tables.
如果User对象,只重写了equals方法
java
@Data
class User {
private String name;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
User user = (User) o;
return Objects.equals(name, user.name);
}
}
放入HashMap中,看看执行结果。
java
Map<User, String> map = new HashMap<>();
User user1 = new User();
user1.setName("zs");
map.put(user1, "zs");
User user2 = new User();
user2.setName("zs");
System.out.println(user1.equals(user2)); // 执行结果:true
System.out.println(map.get(user1)); // 执行结果:zs
System.out.println(map.get(user2)); // 执行结果:null
当把hashCode方法也重写了
java
@Data
class User {
private String name;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
User user = (User) o;
return Objects.equals(name, user.name);
}
@Override
public int hashCode() {
return Objects.hash(name);
}
}
执行结果正常
java
System.out.println(map.get(user2)); // 执行结果:zs
原因分析
调用key的hashCode方法得到一个int值
java
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
优先根据hash值比较,hash值不同直接就返回null了
java
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
5.4 clone
数据类型
在Java中,数据类型可以分为:基础类型和引用类型,其中当基础类型为全局变量时存储在栈中,为局部变量时存储在堆内存中,无论是在栈中还是在堆中存储的都是具体的值,与之不同的引用类型,则记录的是地址,然后通过引用的方式指向具体的内存区域。 比如在m这个方法中,用到的基础类型a与引用类型User在内存中的存储就如下图所示
java
public void m(){
int a = 128;
User user = new User();
}
赋值语句
在应用程序中对象拷贝一般都可以通过赋值语句来实现,比如像下面这样
java
int a = 128;
int b = a;
可以认为,b拷贝了a 对于基础类型来说,这样是没有问题的,但对于引用类型就有问题了,比如像下面这样
java
User user1 = new User();
User user2 = user1;
无论是user1还是user2,只要有一个属性发生了变化,两个对象就都会改变,这通常不是我们希望看到的结果。 基础类型的赋值,实际上在栈中是两个对象
而引用类型的赋值,实际上只是在引用上做了处理,实际在堆中的对象还是只有一个。
深拷贝和浅拷贝
概念理解
- 浅拷贝:如果是基础类型,则直接拷贝数值,然后赋值给新的对象,如果是引用类型,则只复制引用,并不复制数据本身。
- 深拷贝:如果是基础类型,和浅拷贝一样,如果使用引用类型,则不是只复制引用,还会复制数据本身。 深拷贝
不可变对象
有一类对象比较特殊,它们虽然是引用类型对象,但依然可以保证浅拷贝后,得到的就是你想要的对象,那就是不可变对象。 比如像下面这样,str1和str2两个对象是不会互相影响的。
java
String str1 = "a";
String str2 = str1;
或者是这样的类
java
final class User {
final String name;
final String age;
public User(String name, String age) {
this.name = name;
this.age = age;
}
}
对于不可变的类,就算直接赋值了又能怎么样,反正你也无法再修改它了,所以它是安全的。
java
User u1 = new User("小明", "18");
User u2 = u1;
Cloneable接口
实际上JDK也为我们提供了对象clone的方法,就是实现Cloneable接口,只要实现了这个接口的类就表明该对象具有允许clone的能力,Cloneable接口本身不包含任何方法,它只是决定了Object中受保护的clone方法实现的行为: 如果一个类实现了Cloneable接口,Object的clone方法就返回该对象的拷贝,否则就抛出java.lang.CloneNotSupportedException
异常。
java
@Data
@AllArgsConstructor
@NoArgsConstructor
class User implements Cloneable {
private String name;
private int age;
/**
* 如果没有实现Cloneable接口,调用super.clone()方法就会抛出异常
* @return
* @throws CloneNotSupportedException
*/
@Override
protected Object clone() throws CloneNotSupportedException {
return super.clone();
}
}
如果你认为一个类实现了Cloneable接口,并且调用super.clone()
方法就能够得到你想要的对象,那你就错了,因为super.clone()
方法就和浅拷贝一样,如果克隆的对象中包含可变的引用类型,实际上是存在问题的。 只含有基础类型和不可变类型时
java
public static void main(String[] args) throws CloneNotSupportedException {
User u1 = new User("小明", 18);
User u2 = (User) u1.clone();
u2.setName("小王");
u2.setAge(20);
u1.setName("小红");
u1.setAge(19);
log.info("u1:{}", u1);
log.info("u2:{}", u2);
}
因为User对象只有基础类型int和不可变类型String,所以直接调用spuer.clone()
方法没有问题
java
u1:User(name=小红, age=19)
u2:User(name=小王, age=20)
含有引用类型的问题
现在我们为User对象新增一个Role的属性
java
@Data
@AllArgsConstructor
@NoArgsConstructor
class User implements Cloneable {
private String name;
private int age;
private Role[] roles;
/**
* 如果没有实现Cloneable接口,调用super.clone()方法就会抛出异常
*
* @return
* @throws CloneNotSupportedException
*/
@Override
protected Object clone() throws CloneNotSupportedException {
return super.clone();
}
}
@Data
@AllArgsConstructor
@NoArgsConstructor
class Role{
private String roleName;
}
public static void main(String[] args) throws CloneNotSupportedException {
User u1 = new User();
u1.setName("小明");
u1.setAge(18);
Role[] roles = new Role[2];
roles[0] = new Role("A系统管理员");
roles[1] = new Role("B系统普通员工");
u1.setRoles(roles);
log.info("u1:{}", u1);
User u2 = (User) u1.clone();
u2.setName("小王");
u2.setAge(20);
Role[] roles2 = u2.getRoles();
roles2[0] = new Role("A系统普通员工");
roles2[1] = new Role("B系统管理员");
u2.setRoles(roles2);
log.info("u1:{}", u1);
}
问题出现了,我只修改了克隆出来的u2对象,但是u1对象也没改变了。
java
u1:User(name=小明, age=18, roles=[Role(roleName=A系统管理员), Role(roleName=B系统普通员工)])
u1:User(name=小明, age=18, roles=[Role(roleName=A系统普通员工), Role(roleName=B系统管理员)])
解决引用类型的问题
典型的浅拷贝的问题,那么要解决这个问题也很简单,改成下面这样即可
java
@Data
@AllArgsConstructor
@NoArgsConstructor
class User implements Cloneable {
private String name;
private int age;
private Role[] roles;
/**
* 如果没有实现Cloneable接口,调用super.clone()方法就会抛出异常
*
* @return
* @throws CloneNotSupportedException
*/
@Override
protected Object clone() throws CloneNotSupportedException {
User user = (User) super.clone();
user.roles = roles.clone();
return user;
}
}
此时再执行,结果就正确了。
java
u1:User(name=小明, age=18, roles=[Role(roleName=A系统管理员), Role(roleName=B系统普通员工)])
u1:User(name=小明, age=18, roles=[Role(roleName=A系统管理员), Role(roleName=B系统普通员工)])
问题延伸
实际上在有些的情况下,上面的处理方式还是存在问题,比如像下面这样: 现在对象是HashMap了
java
@Data
@AllArgsConstructor
@NoArgsConstructor
class User implements Cloneable {
private HashMap<String, Role> roleMap;
@Override
protected Object clone() throws CloneNotSupportedException {
User user = (User) super.clone();
user.roleMap = (HashMap<String, Role>) roleMap.clone();
return user;
}
}
public static void main(String[] args) throws CloneNotSupportedException {
User u1 = new User();
HashMap<String, Role> roleMap1 = new HashMap<>();
roleMap1.put("A", new Role("系统管理员"));
u1.setRoleMap(roleMap1);
log.info("u1:{}", u1);
User u2 = (User) u1.clone();
HashMap<String, Role> roleMap2 = u2.getRoleMap();
Role role = roleMap2.get("A");
role.setRoleName("普通员工");
roleMap2.put("A", role);
u2.setRoleMap(roleMap2);
log.info("u1:{}", u1);
}
u1:User(roleMap={A=Role(roleName=系统管理员)})
u1:User(roleMap={A=Role(roleName=普通员工)})
为什么不行呢?因为HashMap提供的克隆方法本身就是浅拷贝。
java
/**
* Returns a shallow copy of this <tt>HashMap</tt> instance: the keys and
* values themselves are not cloned.
*
* @return a shallow copy of this map
*/
@SuppressWarnings("unchecked")
@Override
public Object clone() {
HashMap<K,V> result;
try {
result = (HashMap<K,V>)super.clone();
} catch (CloneNotSupportedException e) {
// this shouldn't happen, since we are Cloneable
throw new InternalError(e);
}
result.reinitialize();
result.putMapEntries(this, false);
return result;
}
最终的解决方式
字节流
你在百度上很容易查询到解决方式,最常见的就是字节流。 比如像下面这样。
java
@Data
@AllArgsConstructor
@NoArgsConstructor
class User implements Serializable {
private HashMap<String, Role> roleMap;
public static <T extends Serializable> T clone(T obj) {
T cloneObj = null;
try {
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
ObjectOutputStream outputStream = new ObjectOutputStream(byteArrayOutputStream);
outputStream.writeObject(obj);
outputStream.close();
ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(byteArrayOutputStream.toByteArray());
ObjectInputStream inputStream = new ObjectInputStream(byteArrayInputStream);
cloneObj = (T) inputStream.readObject();
inputStream.close();
} catch (Exception e) {
e.printStackTrace();
}
return cloneObj;
}
}
@Data
@AllArgsConstructor
@NoArgsConstructor
class Role implements Serializable {
private String roleName;
}
此时再调用就没有问题了。
java
public static void main(String[] args) {
User u1 = new User();
HashMap<String, Role> roleMap1 = new HashMap<>();
roleMap1.put("A", new Role("系统管理员"));
u1.setRoleMap(roleMap1);
log.info("u1:{}", u1);
User u2 = User.clone(u1);
HashMap<String, Role> roleMap2 = u2.getRoleMap();
Role role = roleMap2.get("A");
role.setRoleName("普通员工");
roleMap2.put("A", role);
u2.setRoleMap(roleMap2);
log.info("u1:{}", u1);
}
u1:User(roleMap={A=Role(roleName=系统管理员)})
u1:User(roleMap={A=Role(roleName=系统管理员)})
重新实现
实际上你可以自己实现一套clone方法,给它定义为拷贝工厂,或者使用一些已经实现好的第三方工具类。 比如org.springframework.beans
包下提供的BeanUtils类
java
public static void main(String[] args) {
User u1 = new User();
HashMap<String, Role> roleMap1 = new HashMap<>();
roleMap1.put("A", new Role("系统管理员"));
u1.setRoleMap(roleMap1);
log.info("u1:{}", u1);
User u2 = new User();
// 使用copyProperties方法
BeanUtils.copyProperties(u1,u2);
HashMap<String, Role> roleMap2 = u2.getRoleMap();
Role role = roleMap2.get("A");
role.setRoleName("普通员工");
roleMap2.put("A", role);
u2.setRoleMap(roleMap2);
log.info("u1:{}", u1);
}
Hutool工具包
java
// 命名几乎和spring的一样
BeanUtil.copyProperties(u1, u2);
总结
实际上你应该已经发现了,虽然Object类为我们提供了clone方法,但有时候并不能很好的使用它,可能需要多层级的逐个克隆,甚至如果添加了某个引用对象时,忘了修改clone方法还会带来一些奇怪的问题,也许我们应该永远不去使用它,而是通过其他的方式来替代。
5.5 Null值处理
5.5.1 字符串
很多工具包中都有对字符串空值的处理。
java
// guava包
if(Strings.isNullOrEmpty(str)){
}
// apache.commons.lang包,空格也算空
if(StringUtils.isBlank(str)){
}
// apache.commons.lang,空格不算空
if(StringUtils.isEmpty(str)){
}
guava中还有对Null与Empty的转换
java
// null转换为""
Strings.nullToEmpty(str);
// ""转换为null
Strings.emptyToNull(str);
有些方法的默认对null值的处理也要注意,比如String.valueOf(null)
默认就会返回"null"
字符串。
java
String.valueOf(null); // 结果 "null"
5.5.2 Objects
JDK1.7开始提供的Objects类,处理Object中的一些方法,尤其是对于Null值的处理 equals方法不用再担心null值的问题了。
java
// 可能会遇到java.lang.NullPointerException异常
if(str.equals("abc")){
}
// 完全不用担心
Objects.equals(str,"abc");
// 已经对空值做了处理
public static boolean equals(Object a, Object b) {
return (a == b) || (a != null && a.equals(b));
}
hashCode方法也一样
java
String str = null;
// NullPointerException异常
str.hashCode();
// 返回0
int i = Objects.hashCode(str);
public static int hashCode(Object o) {
return o != null ? o.hashCode() : 0;
}
toString方法
java
A a = null;
// NullPointerException异常
a.toString();
// 返回"null"
Objects.toString(a);
// 还可以指定替代值,返回"abc"
Objects.toString(a, "abc");
各种null值的检查、处理
java
Object obj = null;
// NullPointerException异常
Objects.requireNonNull(obj);
// NullPointerException异常,异常message可以自定义
Objects.requireNonNull(obj,"obj is null");
// 返回true
System.out.println(Objects.isNull(obj));
// 返回false
System.out.println(Objects.nonNull(obj));
5.5.3 集合的处理
Map空Value处理
每次从Map中根据Key值获取Value值时,总要注意Null值的情况。
一种简单的通过Map来计数的功能。
java
Map<String, Integer> map = new HashMap<>();
if (map.containsKey("a")) {
int i = map.get("a");
map.put("a", i + 1);
} else {
map.put("a", 1);
}
使用getOrDefault
替换后,一行代码,更加简洁。
java
map.put("a", map.getOrDefault("a", 0) + 1);
在与Json格式做转换时
java
Map<String, Object> map = new HashMap<>();
map.put("a",null);
String jsonString = JSON.toJSONString(map, SerializerFeature.WRITE_MAP_NULL_FEATURES, SerializerFeature.QuoteFieldNames);
System.out.println(JSON.toJSONString(map));// 返回:{}
System.out.println(jsonString);// 返回:{"a":null}
返回空集合
java
List<User> users = getUsers();
if(users != null){
for(User user : users){
// ...
}
}
如果返回对象是一个集合,那么不要返回null,请使用空集合来替代。
java
Collections.emptyList();
Collections.emptyMap();
// 现在可以放心使用了
List<User> users = getUsers();
for(User user : users){
// ...
}
5.5.4 避免传递Null值
传递Null值和返回Null值一样,都需要做额外的处理,很容易导致NEP异常的产生。
java
// 用户null做业务逻辑区分,那么代码就会变成下面这样,不但增加了复杂度,如果入参在多处地方使用,还很容易遗漏
public void test(User user, Address address){
if(user == null){
// ...
}else{
}
if(address == null){
// ...
}else{
}
}
// 如果方法不支持null值,还需要做额外的校验处理,并产生异常
public void test(User user, Address address){
assert user != null : "user should not be null";
assert address != null : "user should not be null";
}
5.5.5 Optional
Optional是一个容器对象,可以存储各种类型的值,包括Null,Optional针对对象为Null值的情况做了很多处理,一起来看看吧!
构建Optional对象
java
Optional<String> optional = Optional.of("abc");
System.out.println(optional);// 输出:Optional[abc]
of方法构建一个泛型Optional对象
java
public static <T> Optional<T> of(T value) {
return new Optional<>(value);
}
如果对象为Null,of方法会抛出异常
java
public static <T> Optional<T> of(T value) {
return new Optional<>(value);
}
private Optional(T value) {
this.value = Objects.requireNonNull(value);
}
public static <T> T requireNonNull(T obj) {
if (obj == null)
throw new NullPointerException();
return obj;
}
当然Optional也支持通过ofNullable方法构建Null对象
java
Optional<String> optional = Optional.ofNullable(null);
System.out.println(optional);// 输出:Optional.empty
然过get方法可以得到泛型对象
java
Optional<String> optional = Optional.of("abc");
System.out.println(optional.get()); // 输出 abc
如果对象为Null,get方法会抛出异常
java
public T get() {
if (value == null) {
throw new NoSuchElementException("No value present");
}
return value;
}
通过isPresent方法判断对象是否为Null
java
public boolean isPresent() {
return value != null;
}
System.out.println(Optional.ofNullable("abc").isPresent()); // 输出:true
System.out.println(Optional.ofNullable(null).isPresent()); // 输出:false
// ifPresent可以接收Consumer<? super T>类型
Optional.of("abc").ifPresent(sb -> System.out.println(sb.toUpperCase()));// 输出:ABC
orElse方法,提供了对Null值对象的处理
java
System.out.println(Optional.ofNullable(null).orElse("abc"));// 输出:abc
System.out.println(Optional.empty().orElse("abc"));/// 输出:abc
orElseGet方法,支持传入Supplier<? extends T>类型
java
System.out.println(Optional.ofNullable(null).orElseGet(() -> "null"));// 输出:"null"
或者使用orElseThrow方法抛出异常
java
Optional.empty().orElseThrow(() -> new Exception("test exception"));
5.5.6 JDK14改进
对于下面这段代码,如果a为null,则会抛出NEP异常,并且通过异常堆栈信息,能够准确地定位具体的代码行数,以此来确定是a对象为null导致的NEP异常。
java
a.i = 0;
但假设是下面这段逻辑呢?如果异常堆栈信息只能精确到具体的行数,那就无法确定到底是a、b、c中哪一个对象为null导致的异常。
java
a.b.c = 0;
类似的场景还有很多
java
a.i = b.i;
a().b().i = 0;
arr[a][b] = 0;
JDK14对此做了改进,可以明确地输出导致NEP异常的对象,就像如下这样。
java
Exception in thread "main" java.lang.NullPointerException:
Cannot read field "c" because "a.b" is null
at Prog.main(Prog.java:5)
5.6 遇到的一些坑
Number类的缓存
对象比较还得使用equals,除了Integer之外,Character、Byte、Short、Long
都有类似的设计。
java
Integer i1 = 127;
Integer i2 = 127;
System.out.println(i1 == i2); // true
Integer i3 = 128;
Integer i4 = 128;
System.out.println(i3 == i4); // false
BigDecimal的精度
注意BigDecimal构造方法传入double类型的值,存在精度问题。
java
BigDecimal bigDecimal = new BigDecimal(0.1);
System.out.println(bigDecimal.toString()); // 结果:0.1000000000000000055511151231257827021181583404541015625
BigDecimal bigDecimal2 = new BigDecimal("0.1");
System.out.println(bigDecimal2.toString()); // 结果:0.1
BigDecimal除不尽
使用divide方法时,如果结果除不尽,就会抛出异常,所以无论如何都应该指定保留的小数点位数。
java
BigDecimal d1 = new BigDecimal("10");
BigDecimal d2 = new BigDecimal("3");
System.out.println(d1.divide(d2));
Exception in thread "main" java.lang.ArithmeticException: Non-terminating decimal expansion; no exact representable decimal result.
BigDecimal比较
BigDecimal的比较,请使用compareTo
方法
java
BigDecimal d1 = new BigDecimal("1");
BigDecimal d2 = new BigDecimal("1.0");
System.out.println(d1.equals(d2)); // 结果:false,应替换为d1.compareTo(d2)
String.valueOf处理null值
String.valueOf()
传入参数是null值时,会返回null值字符串,可能导致某些判空的场景出现问题。
java
Object obj = null;
String str = String.valueOf(obj);
system.out.println(str); // 结果:"null"
public static String valueOf(Object obj) {
return (obj == null) ? "null" : obj.toString();
}
Integer.parseInt转换
java
String str = "1.0";
int i = Integer.parseInt(str); // 这会直接报错,java.lang.NumberFormatException: For input string: "1.0"
// 改成这样即可
int i1 = new BigDecimal(str).intValue();
集合的UnsupportedOperationException
java
// Collections中提供的emptyMap、emptySortedMap、emptyList、emptySet等返回的对象,都是不可操作的
Map<String, String> m1 = Collections.emptyMap();
// singletonMap、singletonList、singleton等返回的对象,都是不可操作的
List<String> singletonList = Collections.singletonList("a");
// Map中的keySet、entrySet、values,都是不可操作的
Map<String, String> m2 = new HashMap<>();
// asList返回的是不可操作的
Arrays.asList(list);