1 jvm内存模型是啥?
内存模型分堆栈区
简单说heap区存放:各种对象实例,stack区存放:基本的数据类型和引用
这样设计目的是啥?
• 栈存储了处理逻辑、堆存储了具体的数据,这样隔离设计更为清晰
• 堆与栈分离,使得堆可以被多个栈共享
• 栈保存了上下文的信息,因此只能向上增长;而堆是动态分配
堆栈模型一表
名称 |
特征 |
作用 |
配置 |
异常 |
栈区 |
线程私有,使用一段连续的内存空间 |
存放局部变量表、操作栈、动态链接、方法出口 |
-Xss 设置每个线程的堆栈大小。JDK5.0以后每个线程堆栈大小为1M,以前每个线程堆栈大小为256K。更具应用的线程所需内存大小进行调整。在相同物理内存下,减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右 |
StackOverflowError OutOfMemoryError |
堆 |
线程共享,生命周期与虚拟机相同 |
保存对象实例 |
-Xms -Xmx -Xmn -Xmn用来设置堆内新生代的大小。通过这个值我们也可以得到老生代的大小:-Xmx减去-Xmn |
OutOfMemoryError |
程序计数器 |
线程私有、占用内存小 |
字节码行号 |
无 |
无 |
方法区 |
线程共享 |
存储类加载信息、常量、静态变量等 |
-XX:PermSize -XX:MaxPermSize -XX:PermSize=64M JVM初始分配的非堆内存 -XX:MaxPermSize=128M JVM最大允许分配的非堆内存,按需分配 非堆就是JVM留给自己用的, 所以方法区、JVM内部处理或优化所需的内存(如JIT编译后的代码缓存)、每个类结构(如运行时常数池、字段和方法数据)以及方法和构造方法的代码都在非堆内存中 |
OutOfMemoryError |
2 分代与回收
heap为什么要分代?
很多对象都是朝生夕死的,如果分代的话,我们把新创建的对象放到某一地方,当minor GC的时候先把这块存“朝生夕死”对象的区域进行回收,这样就会腾出很大的空间出来
年轻代回收?
HotSpot JVM把年轻代分为了三部分:1个Eden区和2个Survivor区(分别叫from和to)。默认比例为8:1,一般情况下,新创建的对象都会被分配到Eden区(一些大对象特殊处理),这些对象经过第一次Minor GC后,如果仍然存活,将会被移到Survivor区。对象在Survivor区中每熬过一次Minor GC,年龄就会增加1岁,当它的年龄增加到一定程度时,就会被移动到年老代中。
因为年轻代中的对象基本都是朝生夕死的(80%以上),所以在年轻代的垃圾回收算法使用的是复制算法,复制算法的基本思想就是将内存分为两块,每次只用其中一块,当这一块内存用完,就将还活着的对象复制到另外一块上面。复制算法不会产生内存碎片
分析:首次Minor GC时候,对象只会存在于Eden区和名为“From”的Survivor区,Survivor区“To”是空的。紧接着进行Minor GC,Eden区中所有存活的对象都会被复制到“To”,而在“From”区中仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置)的对象会被移动到年老代中,没有达到阈值的对象会被复制到“To”区域。经过这次Minor GC后,Eden区和From区已经被清空。这个时候,“From”和“To”会交换他们的角色,也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”。不管怎样,都会保证名为To的Survivor区域是空的。Minor GC会一直重复这样的过程,直到“To”区被填满,“To”区被填满之后,会将所有对象移动到年老代中
.............
3 jvm模型分析java多线程应用
再看jvm模型
根据各个区域的定义,我们可以知道:
1. 类变量 保存在“方法区”
2. 实例变量 保存在“堆”
3. 局部变量 保存在 “虚拟机栈”
“方法区”和“堆”都属于线程共享数据区,“虚拟机栈”属于线程私有数据区。
因此,局部变量是不能多个线程共享的,而类变量和实例变量是可以多个线程共享的。事实上,在java中,多线程间进行通信的唯一途径就是通过类变量和实例变量。也就是说,如果一段多线程程序中如果没有类变量和实例变量,那么这段多线程程序就一定是线程安全的。
以Web开发的Servlet为例,一般我们开发的时候,自己的类继承HttpServlet之后,重写doPost、doGet处理请求,不管我们在这两个方法里写什么代码,只要没有操作类变量或实例变量,最后写出来的代码就是线程安全的。如果在Servlet类里面加了实例变量,就很可能出现线程安全性问题,解决方法就是把实例变量改为ThreadLocal变量,而ThreadLocal实现的含义就是让实例变量变成了“线程私有”的,即给每一个线程分配一个自己的值。
现在我们知道:其实多线程根本的问题只有一个:线程间变量的共享,这里的变量,指的就是类变量和实例变量,后续的一切,都是为了解决类变量和实例变量共享的安全问题
如何安全的共享变量
现在唯一的问题就是要让多个线程安全的共享变量(下文中的变量一般特指类变量和实例变量),上文提到了一种ThreadLocal的方式,其实这种方式并不是真正的共享,而是为每个线程分配一个自己的值。
比如现在有一个特别简单的需求,有一个类变量a=0,现在启动5个线程,每个线程执行a++;如果用ThreadLocal的方式,最后的结果就是5个线程都拥有一份自己的a值,最终结果都是1,这显然不符合我们的预期。
那么如果不使用ThreadLocal呢?直接声明一个类变量a=0,然后让5个线程分别去执行a++;这样结果依旧不对,而且结果是不确定的,可能是1,2,3,4,5中的任一个。这种情况叫做竞态条件(Race Condition),要理解竞态条件先要理解Java内存模型:要理解java的内存模型,可以类比计算机硬件访问内存的模型。由于计算机的cpu运算速度和内存io速度有几个数量级的差距,因此现代计算机都不得不加入一层尽可能接近处理器运算速度的高速缓存来做缓冲:将内存中运算需要使用的数据先复制到缓存中,当运算结束后再同步回内存。如下图:
为了保证共享数据在同一时刻只被一个线程使用,我们有一种很简单的实现思想,就是在共享数据里保存一个锁,当没有线程访问时,锁是空的,当有第一个线程访问时,就在锁里保存这个线程的标识并允许这个线程访问共享数据。在当前线程释放共享数据之前,如果再有其他线程想要访问共享数据,就要等待锁释放。
我们把这种思想的三个关键点抽出来:
1. 在共享数据里保存一个锁
2. 在锁里保存这个线程的标识
3. 其他线程访问已加锁共享数据要等待锁释放
Jvm同步实现
可以说jvm中的三种锁都是以上述思想为基础的,只是实现的“重量级”不同,jvm中有以下三种锁(由上到下越来越“重量级”):
1. 偏向锁
2. 轻量级锁
3. 重量级锁
其中重量级锁是最初的锁机制,偏向锁和轻量级锁是在jdk1.6加入的,可以选择打开或关闭。如果把偏向锁和轻量级锁都打开,那么在java代码中使用synchronized关键字的时候,jvm底层会尝试先使用偏向锁,如果偏向锁不可用,则转换为轻量级锁,如果轻量级锁不可用,则转换为重量级锁。具体转换过程下面会讲。
要想深入了解这3种锁需要了解对象的内存结构(MarkWord头),会涉及到字节码的内部存储格式,但是其实我觉得脱离细节的实现,单从原理上理解这三个锁是很容易的,只需要了解两个大体的概念:
MarkWord:java中的每个对象在存储的时候,都有统一的数据结构。每个对象都包含一个对象头,称为MarkWord,里面会保存关于这个对象的加锁信息。
Lock Record: 即锁记录,每个线程在执行的时候,会有自己的虚拟机栈,当个方法的调用相当于虚拟机栈里的一个栈帧,而Lock Record就位于栈帧上,是用来保存关于这个线程的加锁信息。
最初jvm没有前两种锁(前两种都是jdk1.6才引入的),只有重量级锁。
我们之前给出了同步基本思想的三个点,我们也说了jvm的三种锁都是以基本思想为基础的,而这三种锁在第1、2点的实现上本质上是一样的:
1. 在共享数据里保存一个锁 : java同步是通过synchronized关键字实现的,synchronized有三种用法:一种是同步块,这种用法需要指明一个锁定对象;一种是修饰静态方法,这种用法相当于锁定Class对象;一种是修饰普通方法,这种用法相当于锁定方法所在的实例对象。因此,在java里能够被synchronized关键字锁定的一定是对象,因此就要在对象里保存一个锁,而对象内存结构里的MarkWord就可以认为是这个锁。三种锁虽然实现细节不同,但是都是使用MarkWord保存锁的
2. 在锁里保存这个线程的标识 : 偏向锁是在MarkWord里保存线程id,轻量级锁是在MarkWord里保存指向拥有锁的线程栈中锁记录的指针,重量级锁是在MarkWord中保存指向互斥量的指针(互斥量只向一个线程授予对共享资源的独占访问权,可以认为是记录了线程的标识)而区分这三种锁的关键,就是同步基本思想的第三点
3. 其他线程访问已加锁共享数据要等待锁释放 : 这里的等待锁释放是一个抽象的说法,并没有严格要求怎么等待。而重量级锁因为使用了互斥量,这里的等待就是线程阻塞。使用互斥量可以保证所有情况下的并发安全,但是使用互斥量会带来较大的性能消耗。而且在实际的项目代码中,很可能一段本来不会有并发情况的代码被加了锁,这样每次使用互斥量就白白消耗了性能。能不能先假设被加锁的代码不会有并发的情况,等到发现有并发的时候再使用互斥量呢?答案是可以的,轻量级锁和偏向锁都是基于这种假设来实现的
轻量级锁
轻量级锁的核心思想就是“被加锁的代码不会发生并发,如果发生并发,那就膨胀成重量级锁(膨胀指的锁的重量级上升,一旦升级,就不会降级了)”;轻量级锁依赖了一种叫做CAS(compare and swap)的操作,这个操作是由底层硬件提供相关指令实现的;CAS操作需要3个参数,分别是内存位置V,旧的期望值A和新值B。CAS指令执行时,当且仅当V当前值符合旧值A时,处理器用新值B更新V的值,否则不执行更新。上述过程是一个原子操作
轻量级锁加锁
假设现在开启了轻量级锁,当第一个线程要锁定对象时,该线程首先会在栈帧中建立Lock Record(锁记录)的空间,用于存储对象目前MarkWord的拷贝,然后虚拟机将使用CAS操作尝试将对象的MarkWord更新为指向线程锁记录的指针。如果操作成功,则该线程获得对象锁。如果失败,说明在该线程拷贝对象当前MarkWord之后,执行CAS操作之前,有其他线程获取了对象锁,我们最开始的假设“被加锁的代码不会发生并发”失效了。此时轻量级锁还不会直接膨胀为重量级锁,线程会自旋不停地重试CAS操作寄希望于锁的持有线程主动释放锁,在自旋一定次数后如果还是没有成功获得锁,那么轻量级锁要膨胀为重量级锁:之前成功获取了轻量级锁的那个线程现在依旧持有锁,只是换成了重量级锁,其他尝试获取锁的线程进入等待状态。
轻量级锁解锁
轻量级锁的解锁也是用CAS来操作,如果对象的MarkWord中依然是持有锁线程的锁记录指针,则CAS成功,把锁记录中的原MarkWord的拷贝复制回去,解锁完成;如果对象的MarkWord中保存的不再是持有锁线程的锁记录指针,说明在持有锁线程持有锁期间,这个轻量级锁已经因为其它线程并发获取膨胀为了重量级锁,因此线程在释放锁的同时,还要唤醒(notify)等待的线程
偏向锁
根据轻量级锁的实现,我们知道虽然轻量级锁不支持“并发”,遇到“并发”就要膨胀为重量级锁,但是轻量级锁可以支持多个线程以串行的方式访问同一个加锁对象。比如A线程可以先获取对象o的轻量锁,然后A释放了轻量锁,这个时候B线程来获取o的轻量锁,是可以成功获取得,以这种方式可以一直串行下去。之所以能实现这种串行,是因为有一个释放锁的动作。那么假设有一个加锁的java方法,这个方法在运行的时候其实从始至终只有一个线程在调用,但是每次调用完却也要释放锁,下次调用还要重新获得锁。
那么我们能不能做一个假设:“假设加锁的代码从始至终就只有一个线程在调用,如果发现有多于一个线程调用,再膨胀成轻量级锁也不迟”。这个假设,就是偏向锁的核心思想
核心实现
偏向锁的核心实现很简单:假设开启了偏向锁,当第一个线程尝试获得对象锁的时候,也会在栈帧中建立Lock Record锁记录,但是这个Lock Record空间不需要初始化(后面会用到它),然后直接用CAS将自己的线程ID写到对象的MarkWord里,如果CAS操作成功,就获取了偏向锁。线程获取偏向锁后即便是执行完加锁的代码块,也会一直持有锁不会主动释放。因此这个线程以后每次进入这个锁相关的代码块的时候,都不需要执行任何额外的同步操作。
当有另外一个线程尝试获得锁的时候,需要进行revoke操作,分情况讨论:
1. 判断持有偏向锁的线程是否还活着,如果线程不处于活动状态,则偏向锁被重置为无锁状态。
2. 如果持有偏向锁的线程还活着而且当前线程实际没有持有着锁,则偏向锁被重置为无锁状态。
3. 如果持有偏向锁的线程还活着而且当前线程实际持有着锁(在同步代码块中),那么试图获得偏向锁的线程将等待一个全局安全点(global safepoint),在全局安全点,【试图获得偏向锁的线程】操作【持有偏向锁的线程的线程栈】,遍历里面的所有栈帧里的所有与当前锁对象相关联的LockRecord,修改LockRecord里的内容为轻量级锁的LockRecord应该有的内容,然后把“最老的”(oldest)一个LockRecord的指针写到对象的MarkWord里,至此,就好像是原来从没有使用过偏向锁,使用的一直是轻量级锁
上面的第3点基本是照着官方文档翻译的,看了一些书、博客,对这块都说的不明白。其实抛开实现的细节,java的多线程很简单, java多线程主要面临的问题就是线程安全问题
线程安全问题是由线程间的通信造成的,多个线程间不通信就没有线程安全问题后续将实例剖析....