一次代码走查看出来同事的问题

353 阅读8分钟

这是我参与更文挑战的第 21 天,活动详情查看: 更文挑战


1、 问题

最近结对开发中同事遇到了以下代码问题:

 private void checkRequest(Request request) throws CommonException {
    checkType(request.getType());
    checkProduct(request.getProducts());
    checkIds(request.getIds());
 }

 private void checkIds(List<String> ids) throws CommonException {
    if (ids.size() > 200) {
        throw new CommonException(CELL_MAX_NUM);
    }
    for (String id : ids) {
        if (isIdInvalid(id)) {
            ids.remove(id);
        }
    }
}

此方法是校验id入参是否合法,然后去掉不合法的id,通过UT发现报错,原来是for循环的过程中删除元素,会报ConcurrentModificationException.

2、修改

于是该同事修改此代码为:

private void checkIds(List<String> ids) throws CommonException {
    if (ids.size() > 200) {
        throw new CommonException(CELL_MAX_NUM);
    }
    ids = ids.stream().filter(id -> !isIdInvalid(Id)).collect(Collectors.toList());
    log.print("Valid ids is: " + ids);
}

这样改对了吗???有问题吗???有没有更好的方式呢???

2.1 优化一

第一出现ConcurrentModificationException是由于在循环操作是List的size变化导致删除失败,一般的推荐改法为用迭代器替换foreach循环:

 Iterator<String> iterator = ids.iterator();
    while(iterator.hasNext()){
        String id = iterator.next();
        if(isIdInvalid(id)){
            iterator.remove();
    }
 }

2.2 优化二

如果使用的JDK8以上的版本可以使用removeIf方法:

ids.removeIf(this::isIdInvalid);

2.3 优化三

如果还是想用filter方法,现在再来看:

ids.stream().filter(id -> !isIdInvalid(id)).collect(Collectors.toList());

ids是方法入参,通过上述处理后ids过滤掉不合法id,但是入参request.ids是否会改变呢???到此, 引出本文的主题:java方法参数的传递机制

3、值传递?引用传递?

java方法参数的传递到底是值传递还是引用传递???以前看过一些网上资料说java方法参数如果是基本类型则为值传递,如果是引用类型则为引用传递,这样的说法的得来是有现象推导出的

结论,如下所示:

3.1 基本类型时:

public static void change(int a) {
    a = 50;
    System.out.println("change: " + a);
}

public static void main(String[] args) {
    int a = 10;
    System.out.println("before:" + a);
    change(a);
    System.out.println("after:" + a);
}
输出-》before:10
	  change:50
	  after:10

3.2 引用对象类型时:

public static void main(String[] args) {
    Person person = new Person("steven", "18");
    System.out.println("before:" + person);
    change(person);
    System.out.println("after:" + person);
}

public static void change(Person person) {
    person.setName("niu");
    System.out.println("change: " + person);
}

输出:before:Person(name=steven, age=18)
	 change: Person(name=niu, age=18)
	 after:Person(name=niu, age=18)

3.3 现象推结论

通过两个例子可以看出基本类型时方法内改变方法入参并不会影响方法调用的参数,引用对象作为方法参数时方法内修改也会影响到方法外数据, 但是仅仅看现象就说方法参数的是值传递还是引用传递就是对的吗???我们还是应该从事物的本质去分析为什么基础类型方法内修改不会影响到外面,而引用类型就会影响。

要说明这些问题还是要从java内存模型说起,JVM内存可以简单划分为堆内存、栈内存。

4、 参数传递本质

Java里的方法参数传递类似于西游记里的孙悟空,孙悟空复制了一个假的孙悟空,这个假孙悟空具有与孙悟空相同的能力,可除妖或被砍头, 但不管假孙悟空遇到什么事,真孙悟空不会受到任何影响。与此类似,传入方法的实际参数的复制品,不管方法中对这个复制品如何操作, 实际参数本身不会受到任何影响。

4.1 形参为基本类型

在main方法中调用change方法,main方法还未结束,所以jvm会为main和change方法都分配栈区,main方法中的a变量传入change方法 赋给change方法中a,实际上change方法栈中重新生成了一个变量a,并将main方法中a赋值给change栈区的a(也就是对change方法形参进行了初始化)。 这时在change方法中a更改不会对main方法中a有任何影响。

4.2 形参为引用类型

但是引用对象作为方法形参时,在change方法更改,主调函数中也相应的变化,此时很容易造成一种错觉,调用change方法就是入参本身,而不是它的复制品, 但只是一种错觉,结合内存模型分析下:

程序从main方法开始执行,main方法开始创建了一个Person对象,并定义了person变量来指向Person对象,这个地方与基本类型不同。创建一个对象时,jvm会分配两块内存 ,一个在堆内存中分配内存保存对象本身,另一个会在栈内存中分配内存保存引用该对象的引用变量,接着程序通过引用来操作Person对象, 把该对象的两个成员变量name、age分别 赋值“steven”、“18”,此时系统中内存存储如下所示:

接下来main方法开始调用change方法,main方法并未结束,jvm分别为main和change分配两个栈区内存,调用change方法时,person作为实参传入change方法, 同样采用的是值传递,把main方法中的person变量赋值给change形参完成形参person变量的初始化,此时change方法中的person变量只是保存了main方法中Person对象的 一个指针引用,这个引用也是指向main方法中的同一个Person对象,内存存储如下:

这种参数传递同样是复制了一个person的副本传入change方法,复制的是一个引用变量的副本,所以在change方法通过变量person修改时还是对同一个Person对象 操作,操作的是同一个对象,因此在change方法中改变person时,在main方法中也随之改变。

4.3 方法内重新指向

当在change方法中把person指向别的堆内存时可以看出对main方法中person没有任何影响,

public static void main(String[] args) {
    Person person = new Person("steven", "18");
    System.out.println("before:" + person);
    change(person);
    System.out.println("after:" + person);
}

public static void change(Person person) {
    person = new Person("niu", "20");;
    System.out.println("change: " + person);
}

输出:before:Person(name=steven, age=18)
     change: Person(name=niu, age=20)
     after:Person(name=steven, age=18)

5、特殊类型

按照以上分析,当行参类型为String时,结果是什么呢???

public static void main(String[] args) {
    String name = "steven";
    System.out.println("before:" + name);
    change(name);
    System.out.println("after:" + name);
}

public static void change(String name) {
    name = "niu";
    System.out.println("change: " + name);
}

输出-》before:steven
	  change: niu
	  after:steven

按照分析String为引用类型,传给change方法的是引用的副本,在change方法中修改引用应该修改的同一个String对象, 为什么change方法修改后没有影响到main方法的String对象呢?

这要从String这个类说起,String类声明如下:

public final class String implements Serializable, Comparable<String>, CharSequence 

5、1 常量池的设计

可以看到String被设计成不可变和不能被继承的,String是不可变和不能被继承的(final修饰),这样设计的原因主要是为了设计考虑、效率和安全性。只有当字符串是不可变的,字符串池才有可能实现。字符串池的实现可以在运行时节约很多h堆空间,因为不同的字符串变量都指向池中的同一个字符串。假若字符串对象允许改变,那么将会导致各种逻辑错误,比如改变一个对象会影响到另一个独立对象. 严格来说,这种常量池的思想,是一种优化手段,所有的String对象都存在于常量池中,字符串常量池存在运行时常量池之中(在JDK7之前存在运行时常量池之中,在JDK7已经将其转移到堆中)。

字符串常量池的存在使JVM提高了性能和减少了内存开销。

使用字符串常量池,每当我们使用字面量(String s=”***”;)创建字符串常量时,JVM会首先检查字符串常量池,如果该字符串已经存在常量池中,那么就将此字符串对象的地址赋值给引用s(引用s在Java栈中)。如果字符串不存在常量池中,就会实例化该字符串并且将其放到常量池中,并将此字符串对象的地址赋值给引用s(引用s在Java栈中)。

所以当在change方法中调用:

 name = "niu";

实际相当于调用:

 name= new String("niu");

此时change方法内name指向了另一个String对象"niu",主调方法main方法中name指向还是原来String对象 "steven"。

同样印证了java方法参数传递是值传递的理论。

6、结论

java方法参数的传递不论参数是基本类型还是应用类型都是值传递(也就是栈区副本传递),具体需要结合java内存模型分析,回过头来看原本的问题:

ids = ids.stream().filter(id -> !isIdInvalid(id)).collect(Collectors.toList());

这段代码处理后ids被重新指向了Collectors.toList(),一个新的List对象,所以在checkId方法中操作并不能对方法外的request.ids造成影响。因此如果非要用filter方法实现去掉不合法的id可以采用去下方法:

request.setIds(getValidIds(request.getIds()));

获取合法id然后返回重新赋值给方法外参数使用

private List<String> getValidIds(List<String> ids) throws CommonException {
    if (ids.size() > 200) {
        throw new CommonException(CELL_MAX_NUM);
    }
    return ids.stream().filter(id -> !isIdInvalid(id)).collect(Collectors.toList());
}