在JDK的Java类源码里面,很多工具包的代码都有在使用某个成员变量之前,先拷贝该变量的对象引用到方法的局部变量之中,如下:
比如LinkedList:
public E peek() {
final Node<E> f = first;
return (f == null) ? null : f.item;
}
HashMap里面:
public Set<K> keySet() {
Object var1 = this.keySet;
if (var1 == null) {
var1 = new HashMap.KeySet();
this.keySet = (Set)var1;
}
return (Set)var1;
}
同样的还有Stringlim的trim方法:
public String trim() {
int len = value.length;
int st = 0;
char[] val = value; /* avoid getfield opcode */
while ((st < len) && (val[st] <= ' ')) {
st++;
}
while ((st < len) && (val[len - 1] <= ' ')) {
len--;
}
return ((st > 0) || (len < value.length)) ? substring(st, len) : this;
}
一些朋友,可能认为这是多此一举,明明直接使用成员变量就可以了,为什么还要拷贝一次呢,然后再使用,这样做的好处是什么?
JDK大神写的代码,肯定有其考虑的地方,我们来分析下,这么做的好处:
(1)首先方法里面的变量,都在栈上存储,而成员变量的地址是在堆上存储,而栈的存储,通常情况下都会在cpu的缓存里面,堆的存储一般是在主存里面,cpu的缓存要比主内存的块,所以这种写法,在一定程度上算是一种优化,在执行的时候会通过JIT来完成,尤其是在方法里面的循环中使用,比如trim的方法里例子。
(2)还有一种情况,在方法里面,先拷贝引用地址到本地变量,在栈里面是线程安全的,所以对于方法里面的引用本身来说是不会再存在被别的线程修改的风险,期间,如果另外一个线程修改了这个成员变量的引用地址,那么对于已经拷贝的引用,其实是没有感知的。所以从某种程度上说,拷贝引用地址相当于是一个视图。但是需要注意虽然引用地址不变,但是如果引用的内容变了,那么还能被看到的,所以在使用的时候应该注意二者的区别。
下面我们通过一个例子来看一下,先定义一个共享类:
public class ThreadShare {
public String text="str-0";
public int count=0;
public Cat cat1;
public Cat cat2=new Cat();
static class Cat{
public String name="none";
}
}
上面是共享类里面定义的一些成员变量,现在我们制造一个这样的场景,把同一个类,传递给两个线程,一个是print线程,一个是update线程,首先在print线程的run方法里面,会把这些成员变量都拷贝到local变量里面,然后接着打印一变,接着我们让print线程sleep几秒,同时启动update线程修改这些成员变量的值,最后,我们在分别打印本地local变量的值与直接访问成员变量的值,看看有什么变量。
public static void main(String[] args) throws InterruptedException {
ThreadShare share=new ThreadShare();
Thread printThread=new Thread(new PrintThread(share));
printThread.setName("打印线程");
printThread.start();
Thread.sleep(1000);
Thread updateThread=new Thread(new UpdateThread(share));
updateThread.setName("更新线程");
updateThread.start();
}
public static class UpdateThread implements Runnable{
private ThreadShare threadShare;
public UpdateThread(ThreadShare threadShare) {
this.threadShare = threadShare;
}
@Override
public void run() {
System.out.println(Thread.currentThread().getName()+" 修改成员变量的值......");
threadShare.text="update";
threadShare.count=10;
// threadShare.cat.name="i am tom";
threadShare.cat1=new Cat();
threadShare.cat2.name="cat2";
}
}
public static class PrintThread implements Runnable{
private ThreadShare threadShare;
public PrintThread(ThreadShare threadShare) {
this.threadShare = threadShare;
}
@Override
public void run() {
String threadName=Thread.currentThread().getName();
String text=threadShare.text;
int count=threadShare.count;
Cat cat1=threadShare.cat1;
Cat cat2=threadShare.cat2;
System.out.println(threadName+" 初始值 "+text+" "+count+" "+cat1+" "+cat2.name);
System.out.println();
try {
TimeUnit.SECONDS.sleep(4);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println();
System.out.println(threadName+"局部变量show: "+text+" "+count+" "+cat1+" "+cat2.name);
System.out.println(threadName+"成员变量show: "+threadShare.text+" "+threadShare.count+" "+threadShare.cat1+" "+cat2.name);
}
}
输出结果如下:
打印线程 初始值 str-0 0 null none
更新线程 修改成员变量的值......
打印线程局部变量show: str-0 0 null cat2
打印线程成员变量show: update 10 basic.reference.ThreadShare$Cat@57eff98f cat2
从上面的结果里面,能够看到最后局部变量的值,对于拷贝的引用是没有变化的,但如果是引用的属性变化了(cat2),是可以看到的,这是因为对象数据是在堆上获取的,接着我们看成员变量的值,发现成员变量能看到所有最新的变换,这是因为成员变量的数据就是从堆上获取的。
在上篇文章里面,我们谈到过栈和堆的区别,其中栈里面能够存储基本类型的数据值,还有引用类型的地址值,如果在Java多个线程里面,都访问同一个共享对象,那么要记住,对于引用类型,多个线程里面都会拷贝一份引用地址,对于基本类型就是值本身,多个线程都对基本类型的修改,那么其他的线程是看不到的,谁最后完成就会覆盖之前的结果,对于引用类型,因为对象在堆里面是共享的,实际上引用地址操作的都是同一个对象,所以多线程修改会造成不可预料的结果,这也是为什么在操作共享变量的时候一定需要使用同步和锁的手段来保证正确性的原因。
总结:
本文主要介绍了在JDK的源码里面,针对一些拷贝成员变量到local变量的代码片段做了分析,理解这个问题的本质在于理解堆和栈的区别,以及Java引用(指针)概念,还有Java内存模型对操作系统映射抽象,此外,方法里面的local变量,还只能被final修饰,只要在使用这个变量前初始化即可,这种用法的目的,其实是为了更好的代码可读性,使用final标记的变量,用来明确的来告诉我们这个类型,在声明之后不能再改变了,这些良好的编程习惯,我们可以日常开发中都可以吸收借鉴,从而写出效率更好,可读性更强的代码。