常见Java编程优化策略


遵守普遍接受的命名惯例

  • 包名称组成部分应比较简短,通常不超过8个字符,鼓励有意义的缩写形式,如首字母缩写awt,或单词简写util.
  • 类和接口采用驼峰式命名,避免缩写,除非是一些首字母缩写(如HTTP,若出现连续多个首字母缩写的形式,推荐只首字母大写,即HttpUrl而非HTTPURL)或通用缩写(如max和min)。
  • 方法和域命名与类和接口命名遵循一样的字面惯例,但采用单驼峰式命名规则。
  • 常量域包含多单词时应该以下划线分隔的形式。
  • 类型参数通常由单个字母组成:T 标识任意类型,E 标识集合类型,K和V 标识键值类型,X 标识异常,R标识返回类型。

避免创建不必要的对象

  • 重用immutable对象(一旦创建,状态就不会被改变,改变都是新建,作为参数传递时等同于值传递而不是引用传递,线程安全,不使用锁机制就能被其他线程共享,比如String,Integer以及其他包装类),如String即为immutable的。每次修改都会创建新对象,而StringBuilder是mutable,每次修改都作用于对象本身,极端反例String s = new String("abc")。
  • 重用已知不会被修改的可变对象,通常优先使用静态工厂方法而不是构造器,以避免创建不必要的对象,如Boolean.valueOf(String),而不是Boolean(String)
  • 重用“昂贵的对象”,如使用String.matches方法可以查看字符串是否与正则表达式匹配,但这个方法内部创建的Pattern实例成本很高(需要将正则表达式编译成一个有限状态机),为提升性能,可以显示创建Pattern类型的final静态域在类初始化时缓存起来,并实现重用。
  • 优先使用基本类型而不是装箱基本类型,当心无意识的自动装箱,如 Long sum = 0l;for(long i = 0;i<=Integer.MAX_VALUE;i++){sum += i}
  • 自己维护对象池来避免创建对象通常并不建议,但是重量级的对象需要采用对象池的方式实现重用,如数据库连接池

消除过期的对象引用

  • Java作为支持垃圾回收机制的语言中,通常不需要手工管理内存,但是如果一个对象引用被无意识的保留下来,这些对象及其依赖链上的所有对象都不会被回收处理,从而产生重大的性能影响,导致内存泄漏或磁盘交换(Disk Paging),甚至程序失败(OutOfMenory Error).
  • 通常应该在类自己管理内存时关注下清除过期引用,另外,无意义的缓存应该及时进行清理。
  • 内存泄漏还有一个常见来源是监听器和其他回调,确保回调立即被当作垃圾回收的最佳方法是只保存它们的弱引用,例如,只将它们保存成WeakHashMap中的键。

不要轻易覆盖equals

  • 只有当类需要提供“逻辑相等”的测试功能且无可用的继承实现时需要覆盖equals方法,通常情况下采用继承自Object的equals按全等逻辑处理即可。
  • 确定需要覆盖的场景下,需要遵守以下几点原则:自反,对称,传递,一致(幂等),非空
  • 覆盖equals的同时要覆盖hashCode,相等的对象必须具有相等的散列码(hash code),否则导致该类无法结合所有基于散列的集合(HashMap,HashSet等)一起正常运作。
  • 不要企图让equals过于智能,过度设计将带来较大风险,推荐用IDE生成。
  • 不要将equals声明中的Object对象改为其他类型,那样是重载而非重写,会导致子类中的Override注解产生错误的正值,带来错误的安全感。

始终覆盖toString

  • 提供好的toString方法实现可以使类用起来更加舒适,使用该类的系统也更易于调试。

谨慎覆盖clone

  • 仅通过实现Cloneable接口往往并不能安全的进行对象拷贝,实现一个行为良好的clone方法是相对复杂的选择,而且由于这种方式依赖于无需调用构造器就创建对象的很有风险、语言之外的对象创建机制。
  • 推荐的实现是提供一个拷贝构造器或拷贝工厂,拷贝构造器只是一个构造器,它的唯一参数类型是包含该构造器的类。基于接口的拷贝构造器和拷贝工厂(或者叫做转换构造器、转换工厂),可以带一个参数,参数类型是该类所实现的接口,这样就可以允许客户选择拷贝的实现类型,而不是强迫客户接受原始的实现类型。
  • 上述推荐最绝对的例外是数组,最好利用clone方法复制数组。

考虑实现Comparable接口

  • 对于具有明显的内在排序关系的类,尤其是值类,都应该实现Comparable接口,之后为其对象数组排序就是这么简单:Arrays.sort(a)
  • compareTo方法的通用约定和实现equals的约定相似,并强烈建议这两种比较方式保持一致,即(x.compareTo(y) == 0) ==(x.equals(y))
  • 在compareTo方法中应该使用所有装箱基本类型都具有的静态方法compare进行关系比较,而不是使用容易出错且烦琐的关系操作符<和>

使类和成员的可访问性最小化

  • 正确使用访问修饰符对于实现信息隐藏(封装)非常关键,应使用尽可能小的访问级别,以隐藏实现细节,组件间只通过API通信。
  • 私有类/接口可以考虑作为唯一使用它的那个类的私有嵌套类;如果类/接口可以做成包级私有的,就应该被做成包级私有。
  • 成员(域、方法、嵌套类、嵌套接口)同样应尽量降低访问级别
    ** 子类重写父类方法,访问级别不允许降低,这样可以保证任何使用超类实例的地方都可以使用子类的示例(里氏替换原则)
    ** 公有类的实例域绝不能是公有的,非final实例域或指向可变对象的final引用,一旦成为公有,就等于失去了强制这个域不可变的能力,因为包含公有可变域的类通常不是线程安全的。如果final域包含可变对象的引用,它便具有非final域的所有缺点,因为引用的对象可改后可能导致灾难。
    ** 长度非零的数组总是可变的,所以让类具有公有的静态final数组域,或者返回这种域的访问方法,是错误的,是安全漏洞场景的原因。可以使用私有数组并增加一个公有的不可变列表Collections.unmodifiableList(Arrays.asList(...)).,或者用公有方法返回私有数组的拷贝。

for-each循环优先于传统的for循环

  • 基于将局部变量的作用域最小化的原则,如果在循环终止后不再需要循环变量的内容,for循环就优先于while循环
  • for-each作为增强的for语句,隐藏了迭代器或者索引变量,避免了混乱和出错的可能,简洁,灵活,而且不会有性能损失。
  • 解构过滤(移除),转换(取代),平行迭代(并行遍历)的场景下不适用for-each

了解和使用类库

  • 不要重复造轮子,通过使用标准类库,可以充分利用这些编写标准类库的专家的知识及成功应用经验,比如用ThreadLocalRandom作为随机数生成器,这样你可以不用去了解同余伪随机数生成器,数论和2的求补算法等专业知识。
  • 每个程序员都应该熟悉java.lang、java.util、java.io及其子包中的内容,Collections Framework和Stream类库,以及java.util.concurrent包的内容也应该作为知识储备,其他类库随用随学即可,还可以关注第三方开源类库,并从中汲取应用,比如Google的Guava.

如果需要精确的答案,请避免使用float和double

  • float和double只能用来做科学计算或者是工程计算,在商业计算中我们要用java.math.BigDecimal
  • BigDecimal的构造器有BigDecimal(double val)和BigDecimal(String val),务必使用BigDecimal(String val)来构造(float和double无法精确的表示0.1(10的任何负数次方值)是不可能的)。
  • 可以考虑利用第三方类库Joda-Money(需要jdk8及更高版本,无依赖项)来处理金额类数据的存储和计算。
System.out.println(new BigDecimal(0.1).toString()); // 0.1000000000000000055511151231257827021181583404541015625
System.out.println(new BigDecimal("0.1").toString()); // 0.1
System.out.println(new BigDecimal(
Double.toString(0.1000000000000000055511151231257827021181583404541015625)).toString());// 0.1
System.out.println(new BigDecimal(Double.toString(0.1)).toString()); // 0.1

第一行:事实上,由于二进制无法精确地表示十进制小数0.1,但是编译器读到字符串"0.1"之后,必须把它转成8个字节的double值,因 此,编译器只能用一个最接近的值来代替0.1了,即 0.1000000000000000055511151231257827021181583404541015625。因此,在运行时,传给 BigDecimal构造函数的真正的数值是 0.1000000000000000055511151231257827021181583404541015625。
第二行:BigDecimal能够正确地把字符串转化成真正精确的浮点数。
第三行:问题在于Double.toString会使用一定的精度来四舍五入double,然后再输出。会。 Double.toString(0.1000000000000000055511151231257827021181583404541015625) 输出的事实上是"0.1",因此生成的BigDecimal表示的数也是0.1。
第四行:基于前面的分析,事实上这一行代码等价于第三行

日期处理

  • java.util.Date的构造器被废弃的只剩下 Date(long date),可以用来创建用时间表示某个瞬间的对象(除 “现在” 以外)。该方法使用距离 1970 年 1 月 1 日子时格林威治标准时间(也称为 epoch)以来的毫秒数作为一个参数,此外场景下 java.util.Date 声明应当使用 java.util.Calendar,鉴于 JDK Calendar 类缺乏可用性,产生了Joda-time项目专门处理日期及时间,Joda-Time 令时间和日期值变得易于管理、操作和理解,可扩展性、完整的特性集以及对多种日历系统的支持。并且 Joda 与 JDK 是百分之百可互操作的。
# Calendar 2000 年 1 月 1 日 0 时 0 分
Calendar calendar = Calendar.getInstance();
calendar.set(2000, Calendar.JANUARY, 1, 0, 0, 0);
# Joda
DateTime dateTime = new DateTime(2000, 1, 1, 0, 0, 0, 0);


# Calendar 加上 90 天
Calendar calendar = Calendar.getInstance();
calendar.set(2000, Calendar.JANUARY, 1, 0, 0, 0);
SimpleDateFormat sdf =
new SimpleDateFormat("E MM/dd/yyyy HH:mm:ss.SSS");
calendar.add(Calendar.DAY_OF_MONTH, 90);
System.out.println(sdf.format(calendar.getTime()));
# Joda
DateTime dateTime = new DateTime(2000, 1, 1, 0, 0, 0, 0);
System.out.println(dateTime.plusDays(90).toString("E MM/dd/yyyy HH:mm:ss.SSS");
# Joda-Time在日期/时间计算方面的表现尤其突出,如计算45 天之后的某天在其下一个月的当前周的最后一天的日期
DateTime dateTime = new DateTime(2000, 1, 1, 0, 0, 0, 0);
System.out.println(dateTime.plusDays(45).plusMonths(1).dayOfWeek()
.withMaximumValue().toString("E MM/dd/yyyy HH:mm:ss.SSS");
# 时区是指一个相对于英国格林威治的地理位置,用于计算时间,DateTimeZone 是 Joda 库用于封装位置概念的类。
DateTime(Object instant, DateTimeZone zone)
# 要格式化一个 Joda 对象,调用它的 toString() 方法,并且如果您愿意的话,传递一个标准的 ISO-8601 或一个 JDK 兼容的控制字符串,以告诉 JDK 如何执行格式化。不需要创建单独的 SimpleDateFormat 对象(但是 Joda 的确为那些喜欢自找麻烦的人提供了一个 DateTimeFormatter 类)。调用 Joda 对象的 toString() 方法,仅此而已。
DateTime dateTime = SystemFactory.getClock().getDateTime();
dateTime.toString(ISODateTimeFormat.basicDateTime());//20090906T080000.000-0500
dateTime.toString(ISODateTimeFormat.basicDateTimeNoMillis());//20090906T080000-0500
dateTime.toString(ISODateTimeFormat.basicOrdinalDateTime());//2009249T080000.000-0500
dateTime.toString(ISODateTimeFormat.basicWeekDateTime());//2009W367T080000.000-0500
# 可以传递与 SimpleDateFormat JDK 兼容的格式字符串
dateTime.toString("dd-MM-yyyy HH:mm:ss");
dateTime.toString("EEEE dd MMMM, yyyy HH:mm:ssa");
dateTime.toString("MM/dd/yyyy HH:mm ZZZZ");
dateTime.toString("MM/dd/yyyy HH:mm Z");

基本类型优先于装箱基本类型

  • 自动拆/装箱模糊了但并未抹去基本类型和装箱类型之间的区别,基本类型只有值,装箱类型除了值外还有同一性(equals);基本类型只有函数值,装箱类型除了函数值外,还有null;基本类型比装箱类型更省时间和空间。
  • 对装箱类型采用==比较几乎总是错误的,引用同一个值的装箱类型进行同一性比较会返回false
  • 当基本类型和装箱类型产生运算时,装箱类型会自动拆箱,如果null对象引用被自动拆箱将引发NullPointException,同时频繁的拆装箱操作会导致较高的资源消耗和不必要的对象创建。
  • 适用装箱类型的通用场景:1,作为集合元素、键、值,基本类型不能放在集合中;2、参数化类型和方法时泛型不允许使用基本类型。

如果其他类型更合适,则避免使用字符串

  • 如果有更合适的数据类型,应避免用字符串来表示对象。若使用不当,字符串将显得更加笨拙,速度更慢,更易出错。

了解字符串连接的性能

  • 不要用字符串连接符(+)来合并多个字符串,除非不考虑性能。可以考虑用StringBuffer/StringBuilder代替。

面向接口编程

  • 如果有合适的接口类型存在,那么对于参数,返回值,变量和域来说,都应该使用接口类型来声明;如果没有,不必强求。
  • 接口优先于反射机制,反射会损失编译时类型检查的优势,还会有性能损失,并带来代码的笨拙和冗长

谨慎的进行优化

  • 不成熟的优化弊大于利,要努力编写好的程序而不是快的程序

优先考虑依赖注入来引用资源

  • 静态工具类和Singleton类不适合与需要使用底层资源的类,应该采用依赖注入的模式来处理。简单的依赖注入可以是将底层资源作为一个final域,并由构造器完成注入,或者通过工厂方法将资源传给构造器;复杂的依赖注入就需要由依赖注入框架来完成,常见的如Spring.

资源关闭

  • 在Java中,当一个对象变得不可达的时候,垃圾回收器会回收与该对象相关联的存储空间,所以不必也不该使用终结方法或消除方法去关闭对象,尤其是资源类对象。终结方法和消除方法不能保证会被及时执行,甚至在特殊情况下,不会被执行,另外在执行消耗及安全性上有严重缺陷。比如System.gc 和 System.runFinalization等方法都具有致命缺陷。资源关闭应该让类实现AutoCloseable,并要求客户端在每个实例不再需要的时候调用close方法,一般是使用try-with-resource确保终止。
  • try-finally通常在finally中调用close方法手工关闭资源,当finally出现异常则会覆盖掉try中抛出的异常导致跟踪调试问题困难,多资源下下将更为混乱。try-with-resource(这是个语法糖)可以自动判空并调用资源的close方法,在try后括号中声明实现了AutoCloseable/Closable接口的资源(Closable要求close幂等,AutoCloseable不要求),当try中和资源关闭同时出现异常,会禁止且存储后一个异常,以保留第一个异常。

检查方法参数的有效性

  • 编写方法或者构造器时,应当考虑它的参数有哪些限制,将这些限制写到注释的同时,应该通过显式的检查来实施这些限制,当无效参数进入方法时,参数检查可以让方法快速失败,并清楚的显示相应的异常,这对系统的可用性,健壮性和灵活性有重要的影响。
  • Java 7中新增的Objects.requireNonNull(T obj, String message)方法灵活且方便,故不必再手工进行null检查。
  • 非公有的方法通常应该用断言来检查它们的参数,断言是在声称被断言的条件时为真,断言失败将抛出AssertionError,不同于一般的有效性检查,如果它们没有起到作用,本质上也不会有成本开销

返回零长度的数组或集合,而不是null

  • 不要返回null,而应该返回一个零长度的数组或集合,这样很大程度上避免了客户端程序员因遗漏参数检查而引发的麻烦,若担心零长度容器带来的开销,可以返回不可变的共享对象,如Collections.emptyList.但这种优化往往几乎用不上。

异常使用

  • 异常应该只用于异常的情况下,它们永远不应该用于正常的控制流。
  • 异常机制使Java成为一个安全的编程语言,当异常出现的时候,不应该忽略异常,空catch块会使异常达不到应有的目的,起码应该注释说明并记录异常日志。

泛型使用

  • 泛型可以告诉编译器每个集合所接受的对象类型,编译器可为你的插入自动转换,并在编译时告知是否插入了类型错误的对象,使程序更安全。
  • 声明中具有一个多多个类型参数的类或接口就是泛型类或者接口,不要使用原生态类型(没有类型参数的泛型,允许使用的意义在于移植兼容性),像List这样的原生态类型,逃避了类型检查,失掉类型安全性且会有编译器警告,而参数化的类型则不会。
  • 在不确定或者不关心实际的类型参数的场景下,可以使用无限制的通配符类型,如一个问号,形如Set(读作 某个类型的集合)。由于可以将任何元素放入使用原生态类型的集合中,因此很容易破坏该集合的类型约束条件,但不能将任何元素(除null)放到Collection中,这样会产生编译错误。
  • 要尽可能的消除每一个非受检警告,如果无法消除警告,同时可以证明引起警告的代码是类型安全的(只有在这种情况下),才可以用一个@SuppressWarnings("unchecked") 注解(尽可能小的范围内使用,最小可以到代码行级别,且应注释说明安全性来由)来禁止这条警告,如果禁止警告前没有先证实代码是类型安全的,那就只是给你自己一个错误的安全感而已,运行时依然会抛出ClassCastException异常。
  • 数组是协变的(若Sub是Super的子类型,则Sub[]也是Super[]的子类型),泛型则是可变的(List和List无任何层级关系);数组是具体化的,泛型则是通过擦除来实现的,所以数组和泛型不能很好的混合使用,创建泛型、参数化类型或者类型参数的数组时非法的,如new List[],new List[],new E[],结合使用可变参数(varargs)和泛型时会出现令人费解的警告,可以利用SafeVarargs注解解决这个警告,最好优先使用集合类型List,而不是数组类型E[]

枚举与注解

  • 早期的int枚举模式不具有类型安全性,且几乎没有描述性,未重新编译的情况下客户端也无法及时响应值的变化。这种方式的变体采用String常量的方式,往往会出现不使用变量而是将常量值硬编码进程序,这种方式难以维护且容易出错,还可能出现性能问题,都是应该避免的。
  • 枚举类型是由一组固定的常量组成合法值得类型。枚举类型是实例受控的(没有可访问的构造器),是单例的泛型化,保证了编译时的类型安全,命名空间隔离,允许添加任意的方法和域,并实现任意接口。提供了所有Object方法的高级实现,实现了Comparable和Serializable接口,同时将枚举应用到switch语句中也是很方便的。

Lambda和Stream简介

  • Java 8 中的 Stream 是对集合(Collection)对象功能的增强,它专注于对集合对象进行各种非常便利、高效的聚合操作(aggregate operation),或者大批量数据操作 (bulk data operation)。Stream API 借助于 Lambda 表达式,极大的提高编程效率和程序可读性。同时它提供串行和并行两种模式进行汇聚操作。Stream 就如同一个迭代器(Iterator),数据源本身可以是无限的,单向,不可往复,数据只能遍历一次,遍历过一次后即用尽了,就好比流水从面前流过,一去不复返。而和迭代器又不同的是,Stream 可以并行化操作,迭代器只能命令式地、串行化操作。顾名思义,当使用串行方式去遍历时,每个 item 读完后再读下一个 item。而使用并行去遍历时,数据会被分成多个段,其中每一个都在不同的线程中处理,然后将结果一起输出。Stream 的并行操作依赖于 Java7 中引入的 Fork/Join 框架(JSR166y)来拆分任务和加速处理过程。
  • 操作步骤:获取一个数据源(source)→ 数据转换→执行操作获取想要的结果
  • 有多种方式生成 Stream Source:Collection.stream() ,Collection.parallelStream(),Arrays.stream(T array) or Stream.of(),java.io.BufferedReader.lines(),java.util.stream.IntStream.range(),java.nio.file.Files.walk(),java.util.Spliterator,Random.ints(),BitSet.stream(),Pattern.splitAsStream(java.lang.CharSequence),JarFile.stream()
    ** 转换操作(Intermediate):一个流可以后面跟随零个或多个 intermediate 操作。其目的主要是打开流,做出某种程度的数据映射/过滤,然后返回一个新的流,交给下一个操作使用。这类操作都是惰性化的(lazy),就是说,仅仅调用到这类方法,并没有真正开始流的遍历。如map (mapToInt, flatMap 等)、 filter、 distinct、 sorted、 peek、 limit、 skip、 parallel、 sequential、 unordered
    ** 终结操作(Terminal):一个流只能有一个 terminal 操作,当这个操作执行后,流就被使用“光”了,无法再被操作。所以这必定是流的最后一个操作。Terminal 操作的执行,才会真正开始流的遍历,并且会生成一个结果,或者一个 side effect。如forEach、 forEachOrdered、 toArray、 reduce、 collect、 min、 max、 count、 anyMatch、 allMatch、 noneMatch、 findFirst、 findAny、 iterator