Java虚拟规范中曾经试图定义一种Java内存模型,来屏蔽各种硬件和操作系统的内存访问之间的差异,以实现让Java程序在各种平台上都能达到一致的内存访问效果。在此之前,主流程序语言直接使用物理内存和操作系统的内存模型,会由于不同平台的内存模型的差异,可能导致程序在一套平台上发挥完全正常,而在另一套平台上并发经常发生错误,所以在某种常见的场景下,必须针对平台来进行代码的编写。
这里的内存模型和我们的运行时数据是从不同的角度去分析java对内存的使用的。两者表达的含义和目的不同。在java内存模型当中一样会存在可见性和指令重排的问题。
JMM模型当中存在的问题 (有序性)
(1)指令重排
在指令重排中,有一个经典的as-if-serial语义,计算机会安装该语义对指令进行优化,其目的是不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作依然可能被编译器和处理器重排序。
1和3之间存在数据依赖关系,同时2和3之间也存在数据依赖关系。因此在最终执行的指令序列中,3不能被重排序到1和2的前面(3排到1和2的前面,程序的结果将会被改变)。但1和2之间没有数据依赖关系,编译器和处理器可以重排序1和2之间的执行顺序。 asif-serial语义使单线程下无需担心重排序的干扰,也无需担心内存可见性问题。
在单线程下,我们可以不用担心指令重排
但是多线程下指令重排可以引发一些奇怪的问题。
例子证明指令重排序的存在:
package cn.itcast.myJucLearn;
/**
* 指令重排
* 提出了happens-before的概念来阐述操作之间的内存可见性。对应Java程序员来说,理解happens-before是理解JMM的关键。JMM这么做的原因是:
* 程序员对于这两个操作是否真的被重排序并不关心,程序员关心的是程序执行时的语义不能被改变(即执行结果不能被改变)。
* 因此,happens-before关系本质上和as-if-serial语义是一回事。as-if-serial语义保证单线程内程序的执行结果不被改变,
* happens-before关系保证正确同步的多线程程序的执行结果不被改变。
*
* volatile 禁止指令重排 内存的可见性
* 单线程中
*/
public class OutOfOrderExecution {
private static int x = 0, y = 0;
private static int a = 0, b = 0;
private static int count = 0;
// 共享变量
// private static volatile int NUM = 0;
public static void main(String[] args)
throws InterruptedException {
long start = System.currentTimeMillis();
for (;;) {
//
Thread t1 = new Thread(() -> {
a = 1;
x = b;
});
Thread t2 = new Thread(() -> {
b = 1;
y = a;
});
//
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("一共执行了:" + (count++) + "次");
if(x==0 && y==0){
long end = System.currentTimeMillis();
System.out.println("耗时:+"+ (end-start) +"毫秒,(" + x + "," + y + ")");
break;
}
a=0;b=0;x=0;y=0;
}
}
}
执行结果:
我们发现结果中绝大部分是感觉正确的,(0,1),但是也有(1,0),这两种结果都是正确的,一个是线程1先执行,一个是线程二先执行,其实只要次数足够多哪种情况都会有。
但是按道理绝对不会出现(0,0),因为出现零的情况一定是x = b; y = a; a = 1; b = 1;,如果出现了也就证明了我们的执行在执行的时候确实存在乱序, 则存在指令重排
在Java语言中我们可以使用volatile关键字来保证一个变量在一次读写操作时的避免指令重排,【内存屏障】是在我们的读写操作之前加入一条指令,当CPU碰到这条指令后必须等到前边的指令执行完成才能继续执行下一条指令。
一个对象的版初始化状态
Dog dog = new Dog();
new
invokespecial init
ldc
2(可见性)
public class Test {
private static boolean isOver = false;
private static int number = 0;
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
while (!isOver) {
}
System.out.println(number);
}
});
thread.start();
Thread.sleep(1000);
number = 50;
// 已经改了啊,应该可以退出上边循环的值了啊!
isOver = true;
}
}
可以看见程序直接卡死了
当我们加上volatile关键字时 , 线程马上会感觉到
package cn.itcast.myJucLearn;
/**
* 线程争抢
* 在Java语言中我们可以使用volatile关键字来保证一个变量在一次读写操作时的避免指令重排,
* 【内存屏障】是在我们的读写操作之前加入一条指令,当CPU碰到这条指令后必须等到前边的指令执行完成才能继续执行下一条指令。
* 一个对象的版初始化状态
* Dog dog = new Dog()
* new
* invokespecial init
*
* 如果你直接运行上面的代码,那么你永远也看不到number的输出的,线程将会无限的循环下去。你可能会有疑问,
* 代码当中明明已经把isOver设置为了false,为什么循环还不会停止呢?这正是因为多线程之间可见性的问题
* 。在单线程环境中,如果向某个变量写入某个值,在没有其他写入操作的影响下,那么你总能取到你写入的那个值。然而在多线程环境中,
* 当你的读操作和写操作在不同的线程中执行时,情况就并非你想象的理所当然,
* 也就是说不满足多线程之间的可见性,所以为了确保多个线程之间对内存写入操作的可见性,必须使用同步机制。
*/
public class Test {
private volatile static boolean isOver = false;
private volatile static int number = 0;
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
while (!isOver) {
}
System.out.println("我感知到了值的变化:"+System.currentTimeMillis());
System.out.println(number);
}
});
thread.start();
Thread.sleep(1000);
System.out.println("我改了值:"+System.currentTimeMillis());
number = 50;
// 已经改了啊,应该可以退出上边循环的值了啊!
isOver = true;
System.out.println(number+"------");
}
}
运行结果:
我改了值:1725433360943
50------
我感知到了值的变化:1725433360944
50
总结
程序成功退出,volatile能强制对改变量的读写直接在主存中操作,从而解决了不可见的问题。写操作是,立刻强制刷在主存,并且将其他缓存区域的值设置为不可用,由此可见volatile 即可禁止指令重排也可保证java运行时的数据在内存的可见性
关于happens-before语义
JMM用【happens-before】的概念来阐述操作之间的内存可见性。在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系 。
在Java 规范提案中为让大家理解内存可见性的这个概念,提出了happens-before的概念来阐述操作之间的内存可见性。对应Java程序员来说,理解happens-before是理解JMM的关键。JMM这么做的原因是:程序员对于这两个操作是否真的被重排序并不关心,程序员关心的是程序执行时的语义不能被改变(即执行结果不能被改变)。因此,happens-before关系本质上和as-if-serial语义是一回事。as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。
(3)线程争抢
举一个例子证明一下,线程1和线程2分别对count累计10000次,合适的结果应该是20000才对:
public class Test {
private static int COUNT = 0;
public static void adder(){
COUNT++;
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
adder();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
adder();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("最后的结果是:"+COUNT);
}
}
实际运行结果 :小于 20000
同理对于抢票的例子也进行相同的加锁操作即可
public class Ticket implements Runnable{
private static final Object monitor = new Object();
private static Integer COUNT = 100;
String name;
public Ticket(String name) {
this.name = name;
}
@Override
public void run() {
while (Ticket.COUNT > 0) {
ThreadUtils.sleep(100);
// 在这里加入了同步代码块
synchronized (Ticket.monitor) {
System.out.println(name + "出票一张,还剩" + Ticket.COUNT-- + "张!");
}
}
}
public static void main(String[] args) throws Exception {
Thread one = new Thread(new Ticket("一号窗口"));
Thread two = new Thread(new Ticket("一号窗口"));
one.start();
two.start();
Thread.sleep(10000);
}
}
线程安全的实现方法
#(1)数据不可变
在Java当中,一切不可变的对象(immutable)一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要再进行任何线程安全保障的措施,比如final关键字修饰的基础数据类型,再比如说咱们的Java字符串儿。只要一个不可变的对象被正确的构建出来,那外部的可见状态永远都不会改变,永远都不会看到它在多个线程之中处于不一致的状态,带来的安全性是最直接最纯粹的。比如使用final修饰的基础数据类型(引用数据类型不可以)、比如java字符串,而一旦被创建就永远不能改变,其实谷歌的开发工具包(guava)中也给我们提供了一些不可变的一类(immutable),咱们以后的学习过程当中可能会接触到。
#(2)互斥同步
互斥同步是常见的一种并发正确性的保障手段,同步是指在多个线程并发访问共享数据时,保证共享数据在同一时刻只被一个线程使用,互斥是实现同步的一种手段,互斥是因、同步是果,互斥是方法,同步是目的。
在Java中最基本的互斥同步手段,就是
synchronized
字段,除了synchronize的之外,我们还可以使用ReentrantLock等工具类实现。接下来我们就尝试学习Java中的锁。#(3)非阻塞同步
互斥同步面临的主要问题是,进行线程阻塞和唤醒带来的性能开销,因此这种同步也被称为阻塞同步,从解决问题的方式上来看互斥同步是一种【悲观的并发策略】,其总是认为,只要不去做正确的同步措施,那就肯定会出现问题,无论共享的数据是否真的出现,都会进行加锁。这将会导致用户态到内核态的转化、维护锁计数器和检查是否被阻塞的线程需要被唤醒等等开销。
随着硬件指令级的发展,我们已经有了另外的选择,基于【冲突检测的乐观并发策略】。通俗的说,就是不管有没有风险,先进行操作,如果没有其他线程征用共享数据,那就直接成功,如果共享数据确实被征用产生了冲突,那就再进行补偿策略,常见的补偿策略就是不断的重试,直到出现没有竞争的共享数据为止,这种乐观并发策略的实现,不再需要把线程阻塞挂起,因此同步操作也被称为非阻塞同步,这种措施的代码也常常被称之为【无锁编程】,也就是咱们说的自旋。我们用cas来实现这种非阻塞同步,cas会在接下来的授课当中详细给大家介绍,现在先不着急。
#(4)无同步方案
在我们这个工作当中,还经常遇到这样一种情况,多个线程需要共享数据,但是这些数据又可以在单独的线程当中计算,得出结果,而不被其他的线程所影响,如果能保证这一点,我们就可以把共享数据的可见范围限制在一个线程之内,这样就无需同步,也能够保证个个线程之间不出现数据征用的问题,说人话就是数据拿过来,我用我的,你用你的,从而保证线程安全,比如说咱们的ThreadLocal。
ThreadLocal提供了线程内存储变量的能力,这些变量不同之处在于每一个线程读取的变量是对应的互相独立的。通过get和set方法就可以得到当前线程对应的值。