本文大量内容系转载自以下文章,有删改,并参考其他文档资料加入了一些内容:
Java虚拟机(JVM)你只要看这一篇就够了!
作者:Java程序员-张凯
jvm系列(二):JVM内存结构
作者:Phodal 纯洁的微笑
BAT技术java面试必考题:深入详解JVM内存模型与JVM参数详细配置
作者:优知学院
hotpot java虚拟机Class对象是放在 方法区 还是堆中 ?
作者:潜龙勿用
Java内存区域剖析 —— 定位OutOfMemory异常之前的必修课
作者:SexyCode
Java8 Non-Heap 中的metaspace 和compressed class space解释
作者:chenchene128
JVM源码分析之线程局部缓存TLAB
作者:占小狼
RednaxelaFX、你假笨关于TLAB的一些分析总结
作者:占小狼
JVM之逃逸分析以及TLAB
作者:heyong
Java中几种常量池的区分
作者:holos
图解Java多线程
作者:任何忧伤,都抵不过世界的美丽
hellozhxy
作者:holos
转载仅为方便学习查看,一切权利属于原作者,本人只是做了整理和排版,如果带来不便请联系我删除。
Eden
空间、From Survivor
空间、To Survivor
空间,默认情况下年轻代按照8:1:1的比例来分配;因为JDK1.6之前,多线程在堆上创建对象申请内存时,通过锁或指针碰撞方式来确保不会申请到同一块内存,且JVM Runtime内存分配机器频繁,所以这样会降低性能。
Hotspot JDK1.6引入了TLAB(ThreadLocalAllocBuffer),即JVM堆内部会划分出多个线程私有的分配缓冲区。如果设置了虚拟机参数 -XX:UseTLAB,在线程初始化时,同时也会申请一块指定大小的内存,只给当前线程使用,这样每个线程都单独拥有一个Buffer,如果需要分配内存,就在自己的Buffer上分配,这样就不存在竞争的情况,可以大大提升分配效率,当Buffer容量不够的时候,再重新从Eden区域申请一块继续使用,这个申请动作还是需要原子操作的。
说白了,TLAB的目的就是在为新对象分配内存空间时,让每个Java应用线程能使用自己专属的分配指针来分配空间,均摊对GC堆(eden区)里共享的分配指针做更新而带来的同步开销。
TLAB只是让每个线程有私有的分配指针,但底下存对象的内存空间还是给所有线程访问的,只是其它线程无法在这个区域分配而已。当一个TLAB用满(分配指针top撞上分配极限end了),就新申请一个TLAB,而在老TLAB里的对象还留在原地什么都不用管——它们无法感知自己是否是曾经从TLAB分配出来的,而只关心自己是在eden里分配的。
TLAB数据结构如下:
class ThreadLocalAllocBuffer: public CHeapObj<mtThread> {
// address of TLAB
HeapWord* _start;
// address after last allocation
HeapWord* _top;
// allocation prefetch watermark
HeapWord* _pf_top;
// allocation end (excluding alignment_reserve)
HeapWord* _end;
// desired size (including alignment_reserve)
size_t _desired_size;
// hold onto tlab if free() is larger than this
size_t _refill_waste_limit;
.....................省略......................
}
TLAB简单来说本质上主要就是三个指针:start,top 和 end:
撞上
end 的时候就会触发一次 TLAB refill。要注意TLAB这个词其实有两层意思:
TLAB refill包括下述几个动作:
每次分配TLAB的大小不是固定的,而是每个线程根据该线程启动开始到现在的历史统计信息来自己单独调整的。
如果一个线程上跑的代码的内存分配速率非常高,则该线程会选择使用更大的TLAB以达到均摊同步开销的效果,反之亦然;同时它还会统计浪费比例,并且将其放入计算新TLAB大小的考虑因素当中,把浪费比例控制在一定范围内。
由于TLAB空间一般不会很大,因此大对象无法在TLAB上进行分配,总是会直接分配在堆上。
TLAB空间由于比较小,因此很容易装满。比如,一个100K的TLAB空间,已经使用了80KB,当需要再分配一个30KB的对象时,就无法分配。这时JVM会有两种选择:
实际上虚拟机内部会维护一个叫作refill_waste
的值,当请求对象大于refill_waste时,会选择在堆中分配;若小于该值,则会废弃当前TLAB,新建TLAB来分配对象。
TLABRefillWasteFraction
可调整refill_waste
阈值,它表示TLAB中允许产生这种浪费的比例。默认值为64,即表示使用约为1/64的TLAB空间作为refill_waste。默认情况下,TLAB和refill_waste都会在运行时不断调整的,使系统的运行状态达到最优。如果想要禁用自动调整TLAB的大小,可以使用-XX:-ResizeTLAB禁用ResizeTLAB,并使用-XX:TLABSize手工指定一个TLAB的大小。
-XX:+PrintTLAB可以跟踪TLAB的使用情况。一般不建议手工修改TLAB相关参数,推荐使用虚拟机默认行为。
参数-XX:+UseTLAB
开启TLAB,默认是开启的。
TLAB空间的内存非常小,缺省情况下仅占有整个Eden空间的1%,当然可以通过选项-XX:TLABWasteTargetPercent
设置TLAB空间所占用Eden空间的百分比大小。
HotSpot里的TLAB是只在eden里分配的,用于给新建的小对象用。(本来其实也有考虑让TLAB在任意位置分配,但后来没实现)。PLAB则是在old gen里分配的一种临时的结构。即promotion LAB。
在多GC线程并行做YGC的时候,大家都要为了晋升对象而在old gen里分配空间,于是old gen的分配指针就热起来了。大量的竞争会使得并行度降低,所以跟TLAB用同样的思路,old gen在处理YGC的晋升对象的分配也一样可以用(GC)线程私有的分配区,即PLAB。
另外在CMS里old gen的剩余空间不是连续的,而是有很多空洞。这些剩余空间是通过freelist来管理的。(GC做某些需要线性扫描堆里的对象的操作时,需要知道堆里哪些地方有对象而哪些地方是空洞,CMS老年代是使用外部数据结构,例如freelist或者allocation BitMap之类来记录哪里有空洞;)
如果ParNew要把对象晋升到CMS管理的old gen,不优化的话就得在freelist上做分配。于是就可以通过类似PLAB的方式,每个GC线程先从freelist申请一块大空间,然后在这块大空间里线性分配(bump pointer)。这样就既降低了对分配指针/freelist的竞争,又可以降低freelist分配的频率而转为用线性分配。
局部变量表
存放了编译期(此时就确定了局部变量表大小)可知的方法中的参数和定义的局部变量,包括各种基本数据类型(boolean、byte、等)、对象引用(reference类型,它不等同于对象本身,根据不同的虚拟机实现,它可能是一个指向对象起始地址的引用指针,也可能指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。
JVM通过索引定位
方式使用局部变量表,从0到最大Slot。其中64位长度的long和double类型的数据会占用2个连续的局部变量空间(Slot),其余的数据类型只占用1个slot。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
局部变量没有准备阶段,也就是说不会自动给基本类型的局部变量赋初始零值。
局部变量表在编译阶段就将最大容量设到了方法的Code属性的max_locals
中。
在方法执行时,如果是实例方法,即非static方法,局部变量表中第0位Slot默认存放对象实例的引用,在方法中可以通过关键字 this 进行访问,方法参数按照参数列表顺序,从第1位Slot开始分配,方法内部变量则按照定义顺序进行分配其余的Slot。
操作数栈
后入先出。操作数栈在编译阶段就将最大深度设到了方法的Code属性的max_stacks
数据项中,方法执行时操作数栈深度永不会超过该值。
方法初始执行时,该方法的操作数栈为空,随着方法执行会将各种字节码指令出入栈,比如iadd
指令就是将栈顶的两个int型数据出栈然后相加,并把结果入栈。
为了优化,大多虚拟机会令两个栈帧出现部分重叠,让下面的栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠,以使得方法调用时可共用部分数据,无需进行额外的参数复制传递。
动态链接
每个栈帧都包含了一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接。
字节码方法调用指令就以常量池中指向方法的符号引用作为参数。静态解析就是指在类加载或首次使用时就转为直接引用;动态链接指在每次运行期才转直接引用。
返回地址
当一个方法开始执行以后,只有两种方法可以退出当前方法:
方法退出的过程就是将当前栈帧出栈,可能进行3个操作:
1. 恢复上层方法的局部变量表和操作数栈
2. 把返回值压入调用者调用栈帧的操作数栈
3. 调整PC计数器的值以指向方法调用指令后面的一条指令
线程共有
因为方法区是被所有线程共享的,所以必须考虑数据的线程安全。假如两个线程都在试图找lava的类,在lava类还没有被加载的情况下,只应该有一个线程去加载,而另一个线程等待。
方法区的构建
在类加载时,会通过类的全限定名加载class文件,构建二进制字节流,将该字节流代表的静态存储结构转化为方法区的运行时数据结构。最后生成代表该类的Class对象放入内存,该Class对象就是该类的访问入口。
组成
interface
或是java.lang.Object
,两种情况下都没有父类)、直接接口的有序列表、方法代码,变量名,方法名,访问权限(public/abstract等),返回值等非堆
为与Java堆区分,方法区还有一个别名Non-Heap(非堆)
方法区、永久代、元空间
对于习惯在HotSpot虚拟机上开发和部署程序的开发者来说,很多人愿意把方法区称为“永久代”(Permanent Generation),本质上两者并不等价,仅仅是因为HotSpot虚拟机的设计团队选择把GC分代收集扩展至方法区,或者说使用永久代来实现方法区而已。
JDK1.8以后用元空间替换永久区实现,因为使用永久代实现方法区更容易内存溢出。字符串常量池已经移动到堆
方法区的内存回收
Java虚拟机规范对这个区域的限制非常宽松,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。相对而言,GC行为在这个区域是比较少出现的,但并非数据进入了方法区就“永久”存在。
方法区的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收“成绩”比较难以令人满意,尤其是类型的卸载,条件相当苛刻,但是回收确实是有必要的。
OOM异常
根据Java虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError
异常。
JVM规范虽说编译后代码在方法区,但是不做强制要求,具体要看JVM实现,hotspot将JIT编译生成的代码存放在native memory的CodeCache区域。
总的来说java 8和java 7后,永久代被去除,取而代之的是元空间,他们存储内容不同:
-XX:MaxPermSize
的限制,现在可以使用更多的本地内存。这样就从一定程度上解决了原来在运行时生成大量类的造成经常 Full GC 问题,如运行时使用反射、代理等。String.intern
方法从全局字符串常量池中获取字符串String引用,或是放入常量池并返回该引用。(JDK1.8以后字符串常量池已经移入Java Heap)更多关于常量池内容请点击这里
Field字段信息存放类中声明的每一个字段的信息,包括字段的名、类型、修饰符。字段名称指的是类或接口的实例变量或类变量,字段的描述符是一个指示字段的类型的字符串,如private String a=null
;则a为字段名,String为描述符,private为修饰符
JVM必须在方法区中保存类的所有Filed的相关信息以及Filed的声明顺序,包括:
JVM必须保存所有方法的以下信息,同样域信息一样包括声明顺序:
在编译的时候,就已经将方法的局部变量、操作数栈大小等确定并存放在字节码中,在装载的时候,随着类一起装入方法区。
non-final类变量被存储在声明它的类信息内,位于方法区;而final类变量被存储在所有使用它的类信息的常量池内
JVM必须知道一个类是由BootstrapClassLoader加载的还是由AppClassLoader加载的。如果一个类由AppClassLoader加载,那么JVM会将这个ClassLoader的一个引用作为类信息的一部分保存在方法区中。
JVM在动态链接的时候需要这个信息。当解析一个类型到另一个类型的引用的时候,JVM需要保证这两个类型的类加载器是相同的。这对JVM区分namespace的方式是至关重要的。
JVM会为每个加载的类型(包括类和接口)都在堆中创建一个java.lang.Class的实例,并将该Class实例和存储在方法区中的类型数据联系起来。
你可以通过Class类的一个静态方法得到这个实例的引用
// A method declared in class java.lang.Class:
public static Class forName(String className);
你甚至可以通过这个函数得到任何包中的任何已加载的类引用,只要这个类能够被加载到当前的namespace。如果jvm不能把类加载到当前名字空间,forName就会抛出ClassNotFoundException。
也可以通过任一对象的getClass()函数得到类对象的引用:
// A method declared in class java.lang.Object:
public final Class getClass();
通过类对象的引用,你可以在运行中获得相应类存储在方法区中的类型信息,下面是一些Class类提供的方法:
// Some of the methods declared in class java.lang.Class:
// 返回类的完整名
public String getName();
// 返回父类的Class对象
public Class getSuperClass();
public boolean isInterface();
// 返回直接父接口数组
public Class[] getInterfaces();
public ClassLoader getClassLoader();
以上所有的这些信息都直接从方法区中获得。
为了提高访问效率,必须仔细的设计存储在方法区中的数据信息结构。除了以上讨论的结构,JVM的实现者还可以添加一些其他的数据结构,如方法表。
JVM对每个加载的非虚拟类的类型信息中都添加了一个方法表,方法表是一组对类实例方法的直接引用(包括从父类继承的方法)。JVM可以通过方法表快速激活实例方法。
class Lava {
private int speed = 5; // 5 kilometers per hour
void flow() {
}
}
class Volcano {
public static void main(String[] args) {
Lava lava = new Lava();
lava.flow();
}
}
为了运行这个程序,你以某种方式把“Volcano”传给了jvm。
有了这个名字,JVM找到了这个类文件(Volcano.class)并读入,它从类文件提取了类型信息并放在了方法区中,
通过解析存在方法区中的字节码,JVM激活了main()方法,在执行时,jvm保持了一个指向当前类(Volcano)常量池的指针。
main()的第一条指令告知JVM,需要为列在常量池第一项的类分配足够的内存。JVM使用指向Volcano常量池的指针找到第一项,发现是一个对Lava类的符号引用,然后它就检查方法区看lava是否已经被加载了。这个符号引用仅仅是类lava的完整有效名”lava“。这里我们看到为了jvm能尽快从一个名称找到一个类,一个良好的数据结构是多么重要。这里jvm的实现者可以采用各种方法,如hash表,查找树等等。同样的算法可以用于Class类的forName()的实现。
当jvm发现还没有加载过一个称为”Lava”的类,它就开始查找并加载类文件”Lava.class”。它从类文件中抽取类型信息并放在了方法区中。
jvm于是以一个直接指向方法区lava类的指针替换了常量池第一项的符号引用。以后就可以用这个指针快速的找到lava类了。而这个替换过程称为常量池解析(constant pool resolution)。在这里我们替换的是一个native指针。
jvm终于开始为新的lava对象分配空间了。这次,jvm仍然需要方法区中的信息。它使用指向lava数据的指针(刚才指向volcano常量池第一项的指针)找到一个lava对象究竟需要多少空间。jvm总能够从存储在方法区中的类型信息知道某类型对象需要的空间。但一个对象在不同的jvm中可能需要不同的空间,而且它的空间分布也是不同的。一旦jvm知道了一个Lava对象所要的空间,它就在堆上分配这个空间并把这个实例的变量speed初始化为缺省值0。假如lava的父对象也有实例变量,则也会初始化。
当把新生成的lava对象的引用压到线程栈中,第一条指令也结束了。
下面的指令利用这个引用激活java代码,把speed变量设为初始值,5。
另外一条指令会用这个引用激活Lava对象的flow()方法。
Java8增加了元空间替代了方法区,移除了永久代。元空间特点如下:
MetaspaceSize
和MaxMetaspaceSize
JVM中,每个对象都有一个指向它自身类的指针,不过这个指针只是指向具体的实现类,而不是接口或者抽象类。
直接内存并不属于JVM运行时数据区或JVM规范定义的区域,但其被频繁使用,可能导致OOM。
可以使用DirectByteBuffer对象作为分配的对外内存的引用进行操作,可在某些场景提高性能,避免在Java堆和Native堆中来回复制数据。
要注意直接内存受机器内存制约,所以必须引起重视避免因为总内存超了,导致JVM内存抛出OOM。
import java. text . SimpleDateFormat;
import java . util .Date;
import java.util.logging.Logger;;
public class HelloWorld {
private static Logger LOGGER = Logger .getLogger(HelloWorld.class);
public void sayHello(String message) {
SimpleDateFormat formatter = new SimpleDateFormat("dd.MM.YY");
String today = formatter.format(new Date()); .
LOGGER. info(today + ": " + message);
}
}
这段程序的数据在内存中的存放如下:
注意,上图中方法区中的Class对象1.7以后已经移入堆了。
Java8增加了元空间替代了方法区,移除了永久代。
在JVM中,使用了OOP-KLASS模型来表示java对象,OOP(Ordinary Object Pointer)指的是普通对象指针,而Klass用来描述对象实例的具体类型,即:
instanceKlass-对象类型数据
JVM在加载每个类时,会为该类创建一个instanceKlass
,保存在方法区,在JVM层表示该Java类。
instanceKlass包含其元数据:包括常量池、字段、方法等,存放在方法区;
instanceOopDesc-对象实例数据
在new一个对象时,JVM创建instanceOopDesc
,来表示这个对象。
instanceOopDesc存放在堆区,而其引用存放在线程栈内;它用来表示对象的实例信息,看起来像个指针实际上是藏在指针里的对象;instanceOopDesc对应java中的对象实例,包含了对象头以及实例数据;
Class对象与instanceKlass不同
HotSpot并不把instanceKlass暴露给Java,而会另外创建对应的instanceOopDesc来表示java.lang.Class对象,并将后者称为前者的“Java镜像”。
klass持有指向表示该类的Class实例的oop的引用(_java_mirror便是该instanceKlass对Class对象的引用);
实例——>instanceKlass——>Class。
new操作返回该对象的位于堆区的instanceOopDesc的引用,该instanceOopDesc含有类型指针指向方法区中的instanceKlass;而instanceKlass指向了对应的类型的Class实例的instanceOopDesc;
有点绕,简单说,就是Person实例——>Person的instanceKlass——>Person的Class。
instanceOopDesc,只包含数据信息,它包含三部分:
在Java中用来表示Class类来表示对象的运行时类型信息,每个Java类都有一个Class对象(编译后)。
当new实例化一个对象或引用类的静态变量时,JVM中的ClassLoader会将对象的对应类的Class对象加载,随后JVM根据该Class对象创建我们关注的对象实例或静态变量的引用值。
Class类总结如下:
.class
的文件中(字节码文件),比如创建一个Shapes类,编译Shapes类后就会创建其包含Shapes类相关类型信息的Class对象,并保存在Shapes.class字节码文件中。new
对象指令,检查在常量池是否能定位到某个类的符号引用,以及检查该符号引用代表的类是否已经被加载、解析、初始化,如果没有就走类加载流程。
方法
方法,所有字段还为0,而要等到真正执行new指令之后才会接着执行该方法,创造真正完整意义上的对象。obj_size + tlab_top <= tlab_end
obj_size + tlab_top >= tlab_end && tlab_free > tlab_refill_waste_limit
tlab_free
:剩余的内存空间,tlab_refill_waste_limit
:允许浪费的内存空间)obj_size + tlab_top >= tlab_end && tlab_free < _refill_waste_limit
,重新分配一块TLAB空间,在新的TLAB中分配对象按照上面的规则,如果对象先TLAB划分失败,那么对象会在Eden空间划分对象,首先尝试CAS方式向堆申请锁,如果失败,通过对堆同步加锁的方式进行分配,下面是 new 一个对象的流程图:
CAS分配代码如下:
// 对大小进行判断,比如是否超过eden区能分配的最大大小
if (gen0->should_allocate(size, is_tlab)) {
// while循环 + 指针碰撞 + CAS分配
result = gen0->par_allocate(size, is_tlab);
if (result != NULL) {
assert(gch->is_in_reserved(result), "result not in heap");
return result;
}
}
对堆区进行加锁:
MutexLocker ml(Heap_lock);//锁
// 需要注意的是,只有大对象可以被分配在老年代。
// 一般情况下should_try_older_generation_allocation都是false,所以first_only=true
bool first_only = ! should_try_older_generation_allocation(size);
// 在年轻代分配
result = gch->attempt_allocation(size, is_tlab, first_only);
if (result != NULL) {
assert(gch->is_in_reserved(result), "result not in heap");
return result;
}
下面是一个32位的HotSpot虚拟机中 MarkWord示意图:
对象真正存储的有效信息,即代码中定义的各类型字段内容,包括父类继承的和子类定义的。
非必要,作用是占位符,因为HotSpot要求对象起始地址(大小)必须是8字节的整数倍,而对象头是8字节的1或2倍。所以需要当对象实例数据不够8字节整数倍时,需要加字节填充补足8字节。
可以看到,某个对象在栈帧中的局部变量表存有所需对象的引用。我们的线程代码就是用线程栈内的该引用来操作堆上的具体对象。
堆内存的是存了instanceOopDesc
方法区(1.8是元空间)存了KClass,他包含类的各种属性,如类名,内存布局(大小),方法表,父子类关系等
HotSpot虚拟机使用直接指针来定位对象,这种方法引用存储的是对象地址。直接指针的好处是速度快,少一次寻址(JVM中查找对象是很频繁的)。
GC做某些需要线性扫描堆对象的操作时,需要知道堆里哪些地方有对象而哪些地方是空洞。
HotSpot把空洞部分也假装成有对象,这样GC在线性遍历时会看到一个“对象总是连续分配的”的假象,就可以以统一的方式来遍历:遍历到一个对象时,通过其对象头记录的信息找出该对象的大小,然后跳到该大小之后就可以找到下一个对象的对象头,依此类推。HotSpot选择的是后者的做法,假装成有对象的这种东西就叫做filler object(填充对象)。
工作缓存引入原因
由于计算机的存储设备与处理器的运算能力之间有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(cache)来作为主内存与处理器之间的缓冲:将运算需要使用到的数据从主存复制到高速缓存中,让运算能快速进行,当运算结束后再从高速缓存同步回内存之中,这样处理器就无需等待缓慢的内存读写了。
缓存一致性问题
基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是引入了一个新的问题:缓存一致性(Cache Coherence)。
在多处理器系统中,每个处理器都有自己的高速缓存,而他们又共享同一主存,如下图所示:多个处理器运算任务都涉及同一块主存,需要一种协议可以保障数据的一致性,这类协议有MSI、MESI、MOSI及Dragon Protocol等。Java虚拟机内存模型中定义的内存访问操作与硬件的缓存访问操作是具有可比性的,后续将介绍Java内存模型。
指令重排
除此之外,为了使得处理器内部的运算单元能竟可能被充分利用,处理器可能会对输入代码进行乱起执行(Out-Of-Order Execution)优化,处理器会在计算之后将对乱序执行的代码进行结果重组,保证结果准确性。与处理器的乱序执行优化类似,Java虚拟机的即时编译器中也有类似的指令重排序(Instruction Recorder)优化。
注意区分Java工作内存模型(主内存 缓存等)和Java运行时数据区域(栈 堆等)。
JMM(Java Memory Model),即Java内存模型。Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样底层细节。此处的变量与Java编程时所说的变量不一样,指包括了实例字段、静态字段和构成数组对象的元素,不包括局部变量与方法参数(后者是线程私有的,不会被共享)。
Java堆和方法区是线程共享的,即多个线程可能可以操作保存在堆或者方法区中的同个数据。这也就是所谓“Java的线程间通过共享内存进行通信”,在通信过程中会存在一系列如可见性、原子性、顺序性等问题,JMM就是围绕着多线程通信以及与其相关的一系列特性而建立的模型。
JMM并不像Java运行时数据区域一样是真实存在的,而他只是一个抽象的概念。JSR-133: Java Memory Model and Thread Specification 中描述了
,JMM是和多线程相关的,他描述了一组规则或规范,这个规范定义了一个线程对共享变量的写入时对另一个线程是可见的。JMM定义了一些语法集,这些语法集映射到Java语言中就是volatile、synchronized等关键字。
多个线程间通信的共享内存称为主内存,可勉强对应Java堆中的对象实例数据部分,不包括对象头。
在java中,实例域、静态域和数组元素是线程之间共享的数据,它们存储在主内存中。
工作内存可勉强对应虚拟机栈中部分区域。
简介
每个线程都维护了一个自己的本地工作缓存(抽象概念),其中保存的数据是主内存中的数据拷贝(当然,并不是说将对象全部拷贝,而只是拷贝对象的引用、本线程访问的该对象的目标字段等),一般来说线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,而不能直接读写主内存中的变量。
方法局部变量,方法定义参数和异常处理器参数是不会在线程之间共享的,它们存储在线程的本地内存中。
工作内存线程私有,互相隔离
不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要在主内存来完成。
JMM控制线程本地内存和主内存之间的数据交互
volatile与工作内存
volatile也有工作内存拷贝,但是由于其特殊的操作顺序性规定所以才在使用中貌似直接读写主内存。
JMM定义了8种原子类型操作来实现主内存和工作内存之间交互,如将变量从主内存复制到工作内存时,需要顺序执行read和load,写回主内存时顺序执行store和write,但他们并非一定是连续的:
如果要把一个变量从主内存中复制到工作内存,就需要按顺寻地执行read和load操作, 如果把变量从工作内存中同步回主内存中,就要按顺序地执行store和write操作。Java内存 模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。也就是read和load之间, store和write之间是可以插入其他指令的,如对主内存中的变量a、b进行访问时,可能的顺 序是read a,read b,load b, load a。
Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:
JMM关于这些原子操作有一些规定,十分繁琐,但有一个等效判断原则-HappenBefore原则,原来确定并发环境下的一个访问是否线程安全。
重排是指编译器和处理器为了提高性能,而在程序执行时会对程序进行的重排。
从Java源代码到最终实际执行的指令序列,会经过下面三种重排序:
为了提高程序的并发度,从而提高性能。但是对于多线程程序,重排序可能会导致程序执行的结果不是我们需要的结果。因此,就需要我们通过“volatile,synchronize,锁等方式”作出正确的实现同步。
目的
为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序,即重排序时不能把后面的指令放到内存屏障之前。有时编译器和硬件会处于优化目的将指令重排,可能就不满足我们的本意了。
总的来说,内存屏障有两个功能:
硬件层面
硬件层提供了一系列的内存屏障memory barrier
/memory fence
(Intel的提法)来提供一致性的能力。拿X86平台来说,有几种主要的内存屏障
不同硬件实现内存屏障的方式不同,Java内存模型屏蔽了这种底层硬件平台的差异,由JVM来为不同的平台生成相应的机器码。
本质
内存屏障(内存栅栏),是一组实现对内存操作的顺序限制的CPU指令,用于控制特定条件下的重排序和内存可见性问题。
Java编译器也会根据内存屏障的规则禁止重排序。但有的处理器的重排序规则较严,无需内存屏障也能很好的工作,Java编译器会在这种情况下不放置内存屏障。
内存屏障控制指令访问内存的两种方式
内存屏障包括LoadLoad, LoadStore, StoreLoad, StoreStore:
public class MemoryBarrier {
int a, b;
volatile int v, u;
void f() {
int i, j;
i = a;
j = b;
i = v;
//LoadLoad
j = u;
//LoadStore
a = i;
b = j;
//StoreStore
v = i;
//StoreStore
u = j;
//StoreLoad
i = u;
//LoadLoad
//LoadStore
j = b;
a = i;
}
}
概念
Lock并不是内存屏障,但是能完成类似功能,主要是对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。
功能
volatile使用Lock实现原理
参见Volatile-Lock
synchronized使用Lock实现原理
参见Synchronized-Lock
对于final域,编译器和CPU会遵循两个排序规则:
x.finalField = v;
StoreStore;
sharedRef = x;
Obj copyX = x;
LoadLoad
Obj copyF = x.finalField;
也就是说必需保证一个对象的所有final域被写入完毕后才能引用和读取。这也是内存屏障的起的作用。
写final域
在编译器写final域完毕,构造函数结束之前,会插入一个StoreStore
屏障,保证前面的对final域的写入立即对其他线程/CPU可见,并阻止指令重排序。
读final域
两步操作不能重排序的原理就是在读final域前插入了LoadLoad屏障。
可见性
禁止指令重排序优化
普通的变量仅仅保证在该方法的执行过程中所有依赖赋值结果的地方都能获取正确的结果,而不能保证变量赋值操作的顺序与程序代码的执行顺序一致;
无原子性,互斥性
volatile 仅仅解决了可见性的问题,但是它并不能保证互斥性,也就是说多个线程并发修改某个变量时,依旧会产生多线程问题。因此,不能靠volatile 来完全替代传的锁
当需要使用被volatile修饰的变量时,线程会从主内存中重新获取该变量的值,但当该线程修改完该变量的值写入主内存的时候,并没有判断主内存内该变量是否已经变化,故可能出现非预期的结果。
多线程执行以下代码:
volatile i;
increase(){
i++;
}
因为increase方法中虽然I为volatile表示,但只能保证在读取时是最新的值,但++
操作是多条指令组成,所以并不能保证在运算完成写回主内存过程中没有其他线程改变了该值。
所以volatile此时只能保证可见性,而无法保证原子性。
列1
多线程执行以下循环:
volatile boolean stop = false;
while(!stop){
//work
}
停止方法如下:
stopAll(){
stop = true;
}
如果stop不用volatile修饰,那可能while循环永远读不到stop=true
,因为该线程工作副本内stop的值一直是false。
列2
或是在单例模式中:
class Singleton{
private volatile static Singleton instance = null;
privateSingleton() {}
public static Singleton getInstance() {
if(instance==null) {
synchronized(Singleton.class) {
if(instance==null)
instance = newSingleton();
}
}
return instance;
}
}
如果不用volatile修饰instance,则可能发生意外情况。因为instance = newSingleton()
这行代码其实是以下3步:
volatile变量是个内存屏障,但在这之前和之后的指令可以重排序:
volatile变量会禁止重排序:
以下是JMM 针对编译器制定的 volatile 重排序规则表:
从上表我们可以看出:
一般volatile适用于强制变量的修改对其他线程可见;此外,volatile还能保证顺序性,避免指令重排导致程序异常,如下程序:
private static boolean ready;
private static int number;
private static volatile boolean ready2;
private static volatile int number2;
private static class ReaderThread extends Thread{
public void run(){
while(!ready);
System.out.println(number);
}
}
private static class ReaderThread2 extends Thread{
public void run(){
while(!ready2);
System.out.println(number2);
}
}
public static void main(String[] args) throws InterruptedException {
new ReaderThread().start();
new ReaderThread2().start();
Thread.sleep(1000);
number = 42;
ready = true;
number2 = 422;
ready2 = true;
Thread.sleep(10000);
}
最后的结果:
load
后必须和use
连续,以保证使用volatile变量时,每次都必须从主内存read该变量最新值load到工作内存,然后才能使用。assign
赋值操作后必须和store
连续,以保证每次在工作内存修改volatile变量值后立刻刷入主内存,保证其他线程看到最新的更改。比如对volatile变量赋值时,会在mov
赋值指令后加入lock
指令,相当于一个内存屏障,后面的指令不能重排序到内存屏障之前的位置。只需要对写volatile的使用用lock对总线加锁就行了,这样其他的读、写操作等待总线释放才能继续读。Lock会让其他CPU的缓存invalide,从内存重新加载数据:
写volatile
此时生成汇编码是 lock addl $0x0, (%rsp)
, 在写操作之前使用了lock前缀,锁住了总线和对应的地址,这样其他的写和读都要等待锁的释放。当写完成后,释放锁,把缓存刷新到主内存。
该指令会使得volatile变量刷入主存,且让该变量在别的CPU工作内存中失效,相当于做了一次store+write操作,然后该volatile变量最新值就能对其他线程立即可见了。
读volatile
此时不需要额外的汇编指令。只要CPU发现对应地址的缓存被锁了,等待锁的释放,缓存一致性协议会保证它读到最新的值。
还有一种说法,是基于内存屏障实现的Volatile:
是指一个操作是按原子的方式执行的。要么该操作不被执行;要么以原子方式执行,即执行过程中不会被其它线程中断。
// 语句1是原子性操作。执行该语句的线程直接将赋值写入到自己的工作内存。
x = 10;
// 语句2不是原子性操作。因为需要先读取x值,再将该值赋值给y写入工作内存。
// 虽然分别是原子性操作,但合在一起就不是原子操作了。
y = x;
// 语句3和语句4不是原子性操作,因为包含三个操作:
// 读取x的值,进行加1操作,写入新的值。
x++;
x = x+1;
也就是说,只有简单的读取、赋值(而且必须是将字面量赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。
Java可以用Atomic相关类保证原子性操作。比如AtomicInteger.incrementAndGet
就是利用了Unsafe.compareAndSwapInt
来实现原子性的自增。
JMM规定通过变量修改后刷回主存,变量读取前从主内存读取来实现可见性,包括普通变量和volatile变量。
可参考【Java并发编程】之十六:深入Java内存模型——happen-before规则及其对DCL的分析(含代码)
该规则定义了 Java 多线程操作的有序性和可见性,防止了指令重排对程序结果的影响:
当一个变量被多个线程读取,并且至少被一个线程写入时,如果读操作和写操作没有 HB 关系,则会产生数据竞争问题。 要想保证操作 B 的线程看到操作 A 的结果(无论 A 和 B 是否在一个线程),那么在 A 和 B 之间必须满足 HB 原则,如果没有HB关系,将有可能导致指令重排序。
程序顺序规则中所说的每个操作happens-before于该线程中的任意后续操作并不是说前一个操作必须要在后一个操作之前执行,而是指前一个操作的执行结果必须对后一个操作可见,如果不满足这个要求那就不允许这两个操作进行重排序
程序次序规则
一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;
一段代码在单线程中执行的结果是有序的。注意是执行结果,因为虚拟机、处理器会对指令进行重排序(重排序后面会详细介绍)。虽然重排序了,但是并不会影响程序的执行结果,所以程序最终执行的结果与顺序执行的结果是一致的。故而这个规则只对单线程有效,在多线程环境下无法保证正确性。
锁定规则
在监视器锁上的解锁操作必须在同一个监视器上的加锁操作之前执行。
volatile变量规则
对一个变量的写操作先行发生于后面对这个变量的读操作;
一个线程先去写一个volatile变量,后一个线程去读这个变量,那么这个写操作一定是happens-before读操作的。
传递规则
如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
线程启动规则
Thread对象的start()方法先行发生于此线程的每一个动作;
假定线程A在执行过程中,通过执行ThreadB.start()来启动线程B,那么线程A对共享变量的修改在接下来线程B开始执行后确保对线程B可见。
线程中断规则
对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
线程终结规则
线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
对象终结规则
一个对象的初始化完成先行发生于他的finalize()方法的开始;
一个单线程执行如下方法:
public double rectangleArea(double length , double width){
double leng;
double wid;
leng=length;//A
wid=width;//B
double area=leng*wid;//C
return area;
}
上面的操作在运行之前编译器和处理器可能会进行优化,但在程序中
根据happens-before规则我们来分析重排序后可能产生的结果:
cache protocol
和 memory barrier
。通过缓存一致性协议和内存屏障实现可见性。happen-before 原则是 JMM 的核心所在,只有满足了 hb 原则才能保证有序性和可见性,否则编译器将会对代码重排序。hb 甚至将 lock 和 volatile 也定义了规则。
更多可参考:
synchronized块生成JVM指令是monitorenter, monitorexit,最后生成的汇编指令分别是
lock cmpxchg %r15, 0x16(%r10)
和 lock cmpxchg %r10, (%r11)
cmpxchg
是CAS的汇编指令,这里的含义是先用lock指令对总线和缓存上锁,然后用cmpxchg
做CAS操作设置对象头中的synchronized标志位。CAS完成后释放锁,将工作缓存刷新到主内存。
所以synchronized的底层操作如下:
lock cmpxchg
的方式设置成“锁住“状态,lock cmpxchg
的方式修改对象头的锁标志位为”释放“状态,JVM会进一步对synchronized时CAS失败的那些线程进行阻塞操作,这部分的逻辑没有体现在lock cmpxchg指令上,我猜想是通过某种信号量来实现的。
lock cmpxchg
指令前者保证了可见性和防止重排序。
内存泄露是指:本来不再会被使用的对象的内存不能被回收。典型的例子如下:
-XX:-UseGCOverheadLimit
禁用这个检查,其实这个参数解决不了内存问题,只是把错误的信息延后,替换成 java.lang.OutOfMemoryError: Java heap space
。运行前JVM参数加入 -XX:+HeapDumpOnOutOfMemoryError
将异常现场输出内存堆转储快照dump文件。
出现OOM后,需要分析Dump文件,确认是内存泄露还是内存溢出。
可以使用Eclipse Memory Analyzer
对dump文件做分析,重点是检查溢出的对象是否必要(即确认是内存泄露还是溢出)
JetBrains JVM Debugger Memory View plugin
可通过工具看到GC Roots引用链,从而找到泄露对象是通过怎样的路径与GC Roots相关联,并导致GC无法自动回收的,从而定位到泄露代码位置。
-Xmx和-Xms
,看机器内存是否还能调大这些参数更多关于GC的内容可参考Java-JVM-GC
-Xms:设置堆的最小空间大小。
-Xmx:设置堆的最大空间大小。
-Xmn:设置年轻代大小
-XX:NewSize设置新生代最小空间大小。
-XX:MaxNewSize设置新生代最大空间大小。
-XX:PermSize设置永久代最小空间大小。
-XX:MaxPermSize设置永久代最大空间大小。
-Xss设置每个线程的堆栈大小
-XX:+UseParallelGC:选择垃圾收集器为并行收集器。此配置仅对年轻代有效。即上述配置下,年轻代使用并发收集,而年老代仍旧使用串行收集。
-XX:ParallelGCThreads=20:配置并行收集器的线程数,即:同时多少个线程一起进行垃圾回收。此值最好配置与处理器数目相等。
-XX:MaxTenuringThreshold=0:设置垃圾最大年龄。如果设置为0的话,则年轻代对象不经过Survivor区,直接进入年老代。对于年老代比较多的应用,可以提高效率。如果将此值设置为一个较大值,则年轻代对象会在Survivor区进行多次复制,这样可以增加对象再年轻代的存活时间,增加在年轻代即被回收的概论。
注意:没有直接设置老年代的参数,但是可以设置堆空间大小和新生代空间大小两个参数来间接控制:
老年代空间大小=堆空间大小-年轻代大空间大小
典型JVM参数配置参考:
java
-Xmx3550m
-Xms3550m
-Xmn2g
-Xss128k
-XX:ParallelGCThreads=20
-XX:+UseConcMarkSweepGC
-XX:+UseParNewGC
其中:
-Xms3550m:设置JVM促使内存为3550m。此值可以设置与-Xmx相同,以避免每次垃圾回收完成后JVM重新分配内存。
-Xmn2g:设置年轻代大小为2G。整个堆大小=年轻代大小+年老代大小+持久代大小。持久代一般固定大小为64m,所以增大年轻代后,将会减小年老代大小。此值对系统性能影响较大,官方推荐配置为整个堆的3/8。
-Xss128k:设置每个线程的堆栈大小。JDK5.0以后每个线程堆栈大小为1M,以前每个线程堆栈大小为256K。更具应用的线程所需内存大 小进行调整。在相同物理内存下,减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000 左右。
字面量
字面量就是我们所说的常量概念,如文本字符串、被声明为final的常量值等。
符号引用
符号引用是一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可(它与直接引用区分一下,直接引用一般是指向方法区的本地指针,相对偏移量或是一个能间接定位到目标的句柄)。符号引用一般包括下面三类常量:
JVM为每个已加载的类都维护一个.class文件常量池和运行时常量池,其中.class文件常量池存放编译期生成的各种用到的字面量(包括实际的常量(string, integer, 和floating point常量))和符号引用(field变量和方法)。
常量池中的数据项像数组项一样,是通过索引访问的。
比如,String a = "hello"
,则hello
为字面量
Object = c
中,因为这里引用了非字面量的c,所以不管c是变量还是常量,都是一个字符串定义的符号,称为符号引用。在编译阶段,无法确定具体的内存地址,所以会使用符号来代替,即对抽象的类或接口进行符号填充为符号表,存在该类的.class
文件的常量池内。
当该类加载时第一次遇到该符号引用时,会在连接的解析阶段将符号引用转为直接引用。具体来说,直接引用是指向目标的指针、相对偏移量或是间接定位到目标的句柄。他存储于运行时常量池,这是相当于将常量标识为已解析,以后不用再重复解析。
全局字符串池里的内容是在类加载完成,经过验证,准备阶段之后在堆中生成字符串对象实例,然后将该字符串对象实例的引用值存到string pool
中(注意:string pool中存的是引用值而不是具体的实例对象,具体的实例对象是在堆中开辟的一块空间存放的。)。
只存字符串对象的引用
在HotSpot JVM里实现的string pool功能的是一个StringTable
类,它是一个Hashtable
,里面存的是驻留字符串(也就是我们常说的用双引号括起来的)的引用(而不是驻留字符串实例本身),也就是说在堆中的某些字符串实例被这个StringTable引用之后就等同被赋予了驻留字符串
的身份。该StringTable在每个HotSpot JVM的实例只有一份,被所有的类共享。
String的intern方法:
如果字符串常量池中已经有了这个字符串,那么直接返回常量池中的它的引用,如果没有,那就将它的引用保存一份到字符串常量池,然后直接返回这个引用。
lazy特性-字面量进入字符串常量池的时机:
全局字符串常量池是惰性的。
就HotSpot VM的实现来说,加载类的时候,那些字符串字面量会进入当前类的运行时常量池,不会进入全局字符串常量池(即在字符串常量池中没有相应的引用,在堆中也没有生成对应的对象)。
加载类的时候,没有解析字符串字面量,等到执行ldc指令的时候就会触发这个解析的动作。ldc指令的语义是:到当前类的运行时常量池区查找该index对应的项,如果该项没有解析就解析,并返回解析后的内容。在遇到String类型常量时,解析的过程是如果发现字符串常量池中已经有了内容匹配的String类型的引用,就直接返回这个引用,如果没有内容匹配的String实例的引用,就会在Java堆中创建一个对应内容的String对象,然后在字符串常量池中记录下这个引用。
说明:自己的一点理解,上面说的时对字符串的解析,其实对方法解析也是类似,有些方法也是lazy resolve,有一部分符号引用是在类加载阶段或者第一次使用的时候就转化为直接引用,被称为静态解析(例如静态方法、私有方法等非虚方法),另一部分将在每一次运行期间转换为直接引用,被称为动态连接(例如静态分派),这部分也是lazy resolve。
JDK1.8后,字符串常量池移入了堆中。
例子:
class Test{
public static String s1 = "static";
public static void main(String[] args) {
String s2 = new String("he")+new String("llo");
s2.intern();
String s3 = "hello";
System.out.println(s2==s3); //true
}
}
类加载
“static” “he” “llo” "hello"都会进入Class常量池,但类加载阶段由于解析阶段是lazy的,所以不会创建实例,更不会驻留字符串常量池。
但要注意这个“static"和其他三个不一样,它是静态的,在加载阶段的初始化阶段,会为静态遍历执行初始值,也就是将"static"赋值给s1,所以会创建"static"字符串对象, 并且会保存一个指向它的引用到字符串常量池。
运行阶段
运行main方法后,执行String s2 = new String("he")+new String("llo")
语句,创建"he"和"llo"的对象,并会保存引用到字符串常量池中,然后内部创建一个StringBuilder对象,一路append,最后调用toString()方法得到一个String对象(内容是hello,注意这个toString方法会new一个String对象),并把该String对象赋值给s2(注意这里没有把hello的引用放入字符串常量池,而只是在堆中,引用在s2)。
s2.intern()
此时字符串常量池中没有,它会将上面的这个"hello"对象的引用保存到字符串常量池,然后返回这个对象的引用,但是这个返回的引用没有变量区接收,所以没用。
注意此时已经在字符串常量池有了"hello"对象的引用了,就是s2的指向的那个对象。
String s3 = “hello”
因为字符串常量池中已经有了,所以直接指向堆中"hello"对象,所以s2和s3引用相同
字符串常量池小结如下:
和其他对象分配一样,字符串的分配耗费高昂的时间和空间代价,作为最基础的数据类型大量频繁创建字符串会极大的影响性能。JVM为了提高性能和减少内存开销,在实例化字符串常量的时候进行了一些优化
实现的基础
关于字符串常量池,可以看更多文章
我们都知道,class文件中除了包含类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池(constant pool table),用于存放编译器生成的各种字面量(Literal)和符号引用(Symbolic References)。
.class文件常量池的每一项常量都是一个表,一共有如下表所示的11种各不相同的表结构数据,这每个表开始的第一位都是一个字节的标志位(取值1-12),代表当前这个常量属于哪种常量类型。
每种不同类型的常量类型具有不同的结构,具体的结构本文就先不叙述了,本文着重区分这三个常量池的概念(读者若想深入了解每种常量类型的数据结构可以查看《深入理解java虚拟机》第六章的内容)。
运行时常量池是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池(Constant Pool Table),存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池。
运行时常量池具备动态性,也就是并非预置入Class文件的内容才能进入方法区的运行时常量池,运行期间也可能将新的常量放入池中,比如String类的intern()方法。
当java文件被编译成class文件之后,也就是会生成我上面所说的class常量池,那么运行时常量池又是什么时候产生的呢?
jvm在执行某个类的时候,必须经过加载、连接、初始化,而连接又包括验证、准备、解析三个阶段。而当类加载到内存中后,jvm就会将class常量池中的内容存放到运行时常量池中,由此可知,运行时常量池也是每个类都有一个。在上面我也说了,class常量池中存的是字面量和符号引用,也就是说他们存的并不是对象的实例,而是对象的符号引用值。而经过解析(resolve)之后,也就是把符号引用替换为直接引用,解析的过程会去查询全局字符串池,也就是我们上面所说的StringTable,以保证运行时常量池所引用的字符串与全局字符串池中所引用的是一致的。
Java代码在进行编译时,并不像C那样有"连接"这一步骤,而是在虚拟机加载Class文件的时候进行动态连接。也就是说,Class文件不会保存各个方法、字段的最终内存布局信息,因此这些字段、方法的符号引用不经过运行期间 转换的话无法得到真正的内存入口地址,也就无法直接被虚拟机使用。当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。
举个实例来说明一下:
String str1 = "abc";
String str2 = new String("def");
String str3 = "abc";
String str4 = str2.intern();
String str5 = "def";
System.out.println(str1 == str3);//true
System.out.println(str2 == str4);//false
System.out.println(str4 == str5);//true
上面程序执行步骤如下:
回到上面的那个程序,现在就很容易解释整个程序的内存分配过程了:
.class
文件常量池是在编译的时候每个class都有的,在编译阶段,存放的是常量的符号引用。关于更多常量池的内容可参考文章Java常量池理解与总结
编译期常量指的就是程序在编译时就能确定这个常量的具体值。public static final
不触发初始化。
也就是说,由于编译期常量在编译时就确定了值,使用编译期常量的地方在编译时就会替换成对应的值。
编译期常量可以做隐式转换,如int值赋值给short。还有一点是编译期常量可以在非静态内部类中声明静态的编译时常量。你可能知道,非静态内部类不能拥有静态成员。但可以为非静态内部类添加静态编译时常量。
运行时常量就是程序在运行时才能确定常量的值,因此也称为运行时常量。触发初始化
定义上来说,声明为final类型的基本类型或String类型并直接赋值的变量就是编译期常量,即:
// 编译时常量
public static final String public_static_final_String = "public_static_final_String";
// 通过常量表达式运算得到的字符串常量是在编译时计算得出的,并且之后会将其当做字面常量对待。
// 具体可参考https://blog.csdn.net/u013126804/article/details/87695759
public static final String public_static_final_String_combine = "public_static_final_String" + "combine";
public static final int public_static_final_int = 1;
final int i = 4;
final String str = "dasd";
// 运行时常量
public static final String public_static_final_String_new = new String("public_static_final_String_new");
// 运行时常量
final int i4 = new Random(47).nextInt(20);
// 这种静态变量也会触发被引用类的初始化
static String default_static_String = "default_static_String";
注:若A用B中的的编译期常量,且AB属于不同jar包,则A中使用的该值在编译器就确定了。如果B更新了,A全只替换依赖包而不重新编译,则会造成A程序继续使用老的B中常量池。必须重新编译以使用最新的B中常量值。
原始数据类类型或者String的常量是编译时常量。其他类型都不是编译时常量,甚至包装类也不是。编译时常量的一般规则。
编译时常量详细判定规则:
编译时常量正例:
final int i = 10;
final int j = 20, k = 30;
final String s = "Hello";
final float f = 10.5f;
编译时常量反例:
final Integer i = 10; // 不是原始数据类型或者字符串
int j = 10; // 没有声明为final
final int k; // 没有用声明初始化
k = 10;
final int l, m = l = 20; // l和m都不是编译时常量
更多可参考:
即静态常量,编译期常量,编译时就确定值。(Java代码执行顺序,先编译为class文件,再用虚拟机加载class文件执行)编译阶段存放在class文件常量池中。
在类加载解析后,编译器常量又放入该类的运行时常量池。注意,是将符号引用转为直接引用,指向堆中的常量对象。
如果调用此常量的类不是定义常量的类,那么不会初始化定义常量的类,因为在编译阶段通过常量传播优化,已经将常量存到调用类的常量池中了。
class ConstC{
static{
System.out.println("ConstC init!");
}
public ConstC(){
System.out.println("ConstC ");
}
public static final String HELLO = "hello world!";
}
public class NotInit {
public static void main(String[] args) {
//经过编译优化,静态常量HELLO已经存到NotInit类自身常量池中,不会加载ConstC
System.out.println(ConstC.HELLO);
}
}
//输出:hello world!
final常量,类加载时确定或者更靠后。
当用final作用于类的成员变量时,成员变量(注意是类的成员变量,局部变量只需要保证在使用之前被初始化赋值即可)必须在定义时或者构造器中进行初始化赋值
对于一个final变量,
反射的基础:
在装载类的时候,加入方法区中的所有信息,最后都会形成Class类的实例,代表这个被装载的类。方法区中的所有的信息,都是可以通过这个Class类对象反射得到。
我们知道对象是类的实例,类是相同结构的对象的一种抽象。同类的各个对象之间,其实是拥有相同的结构(属性),拥有相同的功能(方法),各个对象的区别只在于属性值的不同。同样的,我们所有的类,其实都是Class类的实例,他们都拥有相同的结构-----Field数组、Method数组。而各个类中的属性都是Field属性的一个具体属性值,方法都是Method属性的一个具体属性值。
关于反射原理请参考Java-反射
JVM内存结构(基于JDK8)
RUNTIME DATA AREAS – JAVA’S MEMORY MODEL
这篇文章是jvm系列(二):JVM内存结构的英文原文
深入理解java方法调用时的参数传递
Class实例在堆中还是方法区中?
-《深入理解Java虚拟机-周志明》
深入理解Java类型信息(Class对象)与反射机制
Java中几种常量池比较
JVM内存结构 VS Java内存模型 VS Java对象模型
java编译时常量-翻译
java中堆栈(stack)和堆(heap)
【死磕Java并发】-----Java内存模型之happens-before
通俗易懂讲解happens-before原则
Java 使用 happen-before 规则实现共享变量的同步操作
【Java并发编程】之十六:深入Java内存模型——happen-before规则及其对DCL的分析(含代码)
Java内存屏障(Memory Barriers)
浅析内存屏障以及在java中的应用