Java并发的机制的背后是Java虚拟机(JVM)的工作机制,本文从几个关于并发和多线程的疑问开始,引出Java内存区域的介绍,希望能帮助大家更好的理解Java并发机制。
1. 线程创建和切换的代价——JVM的内存区域
在《从任务到线程:Java结构化并发应用程序》和《尝试Java加锁新思路:原子变量和非阻塞同步算法》中,曾经分别介绍过,创建线程和线程间切换对于性能和资源的消耗是不容忽视的,无限制地创建线程会消耗过多的内存资源并不可取,过多的线程间上下文切换也会降低多线程并发的性能。但是线程创建和切换的代价到底是怎么产生的呢?这就不得不提到Java的运行时数据区了。
1.1 JVM运行时数据区
根据《Java虚拟机规范》 JVM 将所管理的内存区域划分为 Method Area(方法区),Heap(堆),Program Counter Register(程序计数器), VM Stack(虚拟机栈),Native Method Stack (本地方法栈),其中Method Area和Heap是线程共享的,VM Stack,Native Method Stack 和Program Counter Register是线程隔离的。
如果读者对于JVM运行时数据区不是很了解,由于篇幅有限,请参看JVM初探 -JVM内存模型和浅析Java虚拟机结构与机制,这里不再展开说明,只提供一份思维导图,帮助大家梳理内容:
概括地说来,JVM初始运行的时候都会分配好Method Area(方法区)和Heap(堆),而JVM 每遇到一个线程,就为其分配一个Program Counter Register(程序计数器), VM Stack(虚拟机栈)和Native Method Stack (本地方法栈),当线程终止时,三者(虚拟机栈,本地方法栈和程序计数器)所占用的内存空间也会被释放掉。
1.2 线程创建的内存代价
每当有线程被创建的时候,JVM就需要为其在内存中分配虚拟机栈和本地方法栈来记录调用方法的内容,分配程序计数器记录指令执行的位置,这样的内存消耗就是创建线程的内存代价。
内存作为有限的资源,如果JVM创建了过多的线程,必然会导致资源的耗尽。因此,使用Executor架构复用线程可以节省内存资源,是十分必要的。
1.3 线程切换的性能代价
JVM的并发是通过线程切换并分配时间片执行来实现的. 在任何一个时刻, 一个处理器内核只会执行一条线程中的指令。因此, 为了线程切换后能恢复到正确的执行位置, JVM需要先保存被挂起线程的上下文环境:将线程执行位置保存到程序计数器中,将调用方法的信息保存在栈中;同时将待执行线程的程序计数器和栈中的信息写入到处理器中,完成线程的上下文切换。维护线程隔离数据区中的内容在处理器中的导入导出,就是线程切换的性能代价。
控制线程上下文切换次数的方法有很多:
- 使用基于CAS的非拥塞算法,详见尝试Java加锁新思路:原子变量和非阻塞同步算法;
- 无锁并发编程,尽量使用线程封闭(ThreadLocal)或者不变量,而不是用锁,详见对象共享:Java并发环境中的烦心事;
- 使用线程池+等待队列的方式,控制线程数目,详见从任务到线程:Java结构化并发应用程序;
2. 对象访问的定位 VS 内存可见性
根据JVM运行时的内存模式,在一个方法中使用某个变量,JVM要现在栈中找到该变量的引用,然后通过引用找到该对象在堆中保存到实例数据,如下图所示:
但是这个过程为什么会出现多线程间的内存可见性的问题呢?
这个过程从单个线程的角度来说,是没有问题的,线程可以找到自己需要的变量,但是得到的变量不一定是最新的,这是由于主存和缓存内容不一致造成的。
JVM不光有内存区域的划分,还有内存模式(JMM)来控制Java线程见的通信,其决定了一个线程的共享变量的写入合适对另一个线程可见。在Java中通过多线程机制使得多个任务同时执行处理,所有的线程共享JVM内存区域主存(main memory),而每个线程又单独的有自己的工作内存,当线程与内存区域进行交互时,数据从主存拷贝到工作内存,进而交由线程处理(操作码+操作数),数据写入时,先被写入到工作缓存中,JMM选择合适的时机将其同步到主存中,以此提高访问效率。
工作缓存其实是一个抽象的概念,Java的内存分区中并没有专门的一块区域叫线程的工作缓存,其实际上是缓存、对读写缓冲区、寄存器以及其他硬件和编译器优化的统称。
因此虚拟栈到Java堆中寻找实例数据和JMM并不矛盾,二者是JVM不通角度的阐述。
3. 对象的创建——静态区域加载的多线程安全性
为了保证多线程安全性,一些必要的操作都需要加锁来保证其原子性和可见性,但是类中静态区的代码是不需要加锁就能保证多线程安全性。这是因为什么呢?
答案在于JVM类加载过程的保护机制。和普通类的实例被分配在Java堆上不同,类的静态属性都保存在方法区,其创建收到类加载过程的影响。
类的加载过程大题分为:加载(Loading),连接(Linking),初始化(Initialization),使用(Using)和卸载(UnLoading)五个步骤。这里和静态属性有关的主要是连接和初始化:在连接步骤的准备阶段,静态属性会分配内存;在初始化步骤,JVM会生成一个特别的方法——<clinit>
方法来专门执行静态代码块和静态变量初始化。
<clinit>
方法执行的过程中,JVM会对类加锁,保证在多线程环境下,只有一个线程能成功执行<clinit>
方法,其他线程都将被拥塞,并且<clinit>
方法只能被执行一次,被拥塞的线程被唤醒之后也不会再去执行<clinit>
方法。如果类有继承关系,JVM还会保证父类的<clinit>
方法将先于子类的<clinit>
方法执行。
由此可见,静态代码块的多线程安全性是由JVM为其加锁实现的,这也是延迟初始化占位类模式的安全性基础,详见《从Java内存模型角度理解安全初始化》。