透过现象看本质,一个Java多线程问题引出的思考

读到这篇文章的朋友,请先看看下面的多线程场景下,程序的运行结果是什么? 如果你能准确的看到结果,那么请不要在这篇文章上浪费你的时间。程序很简单,主要有三个类。

类 ThreadTask : 主要通过execute方法执行Task的一个任务。这里定义的任务就是对int 型变量循环加一,直到大于或等于10为止。代码如下


public class ThreadTask {
	
	private int i = 0;  //定义ThreadTask类的一个私有变量
	
	public void execute(String threadName){
		System.out.println("Thread "+threadName+" start to execute.......");
		
		try {
			
			while(i<10){
				i++;
				System.out.println("Thread "+threadName+" increments to "+i);
				Thread.sleep(1000);
			}
			
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		System.out.println("Thread "+threadName+" execute completes.......");
	}

}

类 ThreadWrapper : 很简单,就是定义一个线程类。执行时调用ThreadTask对象的execute方法。

public class ThreadWrapper implements Runnable {

	
	private String _threadName;
	private ThreadTask _t;
	
	public void run() {
		_t.execute(this._threadName);
	}
	
	public ThreadWrapper(String ThreadName,ThreadTask t){
	   this._threadName = ThreadName;
	   this._t = t;
	}

}

另外,有一个测试类TestStub如下,它生成4个线程,并分别执行这4个线程。 所有四个线程共享一个ThreadTask实例。


public class TestStub {

	public static void main(String[] args){
		ThreadTask ts = new ThreadTask();
		for (int i =1;i<=4;i++){
			Thread t = new Thread(new ThreadWrapper("Thread" + i,ts)); //请注意,所有线程中传入的是一个ThreadTask实例。
			t.start();
		}
	}
}

那请问,上述代码执行时,各个线程之间在执行ThreadTask实例中的execute方法时是否会相互影响?私有变量i是否是线程安全的?

请看下面的执行结果示例

Thread Thread1 start to execute.......
Thread Thread1 increments to 1
Thread Thread3 start to execute.......
Thread Thread3 increments to 2
Thread Thread2 start to execute.......
Thread Thread2 increments to 3
Thread Thread4 start to execute.......
Thread Thread4 increments to 4
Thread Thread1 increments to 5
Thread Thread3 increments to 6
Thread Thread2 increments to 7
Thread Thread4 increments to 8
Thread Thread3 increments to 10
Thread Thread2 execute completes.......
Thread Thread1 increments to 10
Thread Thread4 execute completes.......
Thread Thread3 execute completes.......
Thread Thread1 execute completes.......

很明显,多个线程在同时执行ThreadTask的execute方法,同时在对私有变量i就行加法操作,他们相互影响了各自的行为。

那现在,我们来做一个简单的代码变更,将私有变量i从类ThreadTask的内部转移到其方法execute的内部。代码如下


public class ThreadTask {
	
	public void execute(String threadName){
		System.out.println("Thread "+threadName+" start to execute.......");
		int i = 0; //定义一个方法的内部局部变量i
		try {
			
			while(i<10){
				i++;
				System.out.println("Thread "+threadName+" increments to "+i);
				Thread.sleep(1000);
			}
			
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		System.out.println("Thread "+threadName+" execute completes.......");
	}

}

这时,如果再运行TestStub类,那结果会是怎样?线程还会争抢这个变量i吗?变量i是否是线程安全的呢?答案是线程没有对这个变量产生竞争,变量i是线程安全的。请看下面的运行结果

Thread Thread1 start to execute.......
Thread Thread1 increments to 1
Thread Thread2 start to execute.......
Thread Thread2 increments to 1
Thread Thread3 start to execute.......
Thread Thread3 increments to 1
Thread Thread4 start to execute.......
Thread Thread4 increments to 1
Thread Thread2 increments to 2
Thread Thread3 increments to 2
Thread Thread1 increments to 2
Thread Thread4 increments to 2
Thread Thread3 increments to 3
Thread Thread1 increments to 3
Thread Thread2 increments to 3
Thread Thread4 increments to 3
Thread Thread2 increments to 4
Thread Thread3 increments to 4
Thread Thread1 increments to 4
Thread Thread4 increments to 4
Thread Thread3 increments to 5
Thread Thread1 increments to 5
Thread Thread4 increments to 5
Thread Thread2 increments to 5
Thread Thread3 increments to 6
Thread Thread4 increments to 6
Thread Thread1 increments to 6
Thread Thread2 increments to 6
Thread Thread3 increments to 7
Thread Thread2 increments to 7
Thread Thread4 increments to 7
Thread Thread1 increments to 7
Thread Thread2 increments to 8
Thread Thread1 increments to 8
Thread Thread3 increments to 8
Thread Thread4 increments to 8
Thread Thread2 increments to 9
Thread Thread1 increments to 9
Thread Thread3 increments to 9
Thread Thread4 increments to 9
Thread Thread2 increments to 10
Thread Thread1 increments to 10
Thread Thread3 increments to 10
Thread Thread4 increments to 10
Thread Thread3 execute completes.......
Thread Thread2 execute completes.......
Thread Thread4 execute completes.......
Thread Thread1 execute completes.......

如果上述两次的输出结果你都预见到了,你可以不必往下看了。否则,请和我一起往下看看为什么会是这样?多个线程既然可以争抢同一个实例中的一个私有变量,从而产生冲突,那为什么多个线程不去争抢同一个实例中的同一个方法的局部变量呢?

为了解释清楚这个问题,需要从Java虚拟机的结构说起,让我们看看下面的Java虚拟机的体系结构图



在这里,我不打算花篇幅介绍整个JVM的结构,因为他们很容易理解,Java 类装载器装载.class文件,由执行引擎运行。如果你知道Java可以调用系统的本地方法,那么本地方法接口和本地方法库也就是顺理成章的事情。我主要把精力放在了运行时数据区上,也就是中间那个大框里的小框框。我们知道,当Java虚拟机运行一个Java程序,它需要内存来存储诸如字节码,程序创建的对象,传递给方法的参数,返回值,局部变量等信息。这些信息就存储在了上图中的一个个运行时数据区中。

为了解释上述两个多线程实例(为了引用方便,就分别称呼为例一和例二吧)的行为,我们从线程共享的角度将这些运行时数据区分为两类。第一类包括,方法区 和堆,他们是由所有的Java 虚拟机线程所共享的。第二类包括PC 寄存器,Java 栈,和本地方法栈,他们是由每个Java虚拟机线程所私有的。

读到这里,你肯定已经明白为什么例一中定义的对象的私有变量会被多个线程争抢,这个对象的私有变量肯定是在第一类运行时数据区存储,那例二中方法内定义的局部变量肯定是在第二类运行时方法区中存放。事实也确实如此,那对于例一中类对象定义的内部变量到底是存在方法区中还是堆中呢?例二中方法内部的局部变量又是存在于哪个数据区呢?

接下来,让我们先看例一中涉及到的方法区和堆吧。


方法区中主要存放被Java虚拟机装载的类的如下类型信息

  • 这个类型的全限定名
  • 这个类型的直接超类的全限定名
  • 这个类型是类类型还是接口类型
  • 这个类型的访问修饰符,例如public,abstract,或final
  • 任何直接超接口的全限定名的有序列表
  • 该类型的常量池
  • 字段信息。字段的类型,和修饰符
  • 方法信息。方法名,返回类型,参数的数量和类型,方法修饰符
  • 一个到Class类的引用
  • 一个到ClassLoader的引用
  • ...........

在Java虚拟机规范中提到方法区在逻辑上是堆的一部分。虽然规范并没有强制定义方法区物理上一定要在堆上,但这也足以告诉我们方法区和堆之间有千丝万缕的联系。例如,Java 虚拟机总是可以通过在方法区的类型信息来确定一个对象创建时从堆上需要多少内存。


中存放的是Java虚拟机运行时创建的所有类实例或数组。Java虚拟机有在堆上分配新对象的指令,但没有释放内存的指令,这些都需要靠Java虚拟机实现的垃圾回收机制类处理。Java虚拟机规范中并没有规定堆上的对象的表示方式,这给Java虚拟机的实现者很大的自由。这里列出了几种可能的方式。第一种就是将堆分成两部分,一个句柄池,一个对象池。一个对象引用就是一个指向句柄池的本地指针。句柄池的每个条目有两部分,一个指向对象实例的指针,一个指向方法区类型数据的指针。如下图所示

还有一种是使对象指针直接指向一组数据,而该数据包括对象实例数据以及指向方法区中类数据的指针。如下图所示



除了上述两种对象表示形式外,在方法区的类数据表示上,Java虚拟机的实现者可以采用方法表的形式,将类型信息作为一个索引表进行存储,这样会加快查找,但在空间上会有些浪费。这里就不做过多的介绍。因为到此为止,我们已经很清楚的看到类对象的数据是存储在堆上的。另外,这里没有对数组在堆上的表示形式进行说明,数组中的数据也是存放在堆上。


从上面对堆和方法区的介绍,我们知道例一中对象的私有变量是存放在堆上,并被Java虚拟机中的多个线程所共享。所以,会出现例一的读写冲突结果。


接下来,我们看看例二中的方法内局部变量到底是来自哪里?

PC寄存器是在线程启动时创建,当线程执行某个Java方法时,如果这个方法不是本地方法,PC寄存器包含当前被执行的Java虚拟机指令的地址。如果当前执行的方法时本地方法,那么PC寄存器中的状态将会是"Undefined"。


Java 栈 (在最新的Java虚拟机规范中,它被称为Java虚拟机栈,我们这里还是称它为Java栈,叫起来比较方便),每当启动一个新线程,Java虚拟机会为它分配一个Java栈。Java栈上所有的数据都是线程私有的。任何线程都不能访问另一个线程的栈数据。Java 栈以帧为单位保存线程的运行状态。某个线程正在执行的方法称为线程的当前方法,当前方法使用的栈帧就叫当前帧。每当线程调用一个Java方法(非本地方法)时,虚拟机都会向该线程的Java栈中压入一个新帧。而这个新帧就是当前帧,在线程执行方法时,它使用这个帧来存储参数、局部变量、中间运算结果等数据。再进一步的话,每个栈帧是由局部变量区、操作数栈、和帧数据区组成。其中,局部变量区就是用来存储对应方法的参数和局部变量的。


说到这里,我们已经很清楚的知道,例二中类对象的方法中的局部变量就是保存在Java栈中的当前栈帧的局部变量区。不同的线程独立拥有这个空间,其他线程无法访问。所以,方法内局部变量没有发生多个线程冲突的情况。


至此,我们的问题基本已经解决了,但如果你需要再了解一些本地方法栈及其与Java栈,PC寄存器之间的联系,请继续往下看

本地方法栈,Java 虚拟机可以但不限于使用C栈来实现本地方法栈。如果一个Java虚拟机的具体实现根本不需要或不允许调用本地方法(这在规范上是允许的),那么,它就没有必要提供本地方法栈。一旦提供,那本地方法栈是由每个线程私有的。当Java虚拟机调用本地方法时,虚拟机不会往Java栈中压入新的栈帧,而是转而在本地方法栈中压入新的栈帧来表示当前调用的本地方法。


下图是一个虚拟机实例的快照,能进一步说明本地方法栈,PC寄存器,和Java栈之间的联系。有三个线程在运行,线程1和线程2都在执行Java方法,线程3在执行一个本地方法。



上图中,当前正在执行的方法在 Java栈和本地方法栈中以蓝色表示。对于一个正在运行的Java方法的线程,本例中的线程一和线程二而言,它的PC寄存器总是指向当前被执行的指令。由于线程三正在运行一个本地方法,因此,它的PC寄存器的状态为"undefined",用深色表示。






  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值