关于代码优化的技巧记录
今天刚好收到微信推送有这么一篇文章《分享16个代码优化技巧》,借着这个作者的分享,我也把里面的16个优化技巧给实操一遍,然后记录下来。当然,这16个并不是结束,只是一个开始,因为代码优化是一件非常持久的事情,不是零星几点就可以搞完的。
类成员与方法的可见性最小化
参考文章,仅供学习记录参考,如有侵权,请联系删除!
区分良好设计与不佳设计的最重要因素是,组件是否将其内部数据与实现细节隐藏起来。一个良好的设计应当隐藏所有的实现细节,将其API和实现清晰的分离开。然后组件只能通过API进行交流,而对彼此的实现细节一无所知。这就是所谓的信息隐藏或者封装,是软件设计的最基本原则。
信息隐藏非常重要,主要的原因是,它能够将组成一个系统的各个组件解耦开来,是各个组件能够独立进行开发、测试、优化、使用或者修改,这能够极大幅度提高软件开发的效率,因为各个组件能够同时进行开发。信息隐藏也减轻了维护的负担,因为组件能够被更快地理解和调试或者更换组件,而不用担心损害到其他组件。虽然信息隐藏不能直接创造良好的性能,但是它能进行有效的性能调整:一旦系统完成并且分析确定哪些组件导致了性能问题,就可能优化或者直接替换这些组件,而不用担心对其他组件造成影响。最后,信息隐藏能够降低构建大型系统的风险,因为即使系统不能正常工作,它的各个组件也可能被证明是成功可用的。
Java提供了很多机制来帮助信息隐藏。访问控制机制指定了类、接口和成员的可见性,类和成员的可见性取决于其声明的位置,以及声明中的访问控制符(private
,protect
和public
),正确使用这些操作符对信息隐藏至关重要。
大拇指规则(经验法则)很简单:尽量使类和成员对外界保持不可见性,也就是说能用private
修饰的绝不使用protect
或者public
来修饰。
对于顶层(非嵌套的)类和接口来说,只有两种可能的可访问级别:包级私有和public
,你可以在声明类和接口的时候使用public
修饰符,这样它们就是对所有类都可见的,否则是包级私有的。包级私有类通常作为实现的一部分,你可以修改、替换或者在后续版本中删掉它,而不必担心会对现有使用者造成影响,而public
的类一旦声明了,你就有义务永远维护它,以保持兼容性,所以应该小心地设计一个public
的类或者接口,因为一旦设计不好,后续的维护将会十分艰难。
举个例子:如果是一个private
的方法,想删除就删除。
如果一个public
的service
方法,或者一个public
的成员变量,删除一下,不得思考很多。
使用位移操作替代乘除法
计算机是使用二进制表示的,位移操作会极大地提高性能。
<< 左移相当于乘以 2;>> 右移相当于除以 2
>>> 无符号右移相当于除以 2,但它会忽略符号位,空位都以 0 补齐
总结:左移代替乘法,右移代替除法。【左乘右除】;注意的是使用移位应添加注释,因为移位操作不直观,比较难理解。
举个例子:
1 |
|
1 |
|
1 |
|
1 |
|
尽量减少对变量的重复计算
我们知道对方法的调用是有消耗的,包括创建栈帧、调用方法时保护现场,恢复现场等。
1 |
|
以上的代码,当list.size()
很大的时候,就可以减少很多的消耗。
不要捕捉RuntimeException
Java 类库中定义的一类 RuntimeException
可以通过预先检查进行规避,而不应该通过 catch
来处理,比如: IndexOutOfBoundsException
,NullPointerException
等等。
1 |
|
使用局部变量可避免在堆上分配
首先,我们先来了解概念。根据内存管理(分配和回收)方式的不同,可以将内存分为堆内存
和栈内存
。那这两块内存有什么区别呢?
内存名称 | 作用 |
---|---|
堆内存 | 由内存分配器和垃圾收集器负责回收 |
栈内存 | 由编译器自动进行分配和释放 |
一个程序的运行过程中,可能会有多个栈内存
,但是肯定只有一个堆内存。
每个栈内存都是由线程或协程独立占有,因此从栈中分配内存不需要加锁,并且栈内存再函数结束后会自动回收,性能相对堆内存要高。
而堆内存可能同时有多个线程或者协程从堆中申请内存,因此堆中申请内存需要加锁,避免造成冲突。并且堆内存在函数结束后,需要GC的介入参与,如有大量的GC操作,将导致程序性能下降严重。
了解了内存管理之后,我们来了解下我们的局部变量是从哪里分配的。
正常思路而言,局部变量的作用域仅仅在它所在的函数中,当函数返回后,所有局部变量所占用的内存空间都会被收回。所以说如果局部变量是基本类型的,那么值会直接存在栈中,如果是引用类型的,则会将其对象存在堆中,而把对象的引用(指针)存在栈中。
1 |
|
总结:由于堆资源是多线程共享的,是垃圾回收器工作的主要区域,过多的对象会造成 GC 压力,可以通过局部变量的方式,将变量在栈上分配。这种方式变量会随着方法执行的完毕而销毁,能够减轻 GC 的压力。
减少变量的作用范围
注意变量的作用范围,尽量减少对象的创建。
如下面的代码,变量 s 每次进入方法都会创建,可以将它移动到 if 语句内部。
1 |
|
尽量采用懒加载的策略,在需要的时候才创建
懒加载的方式避免我们创建一些非必需的对象
1 |
|
访问静态变量直接使用类名
首先,我们先来想一下下面这段代码如果运行会发生什么事?
1 |
|
看着上面这段代码是不是感觉肯定有问题,会导致NPE,但实际上运行之后会输出100
,为啥会出现这种奇怪的现象呢?一个null也可以访问到类变量呢?
我们通过JDK自带的反汇编器javap
,可以查看java编译器为我们生成的字节码,用这个工具,我们可以对照源码和字节码,从而了解很多编译器内部的工作。
1 |
|
1 |
|
通过字节码指令7我们可以看到,其实在编译过程中编译器已经将代码优化成类名点访问的形式了,而使用对象访问静态变量,这种方式多了一步寻址的动作,需要先找到变量对应的类,再找到类对应的变量。
1 |
|
字符串拼接使用StringBuilder
首先我们先抛出结论:字符串拼接,使用 StringBuilder 或者 StringBuffer,不要使用 + 号。
为什么我们说不要使用+
号对字符串进行拼接呢?其实这得从字符串拼接的性能问题来说,当我们使用+
拼接,都会生成一个新的String。特别在循环拼接字符串的场景下,性能损失是极其严重的:
- 空间浪费:每次拼接的结果都需要创建新的不可变类
- 时间浪费:创建的新不可变类需要初始化;产生大量
短命
垃圾,影响年轻代甚至full gc
下面我们分两个场景来看看拼接的情况
较为简单的场景
1
2
3
4
5
6
7public class TestDemo {
public static void main(String[] args) {
int i = 0;
String sentence = "Hello" + "world" + i + "\n";
System.out.println(sentence);
}
}通过
javap
我们来看源码和字节码的对比情况1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29Compiled from "TestDemo.java"
public class com.gcoder.test.TestDemo {
public com.gcoder.test.TestDemo();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: iconst_0
1: istore_1
2: new #2 // class java/lang/StringBuilder
5: dup
6: invokespecial #3 // Method java/lang/StringBuilder."<init>":()V
9: ldc #4 // String Helloworld
11: invokevirtual #5 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
14: iload_1
15: invokevirtual #6 // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
18: ldc #7 // String \n
20: invokevirtual #5 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
23: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
26: astore_2
27: getstatic #9 // Field java/lang/System.out:Ljava/io/PrintStream;
30: aload_2
31: invokevirtual #10 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
34: return
}我们可以看到编译器将我们的String的拼接操作优化成了
StringBuilder#append()
,由此我们可以看到编译器对我们代码进行的一些必要的优化,但是在一些较为复杂的场景中,这样的优化效果却并不明显!较为复杂的场景
1
2
3
4
5
6
7
8
9
10public class TestDemo {
public static void main(String[] args) {
int i = 0;
String sentence = null;
for (i = 0; i < 1000000000; i++) {
sentence += "Hello" + "world" + i + "\n";
}
System.out.println(sentence);
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39Compiled from "TestDemo.java"
public class com.gcoder.test.TestDemo {
public com.gcoder.test.TestDemo();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: iconst_0
1: istore_1
2: aconst_null
3: astore_2
4: iconst_0
5: istore_1
6: iload_1
7: ldc #2 // int 1000000000
9: if_icmpge 47
12: new #3 // class java/lang/StringBuilder
15: dup
16: invokespecial #4 // Method java/lang/StringBuilder."<init>":()V
19: aload_2
20: invokevirtual #5 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
23: ldc #6 // String Helloworld
25: invokevirtual #5 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
28: iload_1
29: invokevirtual #7 // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
32: ldc #8 // String \n
34: invokevirtual #5 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
37: invokevirtual #9 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
40: astore_2
41: iinc 1, 1
44: goto 6
47: getstatic #10 // Field java/lang/System.out:Ljava/io/PrintStream;
50: aload_2
51: invokevirtual #11 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
54: return
}从上面的代码我们也可以看到编译器同样会进行优化,但是因为这是在一个循环体中,每次循环都会new一个新的StringBuilder对象来使用,使用完就会销毁。对象的创建和销毁的开销虽然平时看不出来,可是一旦拼接次数多了的话,这个开销也是十分大的!因此在频繁的字符串变量拼接操作中,尽量自己显式创建StringBuilder而不要让jvm自己去优化。
重写对象的HashCode,不要简单地返回固定值
有同学在开发重写 HashCode
和Equals
方法时,会把 HashCode
的值返回固定的 0,而这样做是不恰当的
当这些对象存入 HashMap
时,性能就会非常低,因为 HashMap
是通过HashCode
定位到 Hash
槽,有冲突的时候,才会使用链表或者红黑树组织节点,固定地返回 0,相当于把 Hash 寻址功能无效了。
HashMap等集合初始化的时候,指定初始值大小
这样的对象有很多,比如 ArrayList,StringBuilder 等,通过指定初始值大小可减少扩容造成的性能损耗。下面我们也把java中各种集合的扩容知识进行记录
HashMap
初始容量定义:默认为1 << 4(16)
。最大容量为1<< 30
扩容加载因子为0.75
,第一个临界点在当HashMap中元素的数量大于table数组长度加载因子(160.75=12),则按oldThr << 1(原长度*2)
扩容。
HashSet
初始容量定义:16。因为构造一个HashSet,其实相当于新建一个HashMap,然后取HashMap的Key
扩容机制和HashMap
一样
Hashtable
初始容量定义:capacity (11)
扩容加载因子0.75
,当超出默认长度(int)(11*0.75)=8时,扩容为old*2+1
。(int newCapacity = (oldCapacity << 1) + 1;)
ArrayList
初始容量定义:10
扩容:oldCapacity + (oldCapacity >> 1),即原集合长度的1.5倍。(int newCapacity = (oldCapacity * 3)/2 + 1;)
CopyOnWriteArrayList
CopyOnWriteArrayList并不像ArrayList一样指定默认的初始容量。它也没有自动扩容的机制,而是添加几个元素,长度就相应的增长多少。CopyOnWriteArrayList适用于读多写少,既然是写的情况少,则不需要频繁扩容。并且修改操作每次在生成新的数组时就指定了新的容量,也就相当于扩容了,所以不需要额外的机制来实现扩容。
StringBuffer
初始容量定义:16
扩容:因为StringBuffer extends AbstractStringBuilder
,所以实际上是用的是AbstractStringBuilder
的扩容方法,当用append(str)
添加字符串时,假设字符串中已有字符长度为count的字符串,初始长度value=16,若要添加的
字符串长度(count+str.length())<=(value2+2)则按value2+2长度扩容,并且value=value2+2,若(count+str.length())>(value2+2),则按count+str.length()长度扩容,并且value=count+str.length()。下次超出时再按以上方法与value*2+2比较扩容。
1 |
|
StringBuilder
初始容量定义:16
扩容机制和StringBuffer
一样
1 |
|
循环内不要不断创建对象引用
1 |
|
第一种会导致内存中有size个Object对象引用存在,size很大的话,就耗费内存了。
遍历Map 的时候,使用 EntrySet 方法
使用 EntrySet 方法,可以直接返回 set 对象,直接拿来用即可;而使用 KeySet 方法,获得的是key 的集合,需要再进行一次 get 操作,多了一个操作步骤,所以更推荐使用 EntrySet 方式遍历 Map。
1 |
|
不要在多线程下使用同一个 Random
Random 类的 seed 会在并发访问的情况下发生竞争,造成性能降低,建议在多线程环境下使用ThreadLocalRandom
类。
1 |
|
自增推荐使用LongAddr
这个点需要大家自行去验证一下~
自增运算可以通过 synchronized 和 volatile 的组合来控制线程安全,或者也可以使用原子类(比如 AtomicLong)。
后者的速度比前者要高一些,AtomicLong 使用 CAS 进行比较替换,在线程多的情况下会造成过多无效自旋,可以使用 LongAdder 替换 AtomicLong 进行进一步的性能提升。
1 |
|
程序中要少用反射
反射的功能很强大,但它是通过解析字节码实现的,性能就不是很理想。
现实中有很多对反射的优化方法,比如把反射执行的过程(比如 Method)缓存起来,使用复用来加快反射速度。
Java 7.0 之后,加入了新的包java.lang.invoke
,同时加入了新的 JVM 字节码指令 invokedynamic,用来支持从 JVM 层面,直接通过字符串对目标方法进行调用。