上文讲过了虚拟机的内存划分,即,我们将内存分为线程共享和线程私有。
线程共享的即java堆,和方法区。java堆大家可能都不会陌生;而方法区中包含了常量池,他也被称为永久代。通常方法区也会被叫做非堆,但是在逻辑上,他却是java堆的一部分,而且有些虚拟机会将方法区直接与java堆合并。
线程私有的就是虚拟机栈了,而虚拟机栈,本地方法栈,以及程序计数器。这里我们就不展开讨论了。
上面我就简单的回顾了虚拟机的内存划分部分,下面开始正文。
java内存模型规定了,所有的变量都必须存储在主内存当中。
每天线程私有的内存,即工作内存。
工作内存中保存了该线程所使用的变量的主内存的副本的拷贝。线程对变量所做的操作,都必须在工作内存中进行。
不同个的线程,无法访问对方的工作内存变量,只能通过主内存,来达到线程、工作内存、主内存三者之间的信息交互。
简图如下:
主内存、工作内存,与我上一篇博客中讲述的java内存区域中的堆、栈、方法区等,并不是同一个层次的内存划分。
不同,为了方便记忆,我们可以这么理解:
主内存对应的是java堆中的实例数据部分,工作内存对应的是java虚拟机栈中的部分区域。
从计算机的组织原理来说,我们也可以这么来理解,主内存对应的是物理硬件的内存,所以如果主内存与进程进行数据交互,它将是非常耗时的。
工作内存优先存储在寄存器和高速缓存中,因为程序在运行一般访问的是工作内存。
(所以我在上篇博客的开头就讲了,抛开操作系统和组织原理来讲虚拟机,就是在耍流氓 =_=)
No BB, show code
private static volatile int i = 0;
public static void add(){
i++;
}
public static void main(String [] args){
for(int c=0; c<20; c++){
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
for(int k = 0; k<10000; k++){
add();
}
}
});
thread.start();
}
while (Thread.activeCount()>1){
Thread.yield();
}
System.out.println(i);
}
如果你看过上面的代码,那可以继续阅读,如果没有见过上面的代码,这里建议思考下,最后输出的值是多少?
显而易见,结果并不是200000。(如果最后的结果就是200000,那么我举这个例子干嘛 =。=)
无论是长辈,还是其他人的建议,都提过,带着问题阅读的效率会比漫无目的阅读,效果好很多,所以上面我提出了问题,下文自然是为了解决问题而展开的额讨论和说明。这里,先从java虚拟机内存的操作开始讲起。
原子操作分为两部分,一般,通过read、load、use、write等读写操作,就可以保证数据的原子性。
但是有时候我们需要整块的业务代码,都具有原子性时,就需要使用lock与unlock。
细心的同学可能已经发现了,我上面的代码中。遍历时被volatile声明。
那么volatile的作用是什么呢?
一般来说,volatile变量对所有的线程,都是理解可见的。对于volatile变量所有的写操作,都能理解反应到其他线程中。
换言之,volatile在所有线程中都是一致的,所以,所有基于volatile变量的运算在并发下都是安全的。
其实不然,volatile变量,并不能保证并发安全。
变量类型 | 执行结果1 | 执行结果2 | 执行结果3 | 执行结果4 | 执行结果5 | 平均值(去掉极值) |
---|---|---|---|---|---|---|
volatile | 186632 | 196403 | 193658 | 197305 | 186825 | 192295 |
一般变量 | 178387 | 179369 | 189835 | 174015 | 199458 | 182530 |
我记录了五次代码的执行结果。如上表格所示。都不是我们的目标值200000。那是不是说明volatile声明的变量和不进行声明,是完全一致的呢?
非也,我在去掉了volatile声明后,执行得到的结果,如上表格展示。
最后得出的结论是,加了volatile声明,结果更加趋近目标值。造成这一现象的原因是什么呢?
查看字节码的方式有一般有两种。
一是找到生产的class文件,执行 javap指令,查看编译的代码。
二是,如果你用的是idea编辑器(idea天下第一),你可以在选中要查看的java类后,点击view菜单 点击 Show Bytecode。
这两种方式,我一般选择方式二,方式二方便,且查看的代码格式符合我的阅读习惯。
public static void add();
descriptor: ()V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=0, args_size=0
0: getstatic #2 // Field i:I
3: iconst_1
4: iadd
5: putstatic #2 // Field i:I
8: return
LineNumberTable:
line 12: 0
line 13: 8
volatile变量声明的对象,的的确确是,当他在主内存中的值发生变化,会立即反应到工作内存中。
这里就有一个节点,也就是我们字节码中的
getstatic
getstatic指令,此指令,是获取了当前最新的实时的变量值。后续的对此值进行+1操作,然后返回。但是可能存在一个情况,就是在执行+1操作或者返回操作时,其他线程对这个值进行了处理,导致此线程返回的值并不是正确值了。
可能还是不太理解,我们模拟一下场景。
不加volatile声明,可能在进入线程后,未进行getstatic指令前,变量值发生了改变,而线程不知道。
所以,这也就是加了
讲到这里,我想大家应该对上方的代码执行结果没有什么疑虑了。
但是问题又来了,如何确保能正确的得到目标值呢。
相比大家看到此关键字,就已经知道了我下面要讲什么了。
public synchronized static void add(){
i++;
}
对add方法,加了synchronized关键字进行修饰之后,最后得到的目标结果,就是我们的目标值20000了。
当然,越是万能,往往代表越是无能。
此方法的性能会比使用自己手动的进行lock以及unlock,性能要差很多。
特别是在1.5的jdk版本,性能差异非常大。不过在后续的jdk版本中,逐渐对synchronized在进行优化。而且官方也推荐这种方式,毕竟,他较之ReentrantLock要优雅、coooooool很多。
private static int i = 0;
private static ReentrantLock lock = new ReentrantLock();
public static void add(){
lock.lock();
try{
i++;
}finally {
lock.unlock();
}
}
即使是i++,我们也要进行try,这是为了养成良好的语义习惯 =_=
每一次加锁,必然要进行一次解锁。不然…嘿嘿嘿嘿
需要说明的是,ReentrantLock(重入锁)比之synchronized,多了其他的高级功能,等待可中断、实现公平锁、所可以绑定多个条件。这里就不进行展开讨论。
private static AtomicInteger i =new AtomicInteger(0);
public static void add(){
i.addAndGet(1);
}
Atomic对象有很多,如AtomicBoolean、AtomicLong等。
他保证了数据操作的原子性,实现原理是通过CAS原理。
何为CAS?即比较和交换:
获取主内存值(A),将获取到的值(A)与新的值(B)放入参数。在此获取其值,如果,获取到的值与传输的值A一致,就修改主内存值为新的值B。
这也就是CAS
当然在Atimic的实现中,还是用了Unsafe类,他可以直接操作物理内存!!!!
这里我们不对他详细的展开论述。
内存模型中,分为工作内存与主内存。
这么讲其实没意义,我换个说法,为什么要区分工作内存和主内存??
线程是程序运行的基础,而线程需要与计算机进行数据交换,而由于计算机的组成,进行数据交换会,有的内存区域传输快,有的传输慢。而且也为了保证数据的安全性,我们区分出了主内存(可以狭义的理解为物理内存)与工作内存(寄存器即高速缓存,当量大时,也会存储到物理内存中)
在重温JVM时,我多次的是思考了为什么?也就是为什么要这么设计,这么设计有什么好处,收益颇多。
《深入理解Java虚拟机》