文章初衷:
这篇文章,也是在解决我在java学习上的一些疑惑,堆、栈、堆栈,以及方法区、jvm虚拟机栈、本地方法栈,对于大学生的我,很是头疼,在学习jvm时候,学习到 jvm 结构模型,然后和之前看的JMM Java对象模型,是有区别的,有一次,碰到一个java是值传递还是引用传递,也用到了堆栈,总之很重要,想要对很多java知识的彻底理解,JMM是很重要的,然而大多是资料,怎么说,不讲人话,我是学一半就放弃,于是乎,就写本篇博客,一是方便自己复习巩固,二是帮助和我一样的小白,减少踩坑。文章可能有些长,但是一定很值得研究!
这里主要讲述,并发底层到底什么,怎么实现的?
Demo.java---javac编译--->Demo.class(字节码)------java运行---->JVM
字节码,byteCode,平台无关性
JVM会把字节码翻译成不同平台的cpu指令,例如windows、linux等等
而不同的cpu机器指令千差万别,无法保证并发安全的效果是一致的,JMM内存模型就诞生了!
通过JMM保证java代码最终落地,实现的是一样的
JVM内存结构 VS Java内存模型 VS Java对象模型
上面说到,因为java的平台无关性,衍生的问题,出现了java内存模型,这就使得java独特的内存模型,出现很多误导,我看各种文档,也是十分的混淆,怎么说,就是误人子弟!浪费生命,所以有必要细致讲一下这里的区别
JVM内存结构,和java虚拟机的运行时区域有关,可以理解是java代码存放的jvm中,的内存分离
Java内存模型,记住和java的并发编程相关就可以了
Java对象模型,指的是对象在虚拟机中的表现形式
java代码是运行在jvm虚拟机,虚拟机会把运行过程中的内存分为不同的区域,方便管理
运行时数据区,分为下面五部分,一提到jvm内存结构,我们脑子里第一个词是运行时数据区分为5个部分,知道内存结构式什么
这是我jvm专栏里的一张图,这里重点说一下,元数据区,也就是方法区、和堆,是线程共享的,其他都是线程私有的
堆Heap存放的对象,各种对象、以及数组,GC会回收
虚拟机栈(VM stack)
保存了各种基本数据类型,以及对于对象的引用,特点是:编译的时候,虚拟机栈的大小就已经确定了,并且运行的时候,大小不会改变,
方法区:
存储以及加载的static静态变量,类信息,常量信息,包含永久引用,比如static修饰一个对象,对象在新版jdk中,也是放在堆中的,这个引用就是永久引用,不会随线程的消逝而影响
本地方法栈:
native方法相关的栈
程序计数器:
保存当前字节码文件执行的行号数,还包含下一条需要执行的指令、异常处理等
这就是jvm内存模型
java是面向对象的,所以每一个对象在jvm存储是有一定结构的,这个结构,指的就是java对象模型
可以说java对象模型是依托jvm内存结构的,专门指java对象如何在jvm存储,以及它的结构,如下图:
一个类的诞生,会在方法区定义类的一些信息jvm给这个类创建一个instanceKlass保存在方法区,当
被创建对象时,对象会在堆中出现,堆中对象的结构包括对象头(MarkWord+元数据指针,指向方法去类的信息) 和实例数据,也就是类中定义的数据
,对象被某一个方法调用,就会在栈中把引用保存下来
所以java对象模型是栈、堆、方法区构成的
JMM---Java Momory Model java内存模型
C、C++语言,不存在内存模型概念,
依赖处理器,不同的处理器处理的结果不一样!,无法保证并发安全
假设java没有JMM内存模型,把代码用synchronized保护起来,不同平台处理,还是可能有问题的,所以就需要一个标准,让多线程运行的结果可预期,通过jmm的规范,
没错JMM就是一个规范,让java语言,jvm、编译器、cpu利用这个规范,让咱顶层开发不用考虑很多细节
比如:jvm有各种实现比如openJDK,让他们解释代码、重排序要遵守这个规范,以免不同虚拟机导致执行的结果不一样,
比如volatile、synchronized、Lock等的原理都是JMM
JMM的出现,让我们使用这些关键字就可以开发并发的程序,不需要自己注意内存栅栏的开发
知道JMM干什么的了
那么重点是什么,
一提到JMM,我们马上就需要说出来,重排序、可见性、原子性,这是JMM最主要内容,可以说,JMM就是重排序+可见性+原子性
public class OutOfOrderExecution {
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;
CountDownLatch latch = new CountDownLatch(1);
Thread one = new Thread(new Runnable() {
@Override
public void run() {
try {
latch.countDown();
latch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
a = 1;
x = b;
}
});
Thread two = new Thread(new Runnable() {
@Override
public void run() {
try {
latch.countDown();
latch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
b = 1;
y = a;
}
});
two.start();
one.start();
latch.countDown();
one.join();
two.join();
String result = "第" + i + "次(" + x + "," + y + ")";
if (x == 0 && y == 0) {
System.out.println(result);
break;
} else {
System.out.println(result);
}
}
}
}
这3个条件,就是正常不发生重排序的可能,也就是默认线程里执行的顺序都是一样的,有三种情况
那么00这个也是会发生的
这是为何?
y=a \ a=1\x=b\b=1这种情况,不按照顺序执行,会出现上面运行的情况,这就是重排序
看这个例子,9条指令,重排序后,简化为7条,一条没什么,多了呢?就会明显提高速度
包括jvm、JIT编译器等,
常见的是指令没有依赖关系的情况,会认为没有依赖关系,会默认进行指令重排
编译器不指令重排,cpu也可能进行指令重排
这里的重排序并不是真正的重排序,表面现象的重排序
因为内存中有缓存存在,在JMM中,表现为主存和本地内存,他们不一定保持一致,这就导致A线程的修改B线程看不到,就会出现和重排序一样的现象,
这里也引出了可见性问题
public class FieldVisibility {
int a = 1;
int b = 2;
private void change() {
a = 3;
b = a;
}
private void print() {
System.out.println("b="+b+";a="+a);
}
public static void main(String[] args) {
while (true){
FieldVisibility test = new FieldVisibility();
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
test.change();
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
test.print();
}
}).start();
}
}
}
分析段代码可能出现的结果
a=3 b=3 先运行change线程
a=1 b=2 先运行print线程
a=3 b=2 change运行一个a=3,然后print
but,上面代码运行还有一种情况的出现
假设第一个线程change了,a = 3 b=3,但是第二个线程只看到了b 的修改,没看到a的修改,就会出现 a=1 b=3的情况
we know,cpu多核时代,每个核有自己的内存,他们有一个共享的内存,通过共享的内存交互,就出现了延迟的现象,也就是可见性问题的由来,也就是八股中常看到的,主存和本地内存
总结就是:CPU有多级缓存,读到了过期 的旧数据
当然这是一个简单的图,学过计算机组成、或者操作系统的伙伴,一定知道,cpu的多级缓存,多核cpu,一级,二级,三级缓存,还有主存,还有离核最近的寄存器,RAM主存,速度和成本,大家可以从网上看哈,这里只讲原理,因为多级缓存的提效,这就是为什么会有可见性问题,
这里也回到了刚开始说的为什么需要JMM的问题,依赖处理器,不同OS的缓存也是不一样的,这就需要一个JMM规范,来防止出现因为底层系统原因导致的同步可见性问题。
我们再看一个例子
public class FieldVisibility {
int x = 0;
public void wiriterThread(){
x = 1;
}
public void readerThread(){
int r2 = x;
}
}
如果不发生可见性问题,那么读处理的是1,发生则是0
当发生的时候,就会出现这样的问题,线程一写进去了,但是还没同步到主存,线程2,读的就是旧的数据0
当我们使用valatile修饰变量的时候,就不会有可见性的问题,保证主存写入,本地内存一定能读到最新的数据
他会让线程1,强制的flush到主内存中,就会读取到正确的值,r2读shard cache主存的时候,就会读到正确的值
上面说到了,操作系统层面的多级缓存,引发的可见性问题,但是java语言屏蔽了细节,我们只需要考虑,JMM内存模型抽象出来的主内存和本地内存的概念。并没有寄存器,一级缓存,二层缓存,所有的处理器,到这层首先上,只有两个,就是主内存和本地内存,底层的不需要考虑了,就是下面这张图
这样抽象成两次,既满足了开发,也满足了cpu的多级缓存,既开发方便了些,又没浪费cpu缓存的机制
这里很重要!!!!!!!!!!!!!!!!!
JMM是这样规定他们的关系的
1:所有的变量都存储在主存中,每个线程有自己独立的工作内存,工作内存中的变量都是对主存中的拷贝。主体都是在主内存中的
2:线程不能直接读写主内存变量,只能操作自己工作内存,再同步主内存
3:主内存是多个线程共享的,线程之间是不共享的,线程通信需要通过主内存通信
总结一下:变量是存在主内存的,每个线程有自己的工作内存,但是线程的交互需要通过主内存中转,这也是为什么会出现可见性问题
先说什么不是,便于理解,
第一个线程,执行一个东西,线程B有时候看的到,有时候看不到,说明不具备happens-before,就是上面change、print、未加volitaie关键字的例子
一个操作发生只后,对另一个操作一定是可见的,这就是happends-Before,重点是可见性的问题
这里不一定能记住哈,哈哈,但是了解是很有必要的
我们讲到,本地内存,也就是工作内存,是有自己的内存的,那么对于单个线程,比如某个线程具有两条语句,第一个语句执行了,那么第二个语句一定是能看到的,这种在单个线程内语句执行的可见,就是单线程原则
private void change() {
a = 3;
b = a;
}
比如这个操作,某一行执行,那么另一行一定是可见的,为什么是某一行,不是第一行呢?对的,就是之前说的重排序问题
synchronized 和 lock
这个就很简单了,线程A拿到锁,操作,那么线程B拿到锁的时候,是一定能看到的
volatile就是上面的例子,只要这个变量加了volatile变量,修改了之后 ,其他线程就一定能读到
这里线程启动,默认是没有问题的,比如主线程,和子线程,子线程启动的时候,能看到主线程之前发生的事情
如何执行了join,那么join一下的代码一定能看到join线程之前的操作。
如果hb(A,B) hb(B,C) 那么可以推出hb(A,C)
一个线程被其他线程interrupt,那么一定能检测到他们被中断的。
构造方法的最后一条指令happens-before于finalize()方法的第一条指令
1、线程安全的容器get一定能看到之前put等存入动作
2、CountDownLatch(关注)
这里就是必须等执行完latch.countDown()之后,latch.await() 才能苏醒,保证闸门的正常
3、Semaphere(关注)
信号量,想要在信号量里面获取一个许可证,那么必须之前必须要有人释放,才能保证Semaphere正常
4、Future
future的get方法,get方法一定是可以拿到运行之后的数据
5、线程池
线程池的每个任务提交的时候,都可以看到之前运行的结果
6 、CyclicBarrier (关注)
也是栅栏的作用,必须要达到我释放的条件,不能随意的释放
他们都具有Happens-Before原则
这里这么多,我们只需要了解其他的,着重注意加锁和volatile这两个原则就可以了
1、一种同步机制,只要用了voatile,那么它一定和同步相关,和多线程相关,代替锁和synchronized,更轻量级,不会发生上下文切换
2、如果修饰volatile,那么jvm就会知道这个变量会被并发修改,jvm就会注意它的并发问题,比如清理重排序
3、开销小,但是能力也小,虽然能满足线程同步,但是做不到像synchronized那样的原子同步。适用场景很局限
先说下不使用的场合:a++
public class NoVolatile implements Runnable{
volatile int a;
AtomicInteger readA = new AtomicInteger();
public static void main(String[] args) throws InterruptedException {
Runnable r = new NoVolatile();
Thread thread1 = new Thread(r);
Thread thread2 = new Thread(r);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(((NoVolatile)r).a);
System.out.println(((NoVolatile)r).readA.get());
}
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
a++;
readA.incrementAndGet();
}
}
}
结果:
18402
20000
适用场景:一个共享变量自始至终被只被其他线程去赋值,而不进行其他操作,比如自增,那么就可以用volatile代替synchronized
这也体现了两种的区别,直接赋值时原子操作,而a++这样的,是非原子操作,需要读a的值,+1,再赋值,这是非原子,而直接给flag赋值,是原子操作,就不会有这样的问题
但是注意!适用也只是对于只赋值的情况,只要是依赖于原来的值,做出改变,只要是非原子,都不适用于volatile的情况
所有总结一下:volatile保证了可见性,而赋值时原子性的,所有保证了线程安全
这里很简单,要知道,volatile符合happends-before,比如b变量,用volatile修饰,那么它之前的一些列没加volatile变量的赋值,只要执行到这里,不加volatile也是会变的可见的,由于volatile保证了b变量的happends-before,之前变量都会刷新到主存中。
这里还说明了,volatile不仅有益于当前变量,还会使得其他变量受益
看下图例子
这是项目种的一个例子,加载一些列配置,不同的线程,只要initialized为true了,那么就知道配置好了!,线程B就可以执行了,这样就保证了线程安全!
现在总结一下volatile的两个作用,第一个肯定就是可见性了,上面很多话都是在说这些
机制时这样的:修饰了volatile,读的时候,情况本地内存,只能从主存中读
写的时候,一定会同步到主内存,供其他线程读到
2:用了volatile的时候,会禁止重排序:
案例:解决单例双重锁乱序
volatile是轻量级的synchronized,它通过可见性,以及操作的原子性保证线程安全,缺点就是变量的操作必须是原子性的,
volatile还禁止了重排序
场景:适用于直接修改的原子操作,第二个是利用volatile happends - before的特性,实现触发器,保证之前的变量也可见,刷新之前变量
2:volatile是无锁的,不能保证原子性和互斥性,不能替代synchronized,成本低
3:只能用于某个属性,不像synchronized用于代码块和方法
4:就是上面场景说的,提供了可见性,以及提供了happneds-before的保障
还有个:就是volatile可以使得long、double的赋值也是原子性的,这个会在下面的原子性讲到,这里先点一下
除了volatile让变量保证可见性
还有、synchronized、lock、并发集合、TThread.join Thread.start都可以保证可见性
也就是上面说的,happends-before原则的规定
synchronized不仅保证了原子性,它还保证了可见性,为什么呢?
synchronized保证并发的时候,比如两个线程,都给一个i变量自增,每个线程自增1万次,run方法中,
i++;被同步代码块保证,那么肯定是原子性的,如果没有保障可见性,那么最终结果也不会是2w,所有synchronized也是保证了可见性的
第二点:
上面讲到,volatile可以让之前没加volatile变量也进行更新,做到可见,近朱者赤一样
synchornized也是可以的
比如这样,线程B不仅能看到线程一加锁的内容,也能看到线程A加锁之前的一些列内容,这都归功于synchronized对happens-before的实现,和上面volatile保证之前变量,场景是一样的
比如这个例子,第二个加锁的时候,会知道第一个锁的内容,以及第一个锁之前的内容。
到这里JMM的可见性就结束了,主要就是happends-before原则,重点就是synchronized lock锁、以及volatile这些。
值得考虑的是,happends不仅体现了volatile、synchronized修饰的地方,也保证了他们之前的数据的happends-before!近朱者赤!
一些列操作,要不全部失败要么全部成功,是不可分割的
这里就要说明下并发编程和数据库中的A的区别
数据库中是事物执行,要么全部执行成功,要么回滚
而并发编程不支持回滚,他强调的是一个操作的不可分割线,就是一个线程执行的时候,会一次都执行完,不会被其他线程干扰。
比如i++操作,就不是原子性的,包括取数,加,赋值,有可能某个操作,就被其他线程干扰了。当然这是java的例子,这样上面的例子,就少加了一次,本来应该是2 ,却成为1,synchronized保护起来。就不会发生干扰,就实现了i++的原子性
java语言中原子性,更注重说的是这个操作不可再分,一段代码、一个变量的操作,不会被其他线程执行、干扰
天然的原子性,不是很多
有这几种
除long和double之外的基本数据类型(short int boolean char float)
所有的引用reference的赋值操作,不管32位还是64位机器
java.concurrent.Atomic.*包中所有类的原子操作
看下官方语言怎么说的:
意思就是:
在32位的JVM中,long double的操作不是原子的,但是在64位的JVM上是原子的
实际开发中,商用jvm是解决了这个问题的,我们不用担心
这个就是独立操作,这个很好理解,一个原子操作,和另一个原子操作,每个操作,线程执行的时候,一定是不被干扰的,但是这两个原子操作的时候,是不能保证的,只能通过加锁,来保证这一些列操作不会被其他线程干扰
这也能说明,并发中的原子性,就是指的是,线程执行,要执行就执行完,不可再分,不会被其他线程干扰,我认为java中的原子性指的就是这样的。
eg:同步的HashMap也不一定是安全的,先读取,再操作,把每个原子操作组合适用的时候,不是原子性的!
到这里就基本上把JMM讲完了,但是总感觉,呀!自己懂了,怎么复述出来,讲功利一点,面试怎么把我脑子里的知识清晰的表达出来!这就是我现在要说的哈!
JMM应用实例
单例模式、单例和并发的关系
为什么这样说呢?看这个例子
一个资源,很通用,每次new都会运算和链接很多东西,这就很适合单例模式,加载一次,然后复用
举个例子,要统计人数,为了加快速度,用多线程去统计,多线程统计,需要一个全局的单例计数器来统计
保证结果正确
比如一些工具类,并不需要太多的实例,只需要一个,比如日期工具类,
无状态的工具栏,比如日志工具栏,不需要在实例上存储信息状态,那么一个实例对象即可
全局信息类:比如统计网站访问次数,一个单例模式即可,不论访问哪里,都统统加1,这里就可以用单例模式
/**
*饿汉式(静态常量) (可用)
*/
public class Singleton1 {
private final static Singleton1 INSTENCE = new Singleton1();
private Singleton1(){
}
public static Singleton1 getInstance(){
return INSTENCE;
}
}
/**
* 静态代码快 饿汉式 (可用)
*/
public class Singleton2 {
private final static Singleton2 INSTANCE;
static {
INSTANCE = new Singleton2();
}
public static Singleton2 getInstance(){
return INSTANCE;
}
}
这两类都是饿汉式,优点是简单嘛,缺点浪费资源。没使用,就已经加载好了
public class Singleton3 {
private static Singleton3 instance;
private Singleton3(){
}
public static Singleton3 getInstance(){
if(instance==null){
instance = new Singleton3();
}
return instance;
}
}
并发时,两个线程都判空,会形成两个实例,不符合单例模式要求
public class Singleton3 {
private static Singleton3 instance;
private Singleton3(){
}
public static synchronized Singleton3 getInstance(){
if(instance==null){
instance = new Singleton3();
}
return instance;
}
}
多个线程没法同时创建,不会发生并发问题,但是缺点就是效率太低太低了
比如一个工具类单例,很影响效率的
public class Singleton3 {
private volatile static Singleton3 instance;
private Singleton3(){
}
public static synchronized Singleton3 getInstance(){
if(instance==null){
synchronized (Singleton3.class){
instance = new Singleton3();
}
}
return instance;
}
}
这是对前一个的改进,既想满足并发安全,又想提高效率
但是,假设两个线程,都并发的到了synchronized这行,那么就会出现问题!,虽然只能一个线程创建,但是另一个线程等这个线程结束,还是可用创建的!因为他们的都被判断为空。只是先后的差异而已
public class Singleton4 {
private static Singleton4 instance;
private Singleton4(){
}
public static Singleton4 getInstance(){
if(instance==null){
synchronized (Singleton4.class){
if(instance == null){
instance = new Singleton4();
}
}
}
return instance;
}
}
这里就是再判断一次,这样就能解决上面说的,两个线程排队创建实例,第二个线程进来,发生不为null就直接返回了
面试?:为什么要用双重锁?
我:线程安全
单层check行不行?
我:同时进入synchronized,重复了
面试:啊,把synchronized放到方法行不行呀
我:行啊,就是性能太慢了,别人访问的时候,不能即使响应
面试:为什么要用volatile?
我:
这里先在上帝视角分析一下哈,
上面说原子操作,之说变量、引用、以及原子类,我可没有说创建对象是原子操作哈!!
新建对象其实有3个步骤
1、先进行construct empty resource()
创建一个空的对象
2、all constrctor
执行构造方法
3、assign to rs
将对象赋值到引用,也就是代码用的instance
还记得重排序吗,没错,cpu可能会对他们进行重排序的
那么如果 1 3 2这样的顺序,那么双重校验,check,只进行一次的话,就会出现这样很严重的问题
只是有一个引用,但是构造函数还没执行,对象是空的,就会有NPE空指针异常的问题
所以加上volatile!!!!
public class Singleton7 {
private Singleton7(){
}
private static class SingletonInstance{
private final static Singleton7 INSTANCE = new Singleton7();
}
public static Singleton7 getInstance(){
return SingletonInstance.INSTANCE;
}
}
外部类加载的时候,是不会把内部类的静态实例加载出来的,所以是懒汉
jvm类加载保证了内部类不会重复的加载这个实例,也就是说保证了线程安全
面试推荐懒汉,因为能体现jmm特性,而枚举适合生产熬!
public enum Singleton8 {
INSTANCE;
public void whatever(){
//啥方法都行,这就完成了单例模式!
}
}
class Domo{
public static void main(String[] args) {
Singleton8.INSTANCE.whatever();
}
}
so easy
饿汉:
简单,但是没懒加载!
懒汉模式:
需要注意线程安全
静态内部类:
不错,还行,可用
双重检查(属于懒汉)
体现自己对JMM的理解,面试亮点
枚举类
最好,简单,方便,高效,工作适用
面试:那种最好?你知道还挺多
枚举最好
一个大神,在Effective java中表达观点,说是枚举最好
好处:写法简单!, 线程安全有保障, 枚举反编译之后,实质上是一个静态对象,第一次才会加载,
也是懒加载,用到的时候才会加载
还有一个好处!
就是避免反序列化破坏单例、反射破坏
适用枚举的话会避免这两个破坏
最好的肯定就是枚举了,既保证线程安全,还有懒加载特性,而且防止反序列化、反射破坏单例
饿汉,就不太好了,一是可能导致开始加载的资源太多,二是有些对象需要依赖配置文件,数据库等,类刚加载的时候是不知道的,就会出现问题,不适用
懒汉式:虽然节省内存,但是对于项目来说,这一点内存不太影响的,但是比如静态内部类,会增加代码复杂性
所以,用枚举最省事了
上面讲了很多这些概念,我讲了这么多,哎我们怎么说啊??
要从起因说,说来源,比如C语言,不考虑模型规范,不同系统指令不同,
再说java如何做的,通过java内存模型,兼顾操作系统cpu和jvm, 说下jvm内存规范、java对象模型
然后再说java内存模型是一层规范,规范了cpu和jvm在java的匹配,内存模型主要是重排序、可见性、原子性
然后主要讲可见性了,说下jMM对内存的抽象, 讲happens-before,再说volatile和synchronized,讲下原子性和可见性,还有近朱者赤这样的理解,
主要java的原子操作,主要指的是系列操作不会被其他线程执行,
其他的上面讲的很清楚了!
先说cpu多级缓存,再引入到jmm的抽象
这里就说的确,32位会分两次,可能有问题的,但是JVM商业版本都以解决,开发不需要注意
这个问题按照上面讲的,说下轻量级,但是也有原子性问题,再结合happens-before,就能答的很完美了!!!
毕业了毕业了!感谢阅读,别忘了收藏回顾哦!@
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。