String#intern
引言
在 JAVA 语言中有8中基本类型和一种比较特殊的类型String
。这些类型为了使他们在运行过程中速度更快,更节省内存,都提供了一种常量池的概念。常量池就类似一个JAVA系统级别提供的缓存。
8种基本类型的常量池都是系统协调的,String
类型的常量池比较特殊。它的主要使用方法有两种:
- 直接使用双引号声明出来的
String
对象会直接存储在常量池中。 - 如果不是用双引号声明的
String
对象,可以使用String
提供的intern
方法。intern 方法会从字符串常量池中查询当前字符串是否存在,若不存在就会将当前字符串放入常量池中
接下来我们主要来谈一下String#intern
方法。
一, intern 的实现原理
首先深入看一下它的实现原理。
1,JAVA 代码
/** * Returns a canonical representation for the string object. ** A pool of strings, initially empty, is maintained privately by the * class
String
. ** When the intern method is invoked, if the pool already contains a * string equal to this
String
object as determined by * the {@link #equals(Object)} method, then the string from the pool is * returned. Otherwise, thisString
object is added to the * pool and a reference to thisString
object is returned. ** It follows that for any two strings
s
andt
, *s.intern() == t.intern()
istrue
* if and only ifs.equals(t)
istrue
. ** All literal strings and string-valued constant expressions are * interned. String literals are defined in section 3.10.5 of the * The Java™ Language Specification. * * @return a string that has the same contents as this string, but is * guaranteed to be from a pool of unique strings. */ public native String intern();
String#intern
方法中看到,这个方法是一个 native 的方法,但注释写的非常明了。“如果常量池中存在当前字符串, 就会直接返回当前字符串. 如果常量池中没有此字符串, 会将此字符串放入常量池中后, 再返回”。
2,native 代码
在 jdk7后,oracle 接管了 JAVA 的源码后就不对外开放了,根据 jdk 的主要开发人员声明 openJdk7 和 jdk7 使用的是同一分主代码,只是分支代码会有些许的变动。所以可以直接跟踪 openJdk7 的源码来探究 intern 的实现。
####native实现代码:
\openjdk7\jdk\src\share\native\java\lang\String.cJava_java_lang_String_intern(JNIEnv *env, jobject this) { return JVM_InternString(env, this); }
\openjdk7\hotspot\src\share\vm\prims\jvm.h
/* * java.lang.String */ JNIEXPORT jstring JNICALL JVM_InternString(JNIEnv *env, jstring str);
\openjdk7\hotspot\src\share\vm\prims\jvm.cpp
// String support /// JVM_ENTRY(jstring, JVM_InternString(JNIEnv *env, jstring str)) JVMWrapper("JVM_InternString"); JvmtiVMObjectAllocEventCollector oam; if (str == NULL) return NULL; oop string = JNIHandles::resolve_non_null(str); oop result = StringTable::intern(string, CHECK_NULL); return (jstring) JNIHandles::make_local(env, result); JVM_END
\openjdk7\hotspot\src\share\vm\classfile\symbolTable.cpp
oop StringTable::intern(Handle string_or_null, jchar* name, int len, TRAPS) { unsigned int hashValue = java_lang_String::hash_string(name, len); int index = the_table()->hash_to_index(hashValue); oop string = the_table()->lookup(index, name, len, hashValue); // Found if (string != NULL) return string; // Otherwise, add to symbol to table return the_table()->basic_add(index, string_or_null, name, len, hashValue, CHECK_NULL); }
\openjdk7\hotspot\src\share\vm\classfile\symbolTable.cpp
oop StringTable::lookup(int index, jchar* name, int len, unsigned int hash) { for (HashtableEntry* l = bucket(index); l != NULL; l = l->next()) { if (l->hash() == hash) { if (java_lang_String::equals(l->literal(), name, len)) { return l->literal(); } } } return NULL; }
它的大体实现结构就是:
JAVA 使用 jni 调用c++实现的StringTable
的intern
方法, StringTable
的intern
方法跟Java中的HashMap
的实现是差不多的, 只是不能自动扩容。默认大小是1009。 要注意的是,String的String Pool是一个固定大小的Hashtable
,默认值大小长度是1009,如果放进String Pool的String非常多,就会造成Hash冲突严重,从而导致链表会很长,而链表长了后直接会造成的影响就是当调用String.intern
时性能会大幅下降(因为要一个一个找)。
在 jdk6中StringTable
是固定的,就是1009的长度,所以如果常量池中的字符串过多就会导致效率下降很快。在jdk7中,StringTable
的长度可以通过一个参数指定:
-XX:StringTableSize=99991
二,jdk6 和 jdk7 下 intern 的区别
相信很多 JAVA 程序员都做做类似 String s = new String("abc")
这个语句创建了几个对象的题目。 这种题目主要就是为了考察程序员对字符串对象的常量池掌握与否。上述的语句中是创建了2个对象,第一个对象是"abc"字符串存储在常量池中,第二个对象在JAVA Heap中的 String 对象。
来看一段代码:
public static void main(String[] args) { String s = new String("1"); s.intern(); String s2 = "1"; System.out.println(s == s2); String s3 = new String("1") + new String("1"); s3.intern(); String s4 = "11"; System.out.println(s3 == s4);}
打印结果是
- jdk6 下
false false
- jdk7 下
false true
具体为什么稍后再解释,然后将s3.intern();
语句下调一行,放到String s4 = "11";
后面。将s.intern();
放到String s2 = "1";
后面。是什么结果呢
public static void main(String[] args) { String s = new String("1"); String s2 = "1"; s.intern(); System.out.println(s == s2); String s3 = new String("1") + new String("1"); String s4 = "11"; s3.intern(); System.out.println(s3 == s4);}
打印结果为:
- jdk6 下
false false
- jdk7 下
false false
####1,jdk6中的解释
注:图中绿色线条代表 string 对象的内容指向。 黑色线条代表地址指向。
如上图所示。首先说一下 jdk6中的情况,在 jdk6中上述的所有打印都是 false 的,因为 jdk6中的常量池是放在 Perm 区中的,Perm 区和正常的 JAVA Heap 区域是完全分开的。上面说过如果是使用引号声明的字符串都是会直接在字符串常量池中生成,而 new 出来的 String 对象是放在 JAVA Heap 区域。所以拿一个 JAVA Heap 区域的对象地址和字符串常量池的对象地址进行比较肯定是不相同的,即使调用String.intern
方法也是没有任何关系的。
####2,jdk7中的解释
再说说 jdk7 中的情况。这里要明确一点的是,在 Jdk6 以及以前的版本中,字符串的常量池是放在堆的 Perm 区的,Perm 区是一个类静态的区域,主要存储一些加载类的信息,常量池,方法片段等内容,默认大小只有4m,一旦常量池中大量使用 intern 是会直接产生java.lang.OutOfMemoryError: PermGen space
错误的。 所以在 jdk7 的版本中,字符串常量池已经从 Perm 区移到正常的 Java Heap 区域了。为什么要移动,Perm 区域太小是一个主要原因,当然据消息称 jdk8 已经直接取消了 Perm 区域,而新建立了一个元区域。应该是 jdk 开发者认为 Perm 区域已经不适合现在 JAVA 的发展了。
正式因为字符串常量池移动到 JAVA Heap 区域后,再来解释为什么会有上述的打印结果。
- 在第一段代码中,先看 s3和s4字符串。
String s3 = new String("1") + new String("1");
,这句代码中现在生成了2最终个对象,是字符串常量池中的“1” 和 JAVA Heap 中的 s3引用指向的对象。中间还有2个匿名的new String("1")
我们不去讨论它们。此时s3引用对象内容是"11",但此时常量池中是没有 “11”对象的。 - 接下来
s3.intern();
这一句代码,是将 s3中的“11”字符串放入 String 常量池中,因为此时常量池中不存在“11”字符串,因此常规做法是跟 jdk6 图中表示的那样,在常量池中生成一个 "11" 的对象,关键点是 jdk7 中常量池不在 Perm 区域了,这块做了调整。常量池中不需要再存储一份对象了,可以直接存储堆中的引用。这份引用指向 s3 引用的对象。 也就是说引用地址是相同的。 -
最后
String s4 = "11";
这句代码中"11"是显示声明的,因此会直接去常量池中创建,创建的时候发现已经有这个对象了,此时也就是指向 s3 引用对象的一个引用。所以 s4 引用就指向和 s3 一样了。因此最后的比较s3 == s4
是 true。 -
再看 s 和 s2 对象。
String s = new String("1");
第一句代码,生成了2个对象。常量池中的“1” 和 JAVA Heap 中的字符串对象。s.intern();
这一句是 s 对象去常量池中寻找后发现 “1” 已经在常量池里了。 - 接下来
String s2 = "1";
这句代码是生成一个 s2的引用指向常量池中的“1”对象。 结果就是 s 和 s2 的引用地址明显不同。图中画的很清晰。
- 来看第二段代码,从上边第二幅图中观察。第一段代码和第二段代码的改变就是
s3.intern();
的顺序是放在String s4 = "11";
后了。这样,首先执行String s4 = "11";
声明 s4 的时候常量池中是不存在“11”对象的,执行完毕后,“11“对象是 s4 声明产生的新对象。然后再执行s3.intern();
时,常量池中“11”对象已经存在了,因此 s3 和 s4 的引用是不同的。 - 第二段代码中的 s 和 s2 代码中,
s.intern();
,这一句往后放也不会有什么影响了,因为对象池中在执行第一句代码String s = new String("1");
的时候已经生成“1”对象了。下边的s2声明都是直接从常量池中取地址引用的。 s 和 s2 的引用地址是不会相等的。
####小结
从上述的例子代码可以看出 jdk7 版本对 intern 操作和常量池都做了一定的修改。主要包括2点:- 将String常量池 从 Perm 区移动到了 Java Heap区
String#intern
方法时,如果存在堆中的对象,会直接保存对象的引用,而不会重新创建对象。
三,使用 intern
1,intern 正确使用例子
接下来我们来看一下一个比较常见的使用String#intern
方法的例子。
代码如下:
static final int MAX = 1000 * 10000;static final String[] arr = new String[MAX];public static void main(String[] args) throws Exception { Integer[] DB_DATA = new Integer[10]; Random random = new Random(10 * 10000); for (int i = 0; i < DB_DATA.length; i++) { DB_DATA[i] = random.nextInt(); } long t = System.currentTimeMillis(); for (int i = 0; i < MAX; i++) { //arr[i] = new String(String.valueOf(DB_DATA[i % DB_DATA.length])); arr[i] = new String(String.valueOf(DB_DATA[i % DB_DATA.length])).intern(); } System.out.println((System.currentTimeMillis() - t) + "ms"); System.gc();}
运行的参数是:-Xmx2g -Xms2g -Xmn1500M
上述代码是一个演示代码,其中有两条语句不一样,一条是使用 intern,一条是未使用 intern。结果如下图
2160ms
826ms
通过上述结果,我们发现不使用 intern 的代码生成了1000w 个字符串,占用了大约640m 空间。 使用了 intern 的代码生成了1345个字符串,占用总空间 133k 左右。其实通过观察程序中只是用到了10个字符串,所以准确计算后应该是正好相差100w 倍。虽然例子有些极端,但确实能准确反应出 intern 使用后产生的巨大空间节省。
细心的同学会发现使用了 intern 方法后时间上有了一些增长。这是因为程序中每次都是用了 new String
后,然后又进行 intern 操作的耗时时间,这一点如果在内存空间充足的情况下确实是无法避免的,但我们平时使用时,内存空间肯定不是无限大的,不使用 intern 占用空间导致 jvm 垃圾回收的时间是要远远大于这点时间的。 毕竟这里使用了1000w次intern 才多出来1秒钟多的时间。
2,intern 不当使用
看过了 intern 的使用和 intern 的原理等,我们来看一个不当使用 intern 操作导致的问题。
在使用 fastjson 进行接口读取的时候,我们发现在读取了近70w条数据后,我们的日志打印变的非常缓慢,每打印一次日志用时30ms左右,如果在一个请求中打印2到3条日志以上会发现请求有一倍以上的耗时。在重新启动 jvm 后问题消失。继续读取接口后,问题又重现。接下来我们看一下出现问题的过程。
####1,根据 log4j 打印日志查找问题原因
在使用log4j#info
打印日志的时候时间非常长。所以使用 housemd 软件跟踪 info 方法的耗时堆栈。
- trace SLF4JLogger.
- trace AbstractLoggerWrapper:
- trace AsyncLogger
org/apache/logging/log4j/core/async/AsyncLogger.actualAsyncLog(RingBufferLogEvent) sun.misc.Launcher$AppClassLoader@109aca82 1 1ms org.apache.logging.log4j.core.async.AsyncLogger@19de86bb org/apache/logging/log4j/core/async/AsyncLogger.location(String) sun.misc.Launcher$AppClassLoader@109aca82 1 30ms org.apache.logging.log4j.core.async.AsyncLogger@19de86bb org/apache/logging/log4j/core/async/AsyncLogger.log(Marker, String, Level, Message, Throwable) sun.misc.Launcher$AppClassLoader@109aca82 1 61ms org.apache.logging.log4j.core.async.AsyncLogger@19de86bb
代码出在 AsyncLogger.location
这个方法上. 里边主要是调用了 return Log4jLogEvent.calcLocation(fqcnOfLogger);
和Log4jLogEvent.calcLocation()
Log4jLogEvent.calcLocation()
的代码如下:
public static StackTraceElement calcLocation(final String fqcnOfLogger) { if (fqcnOfLogger == null) { return null; } final StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace(); boolean next = false; for (final StackTraceElement element : stackTrace) { final String className = element.getClassName(); if (next) { if (fqcnOfLogger.equals(className)) { continue; } return element; } if (fqcnOfLogger.equals(className)) { next = true; } else if (NOT_AVAIL.equals(className)) { break; } } return null; }
经过跟踪发现是 Thread.currentThread().getStackTrace();
的问题。
####2, 跟踪Thread.currentThread().getStackTrace()的 native 代码,验证String#intern
Thread.currentThread().getStackTrace();
native的方法:
public StackTraceElement[] getStackTrace() { if (this != Thread.currentThread()) { // check for getStackTrace permission SecurityManager security = System.getSecurityManager(); if (security != null) { security.checkPermission( SecurityConstants.GET_STACK_TRACE_PERMISSION); } // optimization so we do not call into the vm for threads that // have not yet started or have terminated if (!isAlive()) { return EMPTY_STACK_TRACE; } StackTraceElement[][] stackTraceArray = dumpThreads(new Thread[] {this}); StackTraceElement[] stackTrace = stackTraceArray[0]; // a thread that was alive during the previous isAlive call may have // since terminated, therefore not having a stacktrace. if (stackTrace == null) { stackTrace = EMPTY_STACK_TRACE; } return stackTrace; } else { // Don't need JVM help for current thread return (new Exception()).getStackTrace(); } } private native static StackTraceElement[][] dumpThreads(Thread[] threads);
下载 openJdk7的源码查询 jdk 的 native 实现代码,列表如下【这里因为篇幅问题,不详细罗列涉及到的代码,有兴趣的可以根据文件名称和行号查找相关代码】:
\openjdk7\jdk\src\share\native\java\lang\Thread.c
\openjdk7\hotspot\src\share\vm\prims\jvm.h line:294: \openjdk7\hotspot\src\share\vm\prims\jvm.cpp line:4382-4414: \openjdk7\hotspot\src\share\vm\services\threadService.cpp line:235-267: \openjdk7\hotspot\src\share\vm\services\threadService.cpp line:566-577: \openjdk7\hotspot\src\share\vm\classfile\javaClasses.cpp line:1635-[1651,1654,1658]:完成跟踪了底层的 jvm 源码后发现,是下边的三条代码引发了整个程序的变慢问题。
oop classname = StringTable::intern((char*) str, CHECK_0); oop methodname = StringTable::intern(method->name(), CHECK_0); oop filename = StringTable::intern(source, CHECK_0);
这三段代码是获取类名、方法名、和文件名。因为类名、方法名、文件名都是存储在字符串常量池中的,所以每次获取它们都是通过String#intern
方法。但没有考虑到的是默认的 StringPool 的长度是1009且不可变的。因此一旦常量池中的字符串达到的一定的规模后,性能会急剧下降。
####3,fastjson 不当使用 String#intern
导致这个 intern 变慢的原因是因为 fastjson 对String#intern
方法的使用不当造成的。跟踪 fastjson 中的实现代码发现,
####com.alibaba.fastjson.parser.JSONScanner#scanFieldSymbol()
if (ch == '\"') { bp = index; this.ch = ch = buf[bp]; strVal = symbolTable.addSymbol(buf, start, index - start - 1, hash); break;}
####com.alibaba.fastjson.parser.SymbolTable#addSymbol()
:
/** * Constructs a new entry from the specified symbol information and next entry reference. */public Entry(char[] ch, int offset, int length, int hash, Entry next){ characters = new char[length]; System.arraycopy(ch, offset, characters, 0, length); symbol = new String(characters).intern(); this.next = next; this.hashCode = hash; this.bytes = null;}
fastjson 中对所有的 json 的 key 使用了 intern 方法,缓存到了字符串常量池中,这样每次读取的时候就会非常快,大大减少时间和空间。而且 json 的 key 通常都是不变的。这个地方没有考虑到大量的 json key 如果是变化的,那就会给字符串常量池带来很大的负担。
这个问题 fastjson 在1.1.24版本中已经将这个漏洞修复了。程序加入了一个最大的缓存大小,超过这个大小后就不会再往字符串常量池中放了。
[1.1.24版本的com.alibaba.fastjson.parser.SymbolTable#addSymbol()
Line:113]代码
public static final int MAX_SIZE = 1024;if (size >= MAX_SIZE) { return new String(buffer, offset, len);}
这个问题是70w 数据量时候的引发的,如果是几百万的数据量的话可能就不只是30ms 的问题了。因此在使用系统级提供的String#intern
方式一定要慎重!
五,总结
本文大体的描述了 String#intern
和字符串常量池的日常使用,jdk 版本的变化和String#intern
方法的区别,以及不恰当使用导致的危险等内容,让大家对系统级别的 String#intern
有一个比较深入的认识。让我们在使用和接触它的时候能避免出现一些 bug,增强系统的健壮性。
引用:
以下是几个比较关键的几篇博文。感谢!
-----------------------------
Java内存访问重排序的研究
什么是重排序
请先看这样一段代码1
public class PossibleReordering {static int x = 0, y = 0;static int a = 0, b = 0;public static void main(String[] args) throws InterruptedException { Thread one = new Thread(new Runnable() { public void run() { a = 1; x = b; } }); Thread other = new Thread(new Runnable() { public void run() { b = 1; y = a; } }); one.start();other.start(); one.join();other.join(); System.out.println(“(” + x + “,” + y + “)”);}
很容易想到这段代码的运行结果可能为(1,0)、(0,1)或(1,1),因为线程one可以在线程two开始之前就执行完了,也有可能反之,甚至有可能二者的指令是同时或交替执行的。
然而,这段代码的执行结果也可能是(0,0). 因为,在实际运行时,代码指令可能并不是严格按照代码语句顺序执行的。得到(0,0)结果的语句执行过程,如下图所示。值得注意的是,a=1和x=b这两个语句的赋值操作的顺序被颠倒了,或者说,发生了指令“重排序”(reordering)。(事实上,输出了这一结果,并不代表一定发生了指令重排序,内存可见性问题也会导致这样的输出,详见后文)
对重排序现象不太了解的开发者可能会对这种现象感到吃惊,但是,笔者开发环境下做的一个小实验证实了这一结果2。
实验代码是构造一个循环,反复执行上面的实例代码,直到出现a=0且b=0的输出为止。实验结果说明,循环执行到第13830次时输出了(0,0).
大多数现代微处理器都会采用将指令乱序执行(out-of-order execution,简称OoOE或OOE)的方法,在条件允许的情况下,直接运行当前有能力立即执行的后续指令,避开获取下一条指令所需数据时造成的等待3。通过乱序执行的技术,处理器可以大大提高执行效率。
除了处理器,常见的Java运行时环境的JIT编译器也会做指令重排序操作4,即生成的机器指令与字节码指令顺序不一致。as-if-serial语义
As-if-serial语义的意思是,所有的动作(Action)5都可以为了优化而被重排序,但是必须保证它们重排序后的结果和程序代码本身的应有结果是一致的。Java编译器、运行时和处理器都会保证单线程下的as-if-serial语义。
比如,为了保证这一语义,重排序不会发生在有数据依赖的操作之中。int a = 1;
int b = 2; int c = a + b;将上面的代码编译成Java字节码或生成机器指令,可视为展开成了以下几步动作(实际可能会省略或添加某些步骤)。
- 对a赋值1
- 对b赋值2
- 取a的值
- 取b的值
- 将取到两个值相加后存入c
在上面5个动作中,动作1可能会和动作2、4重排序,动作2可能会和动作1、3重排序,动作3可能会和动作2、4重排序,动作4可能会和1、3重排序。但动作1和动作3、5不能重排序。动作2和动作4、5不能重排序。因为它们之间存在数据依赖关系,一旦重排,as-if-serial语义便无法保证。
为保证as-if-serial语义,Java异常处理机制也会为重排序做一些特殊处理。例如在下面的代码中,y = 0 / 0可能会被重排序在x = 2之前执行,为了保证最终不致于输出x = 1的错误结果,JIT在重排序时会在catch语句中插入错误代偿代码,将x赋值为2,将程序恢复到发生异常时应有的状态。这种做法的确将异常捕捉的逻辑变得复杂了,但是JIT的优化的原则是,尽力优化正常运行下的代码逻辑,哪怕以catch块逻辑变得复杂为代价,毕竟,进入catch块内是一种“异常”情况的表现。6
public class Reordering { public static void main(String[] args) { int x, y; x = 1; try { x = 2; y = 0 / 0; } catch (Exception e) { } finally { System.out.println("x = " + x); } }}
内存访问重排序与内存可见性
计算机系统中,为了尽可能地避免处理器访问主内存的时间开销,处理器大多会利用缓存(cache)以提高性能。其模型如下图所示。
在这种模型下会存在一个现象,即缓存中的数据与主内存的数据并不是实时同步的,各CPU(或CPU核心)间缓存的数据也不是实时同步的。这导致在同一个时间点,各CPU所看到同一内存地址的数据的值可能是不一致的。从程序的视角来看,就是在同一个时间点,各个线程所看到的共享变量的值可能是不一致的。
有的观点会将这种现象也视为重排序的一种,命名为“内存系统重排序”。因为这种内存可见性问题造成的结果就好像是内存访问指令发生了重排序一样。 这种内存可见性问题也会导致章节一中示例代码即便在没有发生指令重排序的情况下的执行结果也还是(0, 0)。内存访问重排序与Java内存模型
Java的目标是成为一门平台无关性的语言,即Write once, run anywhere. 但是不同硬件环境下指令重排序的规则不尽相同。例如,x86下运行正常的Java程序在IA64下就可能得到非预期的运行结果。为此,JSR-1337制定了Java内存模型(Java Memory Model, JMM),旨在提供一个统一的可参考的规范,屏蔽平台差异性。从Java 5开始,Java内存模型成为Java语言规范的一部分。
根据Java内存模型中的规定,可以总结出以下几条happens-before规则8。Happens-before的前后两个操作不会被重排序且后者对前者的内存可见。- 程序次序法则:线程中的每个动作A都happens-before于该线程中的每一个动作B,其中,在程序中,所有的动作B都能出现在A之后。
- 监视器锁法则:对一个监视器锁的解锁 happens-before于每一个后续对同一监视器锁的加锁。
- volatile变量法则:对volatile域的写入操作happens-before于每一个后续对同一个域的读写操作。
- 线程启动法则:在一个线程里,对Thread.start的调用会happens-before于每个启动线程的动作。
- 线程终结法则:线程中的任何动作都happens-before于其他线程检测到这个线程已经终结、或者从Thread.join调用中成功返回,或Thread.isAlive返回false。
- 中断法则:一个线程调用另一个线程的interrupt happens-before于被中断的线程发现中断。
- 终结法则:一个对象的构造函数的结束happens-before于这个对象finalizer的开始。
- 传递性:如果A happens-before于B,且B happens-before于C,则A happens-before于C
Happens-before关系只是对Java内存模型的一种近似性的描述,它并不够严谨,但便于日常程序开发参考使用,关于更严谨的Java内存模型的定义和描述,请阅读JSR-133原文或Java语言规范章节17.4。
除此之外,Java内存模型对volatile和final的语义做了扩展。对volatile语义的扩展保证了volatile变量在一些情况下不会重排序,volatile的64位变量double和long的读取和赋值操作都是原子的。对final语义的扩展保证一个对象的构建方法结束前,所有final成员变量都必须完成初始化(的前提是没有this引用溢出)。
Java内存模型关于重排序的规定,总结后如下表所示。
表中“第二项操作”的含义是指,第一项操作之后的所有指定操作。如,普通读不能与其之后的所有volatile写重排序。另外,JMM也规定了上述volatile和同步块的规则尽适用于存在多线程访问的情景。例如,若编译器(这里的编译器也包括JIT,下同)证明了一个volatile变量只能被单线程访问,那么就可能会把它做为普通变量来处理。
留白的单元格代表允许在不违反Java基本语义的情况下重排序。例如,编译器不会对对同一内存地址的读和写操作重排序,但是允许对不同地址的读和写操作重排序。除此之外,为了保证final的新增语义。JSR-133对于final变量的重排序也做了限制。
- 构建方法内部的final成员变量的存储,并且,假如final成员变量本身是一个引用的话,这个final成员变量可以引用到的一切存储操作,都不能与构建方法外的将当期构建对象赋值于多线程共享变量的存储操作重排序。例如对于如下语句 x.finalField = v; ... ;构建方法边界sharedRef = x; v.afield = 1; x.finalField = v; ... ; 构建方法边界sharedRef = x; 这两条语句中,构建方法边界前后的指令都不能重排序。
- 初始读取共享对象与初始读取该共享对象的final成员变量之间不能重排序。例如对于如下语句 x = sharedRef; ... ; i = x.finalField; 前后两句语句之间不会发生重排序。由于这两句语句有数据依赖关系,编译器本身就不会对它们重排序,但确实有一些处理器会对这种情况重排序,因此特别制定了这一规则。
内存屏障
内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题。Java编译器也会根据内存屏障的规则禁止重排序。
内存屏障可以被分为以下几种类型 LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。 StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。 LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。 StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。有的处理器的重排序规则较严,无需内存屏障也能很好的工作,Java编译器会在这种情况下不放置内存屏障。
为了实现上一章中讨论的JSR-133的规定,Java编译器会这样使用内存屏障。为了保证final字段的特殊语义,也会在下面的语句加入内存屏障。
x.finalField = v; StoreStore; sharedRef = x;Intel 64/IA-32架构下的内存访问重排序
Intel 64和IA-32是我们较常用的硬件环境,相对于其它处理器而言,它们拥有一种较严格的重排序规则。Pentium 4以后的Intel 64或IA-32处理的重排序规则如下。9
在单CPU系统中
- 读操作不与其它读操作重排序。
- 写操作不与其之前的写操作重排序。
- 写内存操作不与其它写操作重排序,但有以下几种例外
- CLFLUSH的写操作
- 带有non-temporal move指令(MOVNTI, MOVNTQ, MOVNTDQ, MOVNTPS, and MOVNTPD)的streaming写入。
- 字符串操作
- 读操作可能会与其之前的写不同位置的写操作重排序,但不与其之前的写相同位置的写操作重排序。
- 读和写操作不与I/O指令,带锁的指令或序列化指令重排序。
- 读操作不能重排序到LFENCE和MFENCE之前。
- 写操作不能重排序到LFENCE、SFENCE和MFENCE之前。
- LFENCE不能重排序到读操作之前。
- SFENCE不能重排序到写之前。
- MFENCE不能重排序到读或写操作之前。
在多处理器系统中
- 各自处理器内部遵循单处理器的重排序规则。
- 单处理器的写操作对所有处理器可见是同时的。
- 各自处理器的写操作不会重排序。
- 内存重排序遵守因果性(causality)(内存重排序遵守传递可见性)。
- 任何写操作对于执行这些写操作的处理器之外的处理器来看都是一致的。
- 带锁指令是顺序执行的。
值得注意的是,对于Java编译器而言,Intel 64/IA-32架构下处理器不需要LoadLoad、LoadStore、StoreStore屏障,因为不会发生需要这三种屏障的重排序。
一例Intel 64/IA-32架构下的代码性能优化
现在有这样一个场景,一个容器可以放一个东西,容器支持create方法来创建一个新的东西并放到容器里,支持get方法取到这个容器里的东西。我们可以较容易地写出下面的代码。
public class Container { public static class SomeThing { private int status; public SomeThing() { status = 1; } public int getStatus() { return status; } } private SomeThing object; public void create() { object = new SomeThing(); } public SomeThing get() { while (object == null) { Thread.yield(); //不加这句话可能会在此出现无限循环 } return object; }}
在单线程场景下,这段代码执行起来是没有问题的。但是在多线程并发场景下,由不同的线程create和get东西,这段代码是有问题的。问题的原因与普通的双重检查锁定单例模式(Double Checked Locking, DCL)10类似,即SomeThing的构建与将指向构建中的SomeThing引用赋值到object变量这两者可能会发生重排序。导致get中返回一个正被构建中的不完整的SomeThing对象实例。为了解决这一问题,通常的办法是使用volatile修饰object字段。这种方法避免了重排序,保证了内存可见性,摒弃比使用同步块导致的性能损失更小。但是,假如使用场景对object的内存可见性并不敏感的话(不要求一个线程写入了object,object的新值立即对下一个读取的线程可见),在Intel 64/IA-32环境下,有更好的解决方案。
根据上一章的内容,我们知道Intel 64/IA-32下写操作之间不会发生重排序,即在处理器中,构建SomeThing对象与赋值到object这两个操作之间的顺序性是可以保证的。这样看起来,仅仅使用volatile来避免重排序是多此一举的。但是,Java编译器却可能生成重排序后的指令。但令人高兴的是,Oracle的JDK中提供了Unsafe. putOrderedObject,Unsafe. putOrderedInt,Unsafe. putOrderedLong这三个方法,JDK会在执行这三个方法时插入StoreStore内存屏障,避免发生写操作重排序。而在Intel 64/IA-32架构下,StoreStore屏障并不需要,Java编译器会将StoreStore屏障去除。比起写入volatile变量之后执行StoreLoad屏障的巨大开销,采用这种方法除了避免重排序而带来的性能损失以外,不会带来其它的性能开销。
我们将做一个小实验来比较二者的性能差异。一种是使用volatile修饰object成员变量。public class Container { public static class SomeThing { private int status; public SomeThing() { status = 1; } public int getStatus() { return status; } } private volatile SomeThing object; public void create() { object = new SomeThing(); } public SomeThing get() { while (object == null) { Thread.yield(); //不加这句话可能会在此出现无限循环 } return object; }}
一种是利用Unsafe. putOrderedObject在避免在适当的位置发生重排序。
public class Container { public static class SomeThing { private int status; public SomeThing() { status = 1; } public int getStatus() { return status; } } private SomeThing object; private Object value; private static final Unsafe unsafe = getUnsafe(); private static final long valueOffset; static { try { valueOffset = unsafe.objectFieldOffset(Container.class.getDeclaredField("value")); } catch (Exception ex) { throw new Error(ex); } } public void create() { SomeThing temp = new SomeThing(); unsafe.putOrderedObject(this, valueOffset, null); //将value赋null值只是一项无用操作,实际利用的是这条语句的内存屏障 object = temp; } public SomeThing get() { while (object == null) { Thread.yield(); } return object; } public static Unsafe getUnsafe() { try { Field f = Unsafe.class.getDeclaredField("theUnsafe"); f.setAccessible(true); return (Unsafe)f.get(null); } catch (Exception e) { } return null; }}
由于直接调用Unsafe.getUnsafe()需要配置JRE获取较高权限,我们利用反射获取Unsafe中的theUnsafe来取得Unsafe的可用实例。
unsafe.putOrderedObject(this, valueOffset, null) 这句仅仅是为了借用这句话功能的防止写重排序,除此之外无其它作用。利用下面的代码分别测试两种方案的实际运行时间。在运行时开启-server和 -XX:CompileThreshold=1以模拟生产环境下长时间运行后的JIT优化效果。
public static void main(String[] args) throws InterruptedException { final int THREADS_COUNT = 20; final int LOOP_COUNT = 100000; long sum = 0; long min = Integer.MAX_VALUE; long max = 0; for(int n = 0;n <= 100;n++) { final Container basket = new Container(); ListputThreads = new ArrayList (); List takeThreads = new ArrayList (); for (int i = 0; i < THREADS_COUNT; i++) { putThreads.add(new Thread() { @Override public void run() { for (int j = 0; j < LOOP_COUNT; j++) { basket.create(); } } }); takeThreads.add(new Thread() { @Override public void run() { for (int j = 0; j < LOOP_COUNT; j++) { basket.get().getStatus(); } } }); } long start = System.nanoTime(); for (int i = 0; i < THREADS_COUNT; i++) { takeThreads.get(i).start(); putThreads.get(i).start(); } for (int i = 0; i < THREADS_COUNT; i++) { takeThreads.get(i).join(); putThreads.get(i).join(); } long end = System.nanoTime(); long period = end - start; if(n == 0) { continue; //由于JIT的编译,第一次执行需要更多时间,将此时间不计入统计 } sum += (period); System.out.println(period); if(period < min) { min = period; } if(period > max) { max = period; } } System.out.println("Average : " + sum / 100); System.out.println("Max : " + max); System.out.println("Min : " + min);}
在笔者的计算机上运行测试,采用volatile方案的运行结果如下
Average : 62535770 Max : 82515000 Min : 45161000采用unsafe.putOrderedObject方案的运行结果如下
Average : 50746230 Max : 68999000 Min : 38038000从结果看出,unsafe.putOrderedObject方案比volatile方案平均耗时减少18.9%,最大耗时减少16.4%,最小耗时减少15.8%.另外,即使在其它会发生写写重排序的处理器中,由于StoreStore屏障的性能损耗小于StoreLoad屏障,采用这一方法也是一种可行的方案。但值得再次注意的是,这一方案不是对volatile语义的等价替换,而是在特定场景下做的特殊优化,它仅避免了写写重排序,但不保证内存可见性。
- 样例选自《Java并发编程实践》章节16.1
- 实验代码见附1
- Oracle Java Hotspot IBM JVM
- Java语言规范中对“动作”这个词有一个明确而具体的定义,详见
- 参见《Java并发编程实践》章节16.1
- Intel® 64 and IA-32 Architectures Software Developer’s Manual Volume 3 (3A, 3B & 3C): System Programming Guide章节8.2
###附1 复现重排序现象实验代码
public class Test { private static int x = 0, y = 0; private static int a = 0, b =0; public static void main(String[] args) throws InterruptedException { int i = 0; for(;;) { i++; x = 0; y = 0; a = 0; b = 0; Thread one = new Thread(new Runnable() { public void run() { //由于线程one先启动,下面这句话让它等一等线程two. 读着可根据自己电脑的实际性能适当调整等待时间. shortWait(100000); a = 1; x = b; } }); Thread other = new Thread(new Runnable() { public void run() { b = 1; y = a; } }); one.start();other.start(); one.join();other.join(); String result = "第" + i + "次 (" + x + "," + y + ")"; if(x == 0 && y == 0) { System.err.println(result); break; } else { System.out.println(result); } } } public static void shortWait(long interval){ long start = System.nanoTime(); long end; do{ end = System.nanoTime(); }while(start + interval >= end); }}