Java:性能优化细节21-30
21、ArrayList & LinkedList
一个是线性表,一个是链表,一句话,随机查询尽量使用ArrayList,ArrayList优于LinkedList,LinkedList还要移动指针,添加删除的操作LinkedList优于ArrayList,ArrayList还要移动数据,这两种集合在Java中有着不同的数据结构和用途,它们各自的性能优势和劣势主要由其内部实现决定。
ArrayList
- 内部实现:
ArrayList
基于动态数组实现,支持快速随机访问。数组使得任何位置的元素都可以在常数时间内直接访问。 - 性能特点:
- 随机访问:访问任意元素的时间复杂度为O(1)。
- 添加/删除元素:在列表尾部添加元素的时间复杂度也是O(1)(不考虑扩容的情况),但在列表中间或开头添加或删除元素需要移动后续的元素,时间复杂度为O(n)。
LinkedList
- 内部实现:
LinkedList
基于双向链表实现,每个元素都包含了前后元素的引用。 - 性能特点:
- 随机访问:访问任意位置的元素需要从头或尾部遍历链表,时间复杂度为O(n)。
- 添加/删除元素:在任意位置添加或删除元素时,只需要改变前后元素的引用,时间复杂度为O(1),但这是在已经有了对应节点引用的前提下;如果是索引位置操作,需要先遍历到指定位置,时间复杂度为O(n)。
应用场景
- ArrayList:优于
LinkedList
当主要操作是随机访问元素。适用于元素访问频繁,添加删除操作相对较少的场景。 - LinkedList:优于
ArrayList
当主要操作是在列表开头或中间添加或删除元素。适合于元素的动态插入和删除操作频繁,而访问操作相对较少的场景。
理论与实际
虽然理论上ArrayList
和LinkedList
有明确的性能特点和适用场景,但实际应用时还需考虑其他因素,如内存使用情况、集合的大小、JVM实现细节等。例如,ArrayList
的扩容操作虽然不频繁,但可能会导致较大的性能开销。另外,LinkedList
虽然在理论上在某些操作上更高效,但由于其每个元素都有额外的内存开销(前后节点的引用),在大量数据操作时可能会导致较大的内存占用。
22、尽量使用System.arraycopy ()代替通过来循环复制数组
System.arraycopy() 要比通过循环来复制数组快的多,System.arraycopy()
方法是Java提供的一个本地方法,用于高效地复制数组。这个方法直接利用系统级别的函数来执行数组复制,因此在性能上通常优于在Java层面用循环逐个复制数组元素的方法。
为什么System.arraycopy()
更快
-
本地实现:
System.arraycopy()
是一个本地方法,它直接调用底层C/C++实现,可以利用更多底层优化和指令,比如利用内存复制操作,这些通常比Java层面的代码执行得更快。 -
减少JVM开销:使用循环复制数组元素会产生更多的Java虚拟机(JVM)指令执行,包括循环控制、索引计算、数组访问等,而
System.arraycopy()
调用减少了这些开销。 -
减少上下文切换:在循环复制中,每次复制操作都是一个独立的Java操作,需要JVM进行解释或编译;而
System.arraycopy()
作为单一操作,减少了从Java代码到本地执行代码的上下文切换。
使用System.arraycopy()
public static void arraycopy(Object src, int srcPos,Object dest, int destPos,int length);
- 参数说明:
src
:源数组。srcPos
:源数组中的起始位置。dest
:目标数组。destPos
:目标数据中的起始位置。length
:要复制的数组元素的数量。
示例
int[] src = new int[] {1, 2, 3, 4, 5};
int[] dest = new int[5];System.arraycopy(src, 0, dest, 0, src.length);
这个例子将src
数组的全部内容复制到dest
数组中。
注意事项
- 类型兼容性:
src
和dest
数组必须具有兼容的类型。例如,你不能将一个int[]
数组复制到一个long[]
数组中。 - 异常处理:如果复制过程中出现任何不兼容或不正确的参数,
System.arraycopy()
会抛出相应的异常,如IndexOutOfBoundsException
或ArrayStoreException
。
23、尽量缓存经常使用的对象
缓存是提高应用性能和响应速度的有效手段,特别是对于那些计算成本高、访问频繁或者加载时间长的资源和数据。通过避免重复的计算或数据检索,缓存可以显著减少系统的工作负载,提高效率。然而,设计和实现缓存策略时需要考虑到内存使用、缓存失效策略、数据一致性等因素。
缓存策略
- 选择合适的数据结构:根据具体需求,可以使用简单的数据结构如
HashMap
、ArrayList
或更复杂的数据结构来存储缓存数据。对于简单的缓存需求,标准的Java集合可能就足够了。 - 缓存失效策略:合理的缓存失效策略(如FIFO、LRU、LFU等)对于维护缓存的健康状态非常重要,它可以确保缓存中存储的数据是最有可能被再次访问的,同时移除不常用的数据以释放内存。
使用第三方缓存库
对于更复杂的缓存需求,可以考虑使用成熟的第三方缓存库,它们提供了更高级的缓存功能和性能优化,以及丰富的配置选项和管理工具:
- EhCache:是一个广泛使用的开源Java分布式缓存,为大型、高并发的Java应用提供了高性能的缓存功能。
- Guava Cache:来自Google的Guava库中提供的缓存是一个轻量级但功能强大的本地缓存实现,适用于单个JVM内的缓存需求。
- Caffeine:是一个高性能的Java 8缓存库,由Guava Cache的作者开发,提供了更好的性能和更丰富的功能。
缓存使用注意事项
- 内存管理:缓存虽然可以提高性能,但也会占用额外的内存。需要根据应用的内存使用情况合理分配缓存大小,避免因缓存占用过多内存而影响应用的其他部分或导致内存溢出。
- 数据一致性:在分布式系统中,保持缓存数据的一致性是一个挑战。需要确保缓存数据更新后,所有节点上的缓存都能同步更新,以避免数据过时。
- 性能测试:引入缓存后应进行性能测试,确保缓存实际上提高了性能而不是因为过度缓存或不恰当的失效策略导致性能下降。
24、尽量避免非常大的内存分配
在Java中,内存分配是由堆管理器负责的。当堆中没有足够的连续空间来分配所需大小的对象时,会触发一次垃圾回收操作(GC),以尝试释放一些未使用的内存,并且尝试进行内存碎片整理。如果垃圾回收后仍然无法找到足够的连续空间,则会抛出OutOfMemoryError异常。
导致大内存分配的原因
-
大对象:尝试分配单个较大的对象,这会增加寻找足够连续内存的难度。例如,创建大型数组或者大型数据结构。
-
连续分配:连续分配多个对象,每个对象的大小都很大,这会增加找到足够连续内存的难度。例如,创建大量对象或者数据结构时,会在堆中创建很多相邻的对象。
避免大内存分配的方法
-
优化数据结构:尽量避免创建大型数据结构或者大型数组,可以考虑使用更小的数据结构或者分块存储数据。
-
释放无用资源:在不需要使用的时候及时释放不再需要的对象或者资源,以便垃圾回收能够释放相关的内存空间。
-
内存优化:通过分析应用的内存使用情况,优化对象的生命周期和内存使用模式,减少大内存分配的频率。
-
避免大量连续分配:如果需要大量的对象,可以考虑使用对象池或者缓存,避免频繁地分配和回收大量的对象,从而减少对连续内存的需求。
-
合理的内存配置:根据应用的内存需求和特性,合理配置Java虚拟机的内存参数,例如-Xmx和-Xms参数来调整堆内存的大小,以减少内存分配失败的可能性。
25、慎用异常
如果创建一个 Exception ,就得付出代价,好在捕获异常开销不大,因此可以使用 try-catch 将核心内容包起来。从技术上讲,你甚至可以随意地抛出异常,而不用花费很大的代价。招致性能损失的并不是 throw 操作——尽管在没有预先创建异常的情况下就抛出异常是有点不寻常。真正要花代价的是创建异常,幸运的是,好的编程习惯已教会我们,不应该不管三七二十一就抛出异常。异常是为异常的情况而设计的,使用时也应该牢记这一原则。
确实,异常处理在Java中是一个强大而又重要的机制,但过度使用异常可能会导致性能问题和代码可读性的降低。以下是关于慎用异常的一些考虑:
-
异常的开销:创建异常对象时会产生额外的开销,包括堆栈跟踪等信息的构建。在频繁抛出异常或者创建大量异常对象时,会对性能产生影响。
-
异常的语义:异常应该用于表示异常情况,而不是作为正常的控制流程。过度依赖异常来管理程序流程会使代码难以理解和维护。
-
异常的堆栈跟踪:异常的堆栈跟踪包含了方法调用的完整链路,它的构建可能会导致较大的性能开销。因此,在创建异常时,需要权衡是否需要完整的堆栈跟踪信息。
-
异常的捕获:捕获异常本身的开销相对较小,但频繁的异常捕获和处理也会影响程序的性能。因此,应该避免不必要的异常捕获,尽量将异常处理限制在必要的范围内。
-
异常的设计原则:异常应该用于表示不可恢复的错误或者异常情况,而不应该用于正常的控制流程。正确的异常设计可以提高代码的可读性和可维护性。
26、尽量重用对象
对象的创建和销毁会产生额外的开销,特别是在频繁创建和销毁对象的情况下。在Java中,尤其是在字符串连接等场景中,频繁创建新的字符串对象会导致性能下降,因为字符串是不可变的,每次连接字符串都会生成一个新的字符串对象。
使用StringBuffer和StringBuilder
为了避免频繁创建字符串对象,可以使用StringBuffer
或StringBuilder
来进行字符串的连接。它们是可变的字符序列,允许在不创建新对象的情况下修改字符串内容。
StringBuffer buffer = new StringBuffer();
for (int i = 0; i < 10; i++) {buffer.append(i);
}
String result = buffer.toString();
StringBuffer
是线程安全的,而StringBuilder
是非线程安全的,但在单线程环境下,StringBuilder
的性能会略优于StringBuffer
。
重用对象的原则
-
池化对象:可以使用对象池来重用对象,特别是对于资源消耗较大的对象,例如数据库连接、线程等。通过对象池管理对象的生命周期,可以减少对象的创建和销毁次数,提高性能。
-
缓存对象:对于一些频繁使用的对象,可以考虑使用缓存来重用对象,减少对象的创建次数。缓存可以是基于内存的,也可以是基于磁盘的。
-
避免不必要的对象创建:在编写代码时,应该避免不必要的对象创建,特别是在循环或者频繁执行的代码块中。尽量复用现有的对象,避免创建新的对象。
-
对象池和缓存的管理:对于使用对象池和缓存的情况,需要注意及时释放不再使用的对象,避免内存泄漏和资源浪费。
27、不要重复初始化变量
避免重复初始化变量是良好的编程实践之一。在Java中,成员变量会被自动初始化为默认值,而且构造函数链中的所有构造函数都会被调用。因此,在构造函数中重复初始化变量是不必要的,并且可能会导致混乱或错误。
最佳实践
-
利用默认初始化值:Java会自动为成员变量赋予默认值,包括null、0、0.0、false等。在构造函数中,不需要再次初始化这些变量,除非需要赋予不同的初始值。
-
合理的变量赋值时机:如果需要在构造函数中进行变量赋值,确保这些赋值操作是有必要的,并且不会与默认的初始化值冲突。
-
避免空指针异常:在进行变量赋值时,注意避免空指针异常。例如,在赋值时调用其他方法,确保这些方法不会依赖于尚未初始化的变量。
-
延迟初始化:有时候可以延迟对变量的初始化,直到真正需要使用时再进行初始化操作,以节省资源和提高性能。
-
统一初始化:如果有多个构造函数,可以考虑在一个初始化方法中统一初始化变量,避免在不同的构造函数中重复初始化。
示例
public class Example {private int count; // 默认初始化为0// 构造函数public Example() {// 不需要再次初始化count,已经默认初始化为0}// 初始化方法public void init() {this.count = getCountFromDatabase(); // 避免在构造函数中调用可能导致空指针异常的方法}// 延迟初始化方法public int getCount() {if (count == 0) {count = getCountFromDatabase(); // 只有在需要时才进行初始化}return count;}// 其他方法private int getCountFromDatabase() {// 从数据库获取count值return 10; // 示例,实际应用中从数据库获取}
}
28、进行数据库连接、I/O流操作,及时关闭以释放资源
及时关闭数据库连接和I/O流是Java编程中的一项重要实践,以释放资源并避免系统资源泄漏和性能下降。
数据库连接
-
释放资源:在使用完数据库连接后,必须调用
close()
方法来释放资源,避免数据库连接池的资源耗尽以及数据库服务器负载过高。 -
使用try-with-resources:从Java 7开始,可以使用try-with-resources语句来自动关闭资源,例如:
try (Connection conn = DriverManager.getConnection(url, username, password);Statement stmt = conn.createStatement();ResultSet rs = stmt.executeQuery(sql)) {// 执行数据库操作 } catch (SQLException e) {// 异常处理 }
I/O流操作
-
关闭流:对于文件、网络等I/O流的操作,同样需要及时关闭以释放资源。对于输入流和输出流,分别调用
close()
方法来关闭。 -
使用try-with-resources:同样可以使用try-with-resources语句来自动关闭流,例如:
try (FileInputStream fis = new FileInputStream("file.txt");InputStreamReader isr = new InputStreamReader(fis);BufferedReader br = new BufferedReader(isr)) {// 读取文件内容 } catch (IOException e) {// 异常处理 }
最佳实践
-
在finally块中关闭资源:如果无法使用try-with-resources,应该在finally块中手动关闭资源,以确保资源的释放。
-
及时释放资源:在不再需要使用资源时立即释放,避免资源泄漏和系统资源的浪费。
-
异常处理:在关闭资源时需要注意异常处理,确保资源能够被正确释放,同时也要处理可能出现的异常情况。
29、在使用同步机制时,应尽量使用方法同步代替代码块同步
在Java中应该尽量使用方法同步来实现同步,而不是使用代码块同步。方法同步是指使用synchronized关键字修饰整个方法,从而使得整个方法成为同步代码块;而代码块同步则是在方法内部通过synchronized关键字对一部分代码进行同步控制。
方法同步的优点:
-
简洁清晰:方法同步将整个方法包装在同步块中,使得代码更加简洁、清晰,不需要手动管理锁的获取和释放。
-
原子性:方法同步能够保证整个方法的原子性操作,避免了因为代码块同步控制不当而导致的线程安全问题。
-
易于维护:方法同步使得锁的范围更加明确,便于代码的维护和理解,减少了出错的可能性。
代码块同步的缺点:
-
容易出错:代码块同步需要手动管理锁的获取和释放,容易因为锁的粒度控制不当而导致死锁或者竞态条件等线程安全问题。
-
代码分散:代码块同步会导致代码的分散,需要在方法中手动标识哪些部分需要同步控制,增加了代码的复杂度和维护成本。
示例:
方法同步的示例:
public synchronized void synchronizedMethod() {// 同步代码块// ...
}
代码块同步的示例:
public void synchronizedMethod() {synchronized (this) {// 同步代码块// ...}
}
30、不要在循环中使用Try/Catch语句,应把Try/Catch放在循环最外层
在循环中使用try/catch语句可能会导致性能问题,因为每次循环迭代都会执行异常处理逻辑。因此,最好将try/catch语句放在循环的最外层,以便更有效地处理异常并提高性能。
为什么不要在循环中使用try/catch语句?
-
性能问题:在循环中使用try/catch语句可能会导致性能下降,因为异常处理逻辑会在每次循环迭代时执行,增加了额外的开销。
-
逻辑混乱:在循环中使用try/catch语句会使代码逻辑变得混乱,不利于代码的可读性和维护性。
应将try/catch放在循环最外层的原因
-
减少异常处理次数:将try/catch语句放在循环外部可以减少异常处理的次数,只需在循环结束后执行一次异常处理即可。
-
提高性能:通过减少异常处理次数,可以提高程序的性能,减少不必要的开销。
示例:
try {for (int i = 0; i < n; i++) {// 在循环中执行可能抛出异常的操作}
} catch (SomeException e) {// 处理异常
}
应改为:
try {for (int i = 0; i < n; i++) {// 在循环中执行可能抛出异常的操作}
} catch (SomeException e) {// 处理异常
}