关于代码优化的技巧记录

今天刚好收到微信推送有这么一篇文章《分享16个代码优化技巧》,借着这个作者的分享,我也把里面的16个优化技巧给实操一遍,然后记录下来。当然,这16个并不是结束,只是一个开始,因为代码优化是一件非常持久的事情,不是零星几点就可以搞完的。

类成员与方法的可见性最小化

参考文章,仅供学习记录参考,如有侵权,请联系删除!

区分良好设计与不佳设计的最重要因素是,组件是否将其内部数据与实现细节隐藏起来。一个良好的设计应当隐藏所有的实现细节,将其API和实现清晰的分离开。然后组件只能通过API进行交流,而对彼此的实现细节一无所知。这就是所谓的信息隐藏或者封装,是软件设计的最基本原则。

信息隐藏非常重要,主要的原因是,它能够将组成一个系统的各个组件解耦开来,是各个组件能够独立进行开发、测试、优化、使用或者修改,这能够极大幅度提高软件开发的效率,因为各个组件能够同时进行开发。信息隐藏也减轻了维护的负担,因为组件能够被更快地理解和调试或者更换组件,而不用担心损害到其他组件。虽然信息隐藏不能直接创造良好的性能,但是它能进行有效的性能调整:一旦系统完成并且分析确定哪些组件导致了性能问题,就可能优化或者直接替换这些组件,而不用担心对其他组件造成影响。最后,信息隐藏能够降低构建大型系统的风险,因为即使系统不能正常工作,它的各个组件也可能被证明是成功可用的。

Java提供了很多机制来帮助信息隐藏。访问控制机制指定了类、接口和成员的可见性,类和成员的可见性取决于其声明的位置,以及声明中的访问控制符(private,protectpublic),正确使用这些操作符对信息隐藏至关重要。

大拇指规则(经验法则)很简单:尽量使类和成员对外界保持不可见性,也就是说能用private修饰的绝不使用protect或者public来修饰。

对于顶层(非嵌套的)类和接口来说,只有两种可能的可访问级别:包级私有和public,你可以在声明类和接口的时候使用public修饰符,这样它们就是对所有类都可见的,否则是包级私有的。包级私有类通常作为实现的一部分,你可以修改、替换或者在后续版本中删掉它,而不必担心会对现有使用者造成影响,而public的类一旦声明了,你就有义务永远维护它,以保持兼容性,所以应该小心地设计一个public的类或者接口,因为一旦设计不好,后续的维护将会十分艰难。

举个例子:如果是一个private的方法,想删除就删除。

如果一个publicservice方法,或者一个public的成员变量,删除一下,不得思考很多。

使用位移操作替代乘除法

计算机是使用二进制表示的,位移操作会极大地提高性能。

<< 左移相当于乘以 2;>> 右移相当于除以 2

>>> 无符号右移相当于除以 2,但它会忽略符号位,空位都以 0 补齐

总结:左移代替乘法,右移代替除法。【左乘右除】;注意的是使用移位应添加注释,因为移位操作不直观,比较难理解。

举个例子:

1
2
3
// 乘法(乘号)
int num = a * 4;
int num = a * 8;
1
2
3
// 乘法(位移)
int num = a << 2;
int num = a << 3;
1
2
3
// 除法(除号)
int num = a / 4;
int num = a / 8;
1
2
3
// 除法(位移)
int num = a >> 2;
int num = a >> 3;

尽量减少对变量的重复计算

我们知道对方法的调用是有消耗的,包括创建栈帧、调用方法时保护现场,恢复现场等。

1
2
3
4
5
6
7
8
9
// 反例
for (int i = 0; i < list.size(); i++) {
System.out.println("result");
}

// 正例
for (int i = 0, length = list.size(); i < length; i++) {
System.out.println("result");
}

以上的代码,当list.size()很大的时候,就可以减少很多的消耗。

不要捕捉RuntimeException

Java 类库中定义的一类 RuntimeException 可以通过预先检查进行规避,而不应该通过 catch 来处理,比如: IndexOutOfBoundsExceptionNullPointerException等等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 反例
public String test1(List<String> list, int index) {
try {
return list.get(index);
} catch (IndexOutOfBoundsException ex) {
return null;
}
}

// 正例
public String test2(List<String> list, int index) {
if (index >= list.size() || index < 0) {
return null;
}
return list.get(index);
}

使用局部变量可避免在堆上分配

首先,我们先来了解概念。根据内存管理(分配和回收)方式的不同,可以将内存分为堆内存栈内存。那这两块内存有什么区别呢?

内存名称 作用
堆内存 由内存分配器和垃圾收集器负责回收
栈内存 由编译器自动进行分配和释放

一个程序的运行过程中,可能会有多个栈内存,但是肯定只有一个堆内存。

每个栈内存都是由线程或协程独立占有,因此从栈中分配内存不需要加锁,并且栈内存再函数结束后会自动回收,性能相对堆内存要高。

而堆内存可能同时有多个线程或者协程从堆中申请内存,因此堆中申请内存需要加锁,避免造成冲突。并且堆内存在函数结束后,需要GC的介入参与,如有大量的GC操作,将导致程序性能下降严重。

了解了内存管理之后,我们来了解下我们的局部变量是从哪里分配的。

正常思路而言,局部变量的作用域仅仅在它所在的函数中,当函数返回后,所有局部变量所占用的内存空间都会被收回。所以说如果局部变量是基本类型的,那么值会直接存在栈中,如果是引用类型的,则会将其对象存在堆中,而把对象的引用(指针)存在栈中。

1
2
3
4
5
6
public void test(){
// 引用类型的局部变量
String s1 = "test";
// 基本类型的局部变量
int i = 1;
}

总结:由于堆资源是多线程共享的,是垃圾回收器工作的主要区域,过多的对象会造成 GC 压力,可以通过局部变量的方式,将变量在栈上分配。这种方式变量会随着方法执行的完毕而销毁,能够减轻 GC 的压力。

减少变量的作用范围

注意变量的作用范围,尽量减少对象的创建。

如下面的代码,变量 s 每次进入方法都会创建,可以将它移动到 if 语句内部。

1
2
3
4
5
6
public void test(String str){
final int s =100;
if(!StringUtils.isEmpty(str)){
int result = s*s;
}
}

尽量采用懒加载的策略,在需要的时候才创建

懒加载的方式避免我们创建一些非必需的对象

1
2
3
4
5
6
7
8
9
10
// 反例
String s = "hello world";
if("java".equals(name)){
list.add(s);
}
// 正例
if("java".equals(name)){
String s = "hello world";
list.add(s);
}

访问静态变量直接使用类名

首先,我们先来想一下下面这段代码如果运行会发生什么事?

1
2
3
4
5
6
7
8
9
10
public class TestDemo {
public static void main(String[] args) {
Test test = null;
System.out.println(test.a);
}

}
class Test{
static int a = 100;
}

看着上面这段代码是不是感觉肯定有问题,会导致NPE,但实际上运行之后会输出100,为啥会出现这种奇怪的现象呢?一个null也可以访问到类变量呢?

我们通过JDK自带的反汇编器javap,可以查看java编译器为我们生成的字节码,用这个工具,我们可以对照源码和字节码,从而了解很多编译器内部的工作。

1
2
javac TestDemo.java
javap -c -verbose TestDemo
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
# 反汇编结果
{
public com.gcoder.test.TestDemo();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 18: 0

public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=1
0: aconst_null
1: astore_1
2: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
5: aload_1
6: pop
7: getstatic #3 // Field com/gcoder/test/Test.a:I
10: invokevirtual #4 // Method java/io/PrintStream.println:(I)V
13: return
LineNumberTable:
line 20: 0
line 21: 2
line 22: 13
}
SourceFile: "TestDemo.java"

通过字节码指令7我们可以看到,其实在编译过程中编译器已经将代码优化成类名点访问的形式了,而使用对象访问静态变量,这种方式多了一步寻址的动作,需要先找到变量对应的类,再找到类对应的变量。

1
2
3
4
 // 反例
int i = objectA.staticMethod();
// 正例
int i = ClassA.staticMethod();

字符串拼接使用StringBuilder

首先我们先抛出结论:字符串拼接,使用 StringBuilder 或者 StringBuffer,不要使用 + 号。

为什么我们说不要使用+号对字符串进行拼接呢?其实这得从字符串拼接的性能问题来说,当我们使用+拼接,都会生成一个新的String。特别在循环拼接字符串的场景下,性能损失是极其严重的:

  1. 空间浪费:每次拼接的结果都需要创建新的不可变类
  2. 时间浪费:创建的新不可变类需要初始化;产生大量短命垃圾,影响年轻代甚至full gc

下面我们分两个场景来看看拼接的情况

  1. 较为简单的场景

    1
    2
    3
    4
    5
    6
    7
    public 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
    29
    Compiled 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(),由此我们可以看到编译器对我们代码进行的一些必要的优化,但是在一些较为复杂的场景中,这样的优化效果却并不明显!

  2. 较为复杂的场景

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    public 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
    39
    Compiled 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
2
3
4
5
private int newCapacity(int minCapacity) {
// overflow-conscious code
int newCapacity = (value.length << 1) + 2;
if (newCapacity - minCapacity < 0) {
newCapacity = minCapacity;

StringBuilder

初始容量定义:16

扩容机制和StringBuffer一样

1
2
3
4
5
6
7
8
9
10
11
public final class StringBuilder
extends AbstractStringBuilder
implements java.io.Serializable, CharSequence
public StringBuilder() {
super(16);
}
private int newCapacity(int minCapacity) {
// overflow-conscious code
int newCapacity = (value.length << 1) + 2;
if (newCapacity - minCapacity < 0) {
newCapacity = minCapacity;

循环内不要不断创建对象引用

1
2
3
4
5
6
7
8
9
10
// 反例
for (int i = 1; i <= size; i++) {
Object obj = new Object();
}

// 正例
Object obj = null;
for (int i = 0; i <= size; i++) {
obj = new Object();
}

第一种会导致内存中有size个Object对象引用存在,size很大的话,就耗费内存了。

遍历Map 的时候,使用 EntrySet 方法

使用 EntrySet 方法,可以直接返回 set 对象,直接拿来用即可;而使用 KeySet 方法,获得的是key 的集合,需要再进行一次 get 操作,多了一个操作步骤,所以更推荐使用 EntrySet 方式遍历 Map。

1
2
3
4
Set<Map.Entry<String, String>> entryseSet = nmap.entrySet();
for (Map.Entry<String, String> entry : entryseSet) {
System.out.println(entry.getKey()+","+entry.getValue());
}

不要在多线程下使用同一个 Random

Random 类的 seed 会在并发访问的情况下发生竞争,造成性能降低,建议在多线程环境下使用ThreadLocalRandom类。

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static void main(String[] args) {
ThreadLocalRandom threadLocalRandom = ThreadLocalRandom.current();
Thread thread1 = new Thread(()->{
for (int i=0;i<10;i++){
System.out.println("Thread1:"+threadLocalRandom.nextInt(10));
}
});
Thread thread2 = new Thread(()->{
for (int i=0;i<10;i++){
System.out.println("Thread2:"+threadLocalRandom.nextInt(10));
}
});
thread1.start();
thread2.start();
}

自增推荐使用LongAddr

这个点需要大家自行去验证一下~

自增运算可以通过 synchronized 和 volatile 的组合来控制线程安全,或者也可以使用原子类(比如 AtomicLong)。

后者的速度比前者要高一些,AtomicLong 使用 CAS 进行比较替换,在线程多的情况下会造成过多无效自旋,可以使用 LongAdder 替换 AtomicLong 进行进一步的性能提升。

1
2
3
4
5
6
7
8
9
10

public class Test {
public int longAdderTest(Blackhole blackhole) throws InterruptedException {
LongAdder longAdder = new LongAdder();
for (int i = 0; i < 1024; i++) {
longAdder.add(1);
}
return longAdder.intValue();
}
}

程序中要少用反射

反射的功能很强大,但它是通过解析字节码实现的,性能就不是很理想。

现实中有很多对反射的优化方法,比如把反射执行的过程(比如 Method)缓存起来,使用复用来加快反射速度。

Java 7.0 之后,加入了新的包java.lang.invoke,同时加入了新的 JVM 字节码指令 invokedynamic,用来支持从 JVM 层面,直接通过字符串对目标方法进行调用。


关于代码优化的技巧记录
https://gcoder5.com/2023/03/19/关于代码优化的技巧记录/
作者
Gcoder
发布于
2023年3月19日
许可协议