在上面基本的数据类型读或写,我们看到了long,double除外,这涉及到了针对long和double型变量的特殊规则。
Java内存模型要求lock、unlock、read、load、assign、use、store、write这八种操作都具有原子性,但是对于64位的数据类型(long和double),在模型中特别定义了一条宽松的规定:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,即允许虚拟机实现自行选择是否要保证64位数据类型的load、store、read和write这四个操作的原子性,这就是所谓的“long和double的非原子性协定”(Non-Atomic Treatment of double and long Variables)。
关于三大特性,我们这里简单介绍一下
1.3.1 原子性
原子性呢是指操作是一体的,要么成功,要么失败,没有第三种情况。上图我们也说到Java中的基本数据类型的访问,读或写都是具备原子性的。这里我们举一个例子
int i = 5;
这里我们的赋值操作就是原子性,而一个比较经典的就是
int i = 0;
i++;
这里i++
就不是原子性的,我们可以看作它是先获取了i的值,然后进行写入值i = 1
的操作。我们常见的数据类型的操作都是原子性的,但是如果应用场景需要一个更大范围的原子性保证的话,Java内存模型提供了lock和unlock操作来满足这种需求。也提供了更方便快捷的synchronized关键字,同样具备原子性。
1.3.2 可见性
可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。比如当我们没有任何操作来处理两个线程的时候:
int i = 0; //主线程
i++; //线程1
j = i; //线程2
我们可以发现线程1修改了i的值,但是没有刷回主内存,线程2读取了i的值,赋值给了j,我们期望j就是1,但是因为虽然线程1修改了,没有来得及复制到主内存中,线程2读取后,j还是0。这就是内存不可见性,同理我们就可以理解了可见性。
常见的我们可以用volatile关键字来修饰变量,达到了内存可见性。
Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是volatile变量都是如此。普通变量与volatile变量的区别是,volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。因此我们可以说volatile保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。
除了volatile之类,还有synchronized和final也可以保证可见性。syschronized是因为JMM的规则限定对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作),而final关键字的可见性是指:
被final修饰的字段在构造器中一旦被初始化完成,并且构造器没有把“this”的引用传递出去(this引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到“初始化了一半”的对象),那么在其他线程中就能看见final字段的值。
1.3.3 有序性
一句话总结的话就是
如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。
问题来了,为什么在一个线程观察另一个线程的时候,操作都是无序的呢?
这就涉及到了指令重排:你写的程序,计算机并不是按照你写的那样去执行的,我们可以举个例子来说明
int x = 1; // 1
int y = 2; // 2
x = x + 5; // 3
y = x * x; // 4
我们期望的程序执行顺序是1->2->3->4,我们发现如果程序是2->1->3->4执行结果也是一样的,或者1->3->2->4也行,但是如果按照1—>2->4->3之类的呢?就得不到我们的期望结果,这就是指令重排导致的。
Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排序的语义,而synchronized则是由“一个变量在同一个时刻只允许一条线程对其进行lock操作”这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入。
但是如果所有的有序性都靠这两个关键字来完成的话,那么很多操作就会变得特别啰嗦,所以就有了一个Happens-Before原则。
1.4 Happens-Before原则
- 程序次序规则(Program Order Rule):在一个线程内,按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作。注意,这里说的是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。
- 管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是“同一个锁”,而“后面”是指时间上的先后。
·volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样是指时间上的先后。- 线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作。
- 线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread::join()方法是否结束、Thread::isAlive()的返回值等手段检测线程是否已经终止
执行。- 线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread::interrupted()方法检测到是否有中断发生。
- 对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
- 传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。
2 关于Synchronized关键字
上面我们介绍了JMM的特性的时候,也了解到了Synchronized是一个比较全能的同步块,可以保证很多的特性。
synchronized块是Java提供的一种原子内置锁,Java中的每个对象都可以把它当作一个同步块来使用,这些Java内置的使用者看不到的锁被称为内部锁,也叫做监视器锁。程的执行代码在进入synchronized代码块前会自动获取内部锁,这时候其他线程访问该同步代码块时会被阻塞挂起。拿到内部锁的线程会在正常退出同步代码块或者抛出异后或者在同步块内调用了该内置锁资源的wai t系列方法时释放该内置锁。内置锁是排它锁,也就是当一个线程获取这个锁后,其他线程必须等待该线程释放锁后才能获取该锁。
2.1 synchronized的内存语义
使用synchronized可以解决共享变量内存可见性的问题。
synchronized块的内存语义是把在synchronized块内使用到的变量从线程的工作内存中清除,这样在synchronized块内使用到该变量时就不会从线程的工作内存中获取,而是直接从主内存中获取。退出synchronized块的内存语义是把在synchronized块内对共享变量的修改刷新到主内存。其实这也是加锁和释放锁的语义,当获取锁后会清空锁块内本地内存中将会被用到的共享变量,在使用这些共享变量时从主内存进行加载,在释放锁时将本地内存中修改的共享变量刷新到主内存。
除可以解决共享变量内存可见性问题外,synchronized经常被用来实现原子性操作。另外请注意,synchronized关键字会引起线程上下文切换并带来线程调度开销。
3 关于Volatile关键字
其实关于volatile的特性,我们在介绍JMM的时候都已经了解很大一部分了。但是我们也说过volatile是不保证原子性的,这里我们还是需要用代码来展示的。
我们开十个线程,每个线程都对被volatile修饰的共享变量进行1000次自增操作。
public class Demo01 {
private volatile int num = 0;
private void increase(){
num++;
}
public static void main(String[] args) throws InterruptedException {
final Demo01 demo01 = new Demo01();
for(int i = 0; i < 10; i++){
new Thread(()->{
for(int j = 0; j < 1000; j++){
demo01.increase();
}
}).start();
}
//保证在主线程结束之前,其他线程执行完毕
TimeUnit.SECONDS.sleep(2);
System.out.println(demo01.num);
}
}
我们执行之后就可以看,打印的数有时候并不是我们想要的10000,而且接近这个数。这充分体现了volatile并不能保证原子性。而要解决这个问题的话,就需要用加锁或者用synchronized来修饰方法。
还有一种情况比较经典的如下:
public class Demo02 {
private int value;
public int getValue() {
return value;
}
public void setValue(int value) {
this.value = value;
}
}
这里在并发下是不安全的,因为没有适当的同步措施。就会导致内存不可见,getValue
有时候取到的值的之前调用了setValue
,但是还没有刷回主内存。这里我们就可以用到synchronized或者是volatile:
public class Demo02 {
private int value;
public synchronized int getValue() {
return value;
}
public synchronized void setValue(int value) {
this.value = value;
}
}
public class Demo02 {
private volatile int value;
public int getValue() {
return value;
}
public void setValue(int value) {
this.value = value;
}
}
在这里这两种方法是等价的,都可以解决内存可见性的问题。但是需要注意的是synchronized内置的锁是独占锁,这个时候同时只能有一个线程调用getValue
方法,其他线程会被阻塞,同时也会存在线程上下文切换和线程重新调度的开销,这也是使用锁不好的地方。
到了这里我们也了解到了volatile关键字的两个比较重要的特性,那么我们如何将两个特性用在该用的地方呢?也就是下面最后的一个问题
自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。
深知大多数前端工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!
因此收集整理了一份《2024年Web前端开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上前端开发知识点,真正体系化!
由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!
如果你觉得这些内容对你有帮助,可以扫码获取!!(备注:前端)
核心竞争力,怎么才能提高呢?
成年人想要改变生活,逆转状态?那就开始学习吧~
万事开头难,但是程序员这一条路坚持几年后发展空间还是非常大的,一切重在坚持。
为了帮助大家更好更高效的准备面试,特别整理了《前端工程师面试手册》电子稿文件。
前端面试题汇总
JavaScript
性能
linux
前端资料汇总
完整版PDF资料免费分享,只需你点赞支持,动动手指点击此处就可免费领取了。
前端工程师岗位缺口一直很大,符合岗位要求的人越来越少,所以学习前端的小伙伴要注意了,一定要把技能学到扎实,做有含金量的项目,这样在找工作的时候无论遇到什么情况,问题都不会大。
完整版PDF资料免费分享,只需你点赞支持,动动手指点击此处就可免费领取了。
前端工程师岗位缺口一直很大,符合岗位要求的人越来越少,所以学习前端的小伙伴要注意了,一定要把技能学到扎实,做有含金量的项目,这样在找工作的时候无论遇到什么情况,问题都不会大。