一、不必要的含义

通常情况下,我们都会毫无估计的创建所需对象,但有些对象在创建时内存消耗大、创建时间长,频繁创建势必会给CPU带来沉重负担,这将会极大的影响客户端性能,所以要避免创建这些不必要的对象。符合以下特征之一的,就可以认为是不必要的:

  • 创建开销昂贵,且频繁创建
  • 虽创建开销不大,但过于频繁且完全可以避免

避免创建不必要的对象主要有两种方式:一是复用对象;二是优化程序。

二、复用原理

复用对象,而不是每次需要时创建一个新的功能相同的对象,往往是更好的选择。

字符串使用机制是典型的通过复用对象来避免创建不必要对象的设计。

通过,我们这样定义字符串:

String s="bikini";                // 推荐

而不是这样:

String s=new String("bikini");    // 不建议

为什么呢?因为new String()每次都会创建一个新的String对象,而"bikini"只会创建一次。在Java程序中会大量用到字符串,为了节约内存、提升性能,Java采用了字符串常量池的来缓存字符串。

(1)字符串常量池是什么?

字符串常量池(String Pool)是JVM为了复用字符串字面量、节省内存而在维护的一块特殊缓存区域。说白了,就是同样的字符串字面量 "bikini",在JVM中只会存在一份StringPool本质上是JVM在方法区/元空间(Metaspace JDK7以后) 中维护的一个哈希表

StringPool = HashMap<String内容, String对象引用>

注意:元空间 ≠ 堆,String对象本体在堆里,常量池里存的是引用

(2)字符串如何创建的?

首先,无论哪种方式定义字符串,判断字符串常量池中是否存在指定字符串,如果不存在,则JVM会在堆中创建字符串,并将其引用存储在常量池中。

其次,使用字面量定义字符串变量,会返回肯定常量池中的唯一引用,不管定义多少次,都是同一个;
  如果new方式,会在堆中创建一个新的字符串实例并返回,这时候定义的变量就指向新定义的对象,每new一次,就会创建一个实例。

创建过程:

过程 String s="bikini"; String s = new String("bikini");
检查常量池中 "bikini" ✅(若不存在,堆中创建对象入池) ✅(若不存在,堆中创建对象入池)
变量s的值为 常量池中引用 在堆中创建一个新String对象
是否同一个对象 ✅每次执行都不会创建新对象 ❌每次执行都会创建新对象

验证测试:

![util](E:\BaiduNetdiskWorkspace\MD\images\effective\util.png)public static void main(String[] args) {
    String s1=new String("bikini");
    String s2=new String("bikini");
    String s3="bikini";
    String s4="bikini";

    System.out.println(s1==s2);           // fasle s1、s2各创建的新实例,完全不同
    System.out.println(s2==s3);           // false s2是新创建的新实例,与s3常量池中的引用
    System.out.println(s3==s4);           // true  s3、s4均为常量池中同一引用
    System.out.println(s2.intern()==s3);  // true  intern返回常量池中的引用,与s3是同一个
}

一图读懂:
在这里插入图片描述

三、如何复用

不可变类最大的特点就是无法继承和修改,线程安全的。比如,上述Java本身维护的String类就是不可变类,所创建的对象完全可以复用。

当然,我们也可以自行维护缓存池来实现复用对象目的。例如,我们要编写一个方法,确定一个字符串是否为电子邮件。

(1)便捷实现

public static boolean isEmail(String s) {
    return s.matches("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}");
}

String.matches方法最大特点就是使用方便,但不适合性能要求高或频繁调用的场景,其原因是,方法每调用一次会编译这个正则表达式,并创建一个Pattern实例,而编译(complie)的过程资源开销是很大的,问题是使用一次就销毁了(造孽啊v)。这段代码等价于:

public static boolean isEmail(String s) {
    Pattern p = Pattern.compile("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}"); // 每次都编译,编译特别慢
    return p.matcher(s).matches();
}

创建Pattern实例的开销很大,因为它需要将这个正则表达式编译成一个有限状态机。

正则表达式是一种小型程序语言,JVM 在处理它时,并不是简单扫描字符串,而是要经过解析正则语法构建抽象语法树(AST)转换成有限状态机(FSM / NFA → DFA)优化状态转移表等过程。这个过程是 CPU 密集型 + 内存分配型 的操作。在生成状态机和优化环节成本高,尤其是复杂正则(回溯、分组、断言等),编译成本非常高。

这里的有限状态机,可以理解为一个自动机程序,用来判断输入字符串是否符合某种模式。

(2)复用改进

class Email{
	private static final Pattern ROMAN=Pattern.compile("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}");
	public static boolean isEmail(String s) {
		return ROMAN.matcher(s).matches();
	}
}

Pattern.compile()只执行一次,状态机只构建一次,后续只是轻量级的 Matcher对象创建。

(3)加深理解

我们把Pattern创建比作盖楼:

Java 对象 现实类比
正则表达式字符串 建筑图纸
Pattern.compile() 按图纸盖楼
Pattern 盖好的大楼
Matcher 检查某个房间

正确的做法是,楼盖好,大家轮流进去检查,而非每来一个检查,就得拆楼重新盖。Pattern 是大楼,Matcher 是访客;大楼别反复盖,访客随便来。

Pattern.compile()之所以昂贵,是因为它将正则表达式编译成一个复杂的有限状态机;这种编译是一次性的高成本操作,应当通过静态常量复用以避免重复开销。

四、适配器

(一)适配器是什么?

适配器是这样的对象,它将功能委托一个后备对象(backing object),为其提供一种替代接口。因为适配器除了后备对象之外不会保存其他状态信息,所以对于某个给定对象的给定适配器,不需要为其创建多个实例。

这是一种违反感觉的复用,尽管不是不可变对象,但由于其特殊用途,一个对象足以,不需要创建过多对象。

适配器是通过组合的方式,将一个类的接口转换成客户端期望的另一个接口,使原本接口不兼容的类可以一起工作。适配器本身不存储额外状态(无字段、无数据),它只是把调用转发给后备对象。因此,对于同一个后备对象(就是拥有真实数据和真实功能的对象),一个适配器实例就足够用了。

打个比方,就像你有一个电源转接头(适配器),它只是把一种插头转换成另一种,本身不存电,也不记录任何状态。同一个转接头可以给多个相同类型的电器用,不需要每个电器配一个。

通俗讲,适配器就是一个“套在外面的壳”,它拿着原来的对象(后备对象),把旧接口换成新接口,自己不存数据、不干实事,所有活都丢给原来的对象去做。

(二)Map的KeySet()

Map 接口的 keySet 方法返回包含该 Map 对象所有键的一个 Set 视图。

Map是一个键值对集合。有时候你只想要所有键的集合(Set<K>),但 Map内部并没有单独存储一个 Set<K>对象,于是 keySet()返回一个适配器对象。它内部持有当前 Map 的引用(后备对象),对外表现为一个 Set,所有对 Set 的操作(如 size()iterator()contains() 等)都委托给 Map 去实现。这样实现的效果:

  • 不需要复制数据,不需要额外存储空间
  • keySet 的修改会直接影响原 Map(比如删除一个 key),所以,每次调用返回的对象都会发生变化
  • 对于同一个 Map,多次调用 keySet()可以返回同一个适配器实例

示例代码:

![adapter](E:\BaiduNetdiskWorkspace\MD\images\effective\adapter.jpg)// 假设我们有一个 Map
Map<String, Integer> map = new HashMap<>();
map.put("A", 1);
map.put("B", 2);

// keySet() 返回一个适配器(Set 视图)
Set<String> keys = map.keySet();

// 这个 Set 的 size() 实际是委托给 map.size()
System.out.println(keys.size()); // 2

// 通过适配器删除键,会影响原 Map
keys.remove("A");
System.out.println(map.containsKey("A")); // false

结构示意图

在这里插入图片描述

(三)实际开发用例

假设你接手了一个老项目,里面有一个用户服务

public class LegacyUserService {

    public String getUserInfo(long userId) {
        return "User{id=" + userId + ", name='Tom'}";
    }
}

现在的新系统要求统一使用如下接口:

public interface UserQueryService {
    UserDTO findById(Long id);
}
public class UserDTO {
    private Long id;
    private String name;
    // getter / setter
}

问题来了LegacyUserService不能直接实现 UserQueryService,因为它返回的是 String

你不修改老代码,而是写一个适配器:

public class UserServiceAdapter implements UserQueryService {

    // 后备对象(被适配的对象)
    private final LegacyUserService legacyUserService;

    public UserServiceAdapter(LegacyUserService legacyUserService) {
        this.legacyUserService = legacyUserService;
    }

    @Override
    public UserDTO findById(Long id) {
        // 1. 调用老接口
        String info = legacyUserService.getUserInfo(id);

        // 2. 做适配转换
        UserDTO dto = new UserDTO();
        dto.setId(id);
        dto.setName(parseName(info));
        return dto;
    }

    private String parseName(String info) {
        // 简化示例
        return "Tom";
    }
}

客户端使用方式:

public class Client {

    public static void main(String[] args) {
        LegacyUserService legacyService = new LegacyUserService();

        // 只创建一次适配器
        UserQueryService service =
                new UserServiceAdapter(legacyService);

        UserDTO user = service.findById(1L);
        System.out.println(user.getName());
    }
}

特点分析:

  • 适配器 没有自己的业务状态
  • 所有功能都 委托给 LegacyUserService
  • 对同一个 LegacyUserService只需要一个适配器实例

四、自动装箱

自动装箱模糊了基本类型与其封装类型的区别,但是并没有消除这种区别。二者之间存在微妙的语义差别和明显的性能差别。

**例:**计算所有int类型正整数值的总和。

private static long sum(){
    Long sum=0L;     // 类型定义应该是long,而不是Long
    for(long i=0;i<=Integer.MAX_VALUE;i++) {
        sum+=i;
    }
    return sum;
}

这个程序在编译时,发生了自动装箱和拆箱。sum+=i等价于:

sum = Long.valueOf(sum.longValue() + i);

每循环一次,就会发生一次拆箱和装箱的操作,并构造了大约21亿个不必要的Long实例,这就导致程序运行非常慢。

结论显而易见:

应该优先使用基本类型而不是其封装类,并提防无意重的自动装箱。

五、总结说明

1.普通的对象创建消耗不大,完全可以放心创建,只有当创建对象的开销极为昂贵时,才推荐维护对象池的方式来避免创建对象,因为维护一个对象池也是会影响性能的。

2.本条与保护性复制不冲突,前者重在性能优化,后者重在安全保护。

更多推荐