多线程
概念
**进程与线程:**进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。
线程与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。与进程不同的是同类的多个线程共享同一块内存空间和一组系统资源。因为某些资源共用,无须重新加载,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。
多线程就是多个线程同时运行或交替运行,多线程可以充分地利用多处理器系统的资源。对于多线程程序,多核处理器可以同时跑多个线程,因为每个CPU有自己的运算器,所以在多个CPU中可以同时运行。单核CPU的话线程是交替运行,即使采用多线程程序,因为只有一个CPU,某一时刻也只能有一个线程在运行。
开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。
线程生命周期
新建( new ):新创建了一个线程对象。
可运行( runnable ):线程对象创建后,其他线程(比如 main 线程)调用了该对象 的 start ()方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获 取 cpu 的使用权 。
运行( running ):可运行状态( runnable )的线程获得了 cpu 时间片( timeslice ) ,执行程序代码。
阻塞( block ):阻塞状态是指线程因为某种原因放弃了 cpu 使用权,也即让出了 cpu timeslice ,暂时停止运行。直到线程进入可运行( runnable )状态,才有 机会再次获得 cpu timeslice 转到运行( running )状态。阻塞的情况分三种:
-
- 等待阻塞:运行( running )的线程执行 o . wait ()方法, JVM 会把该线程放 入等待队列( waitting queue )中。
- 同步阻塞:运行( running )的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则 JVM 会把该线程放入锁池( lock pool )中。
- 其他阻塞: 运行( running )的线程执行 Thread . sleep ( long ms )或 t . join ()方法,或者发出了 I / O 请求时, JVM 会把该线程置为阻塞状态。 当 sleep ()状态超时、 join ()等待线程终止或者超时、或者 I / O 处理完毕时,线程重新转入可运行( runnable )状态。
死亡( dead ):线程 run ()、 main () 方法执行结束,或者因异常退出了 run ()方法,则该线程结束生命周期。死亡的线程不可再次复生。
线程的常用方法
- 1.start():1.启动当前线程,线程进入可运行状态。
- 2.run():通常需要重写Thread类中的 run() 方法,将创建的线程要执行的操作声明在此方法中
- 3.currentThread(): Thread的静态方法,返回执行当前代码的线程
- 4.getName():获取当前线程的名字
- 5.setName():设置当前线程的名字
- 6.yield():主动释放当前线程的执行权
- 7.join(): 在当前线程中插入执行另一个线程,比如在主线程的main方法中有语句thread1.join()。则main()所在的线程被阻塞,开始执行线程thread1,直到中途插入的线程thread1执行完毕以后,main()所在线程才继续执行下去。
- 8.stop():过时方法。当执行此方法时,强制结束当前线程。
- 9.sleep(long millitime):线程休眠一段时间
- 10.isAlive():判断当前线程是否存活
平时我们本地测试的时候可以用 Thread.currentThread() 获取当前线程。
创建线程的方法
1 继承Thread类
public class MyThread extends Thread {
@Override
public void run() {
super.run();
System.out.println("MyThread");
}
}
public class Run {
public static void main(String[] args) {
MyThread mythread = new MyThread();
mythread.start();
System.out.println("运行结束");
}
}
start与run方法的区别:
start方法的作用:1.启动当前线程 2.调用当前线程的重写的run方法(在主线程中生成子线程,有两条线程)
调用start方法以后,一条路径代表一个线程,同时执行两线程时,因为时间片的轮换,所以执行过程随机分配,且一个线程对象只能调用一次start方法。
run方法的作用:在主线程中调用以后,直接在主线程一条线程中执行了该线程中run的方法。(调用线程中的run方法,只调用run方法,并不新开线程)
总结:我们不能通过run方法来新开一个线程,只能调用线程中重写的run方法(可以在线程中不断的调用run方法,但是不能开启子线程,即不能同时干几件事),start是开启线程,再调用方法(即默认开启一次线程,调用一次run方法,可以同时执行几件事)
2 实现Runnable接口
推荐实现Runnable接口方式开发多线程,因为Java单继承但是可以实现多个接口。要实现线程行为,仍需要显式地将一个任务附着到线程上。
public class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("MyRunnable");
}
}
public class Run {
public static void main(String[] args) {
Runnable runnable=new MyRunnable();
Thread thread=new Thread(runnable);
thread.start();
System.out.println("运行结束!");
}
}
3 Executors线程池
public class ExecutorsDemo {
private static ExecutorService executor = Executors.newFixedThreadPool(15);
public static void main(String[] args) {
for (int i = 0; i < Integer.MAX_VALUE; i++) {
executor.execute(new MyThread());
}
}
}
class MyThread implements Runnable {
@Override
public void run() {
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
//do nothing
}
}
}
yield():建议具有相同优先级的其它线程可以运行。
线程通信
wait()/ notify()/ notifayAll():此三个方法定义在Object类中的,因为这三个方法需要用到锁,而锁是任意对象都能充当的,所以这三个方法定义在Object类中。
由于wait,notify,以及notifyAll都涉及到与锁相关的操作
wait(在进入锁住的区域以后阻塞等待,释放锁让别的线程先进来操作)---- Obj.wait 进入Obj这个锁住的区域的线程把锁交出来原地等待通知
notify(由于有很多锁住的区域,所以需要将区域用锁来标识,也涉及到锁) ----- Obj.notify 新线程进入Obj这个区域进行操作并唤醒wait的线程
有点类似于我要拉粑粑,我先进了厕所关了门,但是发现厕所有牌子写着不能用,于是我把厕所锁给了别人,别人进来拉粑粑还是修厕所不得而知,直到有人通知我厕所好了我再接着用。
所以wait,notify需要使用在有锁的地方,也就是需要用synchronize关键字来标识的区域,即使用在同步代码块或者同步方法中,且为了保证wait和notify的区域是同一个锁住的区域,需要用锁来标识,也就是锁要相同的对象来充当
同步与异步,阻塞和非阻塞
并发与并行
并发:在一个时间段,计算机在运行一个程序的时候也在运行其它的程序,这叫做并发。例如,我们的电脑上可能表面上同时运行音乐播放软件以及浏览器,其实对于单核处理器,它们并不是在同时运行,而是计算机在它们之间来回切换运行。只不过这个切换时间非常短,我们感觉不到。
并行:同一时刻,计算机在运行一个程序,也在运行其它程序,这种方式叫做并行。并行需要额外的CPU内核才能实现。通常,CPU核数总是小于进程数,
无论是并发还是并行,CPU需要在不同进程之间进行调度,以保证每个进程都能得到执行。实际上,进程并不是连续地得到执行,只有在分配到CPU执行时间时才执行。切换CPU从一个进程到另一个进程,需要保存当前进程状态和回复另一个进程的执行状态,这个任务成为上下文切换。
高并发
高并发(High Concurrency)是互联网分布式系统架构设计中必须考虑的因素之一,它通常是指,通过设计保证系统能够同时并行处理很多请求。
高并发相关常用的一些指标有响应时间(Response Time),吞吐量(Throughput),每秒查询率QPS(Query Per Second),并发用户数等。
同步与异步
所谓同步,可以理解为在执行完一个函数或方法之后,一直等待系统返回值或消息,这时程序是出于阻塞的,只有接收到返回的值或消息后才往下执行其它的命令。
异步,执行完函数或方法后,不必阻塞性地等待返回值或消息,只需要向系统委托一个异步过程,程序会往下继续执行。当系统接收到返回值或消息时,系统会自动触发委托的异步过程,从而完成一个完整的流程。
举个通俗的例子:
你打电话问书店老板有没有《分布式系统》这本书,如果是同步通信机制,书店老板会说,你稍等,”我查一下",然后开始查啊查,等查好了(可能是5秒,也可能是一天)告诉你结果(返回结果)。
而异步通信机制,书店老板直接告诉你我查一下啊,查好了打电话给你,然后直接挂电话了(不返回结果)。然后查好了,他会主动打电话给你。在这里老板通过“回电”这种方式来回调。
在多线程的环境中,经常会碰到数据的共享问题,即当多个线程需要访问同一个资源时,它们需要以某种顺序来确保该资源在某–时刻只能被-一个线程使用,否则,程序的运行结果将会是不可预料的,在这种情况下就必须对数据进行同步,例如多个线程同时对同一数据进行写操作,即当线程A需要使用某个资源时,如果这个资源正在被线程B使用,同步机制就会让线程A。直等待下去,直到线程B结束对该资源的使用后,线程A才能使用这个资源,由此可见,同步机制能够保证资源的安全。
举例说明同步和异步
如果系统中存在临界资源(资源数量少于竞争资源的线程数量的资源),例如正在写的数据以后可能被另一个线程读到,或者正在读的数据可能已经被另一个线程写过了,那么这些数据就必须进行同步存取(数据库操作中的互斥锁就是最好的例子)。当应用程序在对象上调用了一个需要花费很长时间来执行的方法,并且不希望让程序等待方法的返回时,就应该使用异步编程,在很多情况下采用异步途径往往更有效率。事实上,所谓的同步就是指阻塞式操作,而异步就是非阻塞式操作。
临界区
临界区用来表示一种公共资源或者说是共享数据,可以被多个线程使用。但是每一次,只能有一个线程使用它,一旦临界区资源被占用,其他线程要想使用这个资源,就必须等待。在并行程序中,临界区资源是保护的对象。
数据同步
1 sychonized
在并发编程中存在线程安全问题,主要原因有:1.存在共享数据 2.多线程共同操作共享数据。关键字synchronized可以保证在同一时刻,只有一个线程可以执行某个方法或某个代码块。
Java中每一个对象都可以作为锁,这是synchronized实现同步的基础:
普通同步方法(实例方法),锁是当前实例对象 ,进入同步代码前要获得当前实例的锁
静态同步方法,锁是当前类的class对象 ,进入同步代码前要获得当前类对象的锁
同步方法块,锁是括号里面的对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。
1.1 普通同步方法
1.1.1 多个线程访问同一个对象的同一个方法
此时锁是实例对象
public class SynchronizedTest implements Runnable{
//使用静态变量定义共享资源
static int i =0;
/**
* synchronized 修饰实例方法
*/
public synchronized void increase(){
i++;
}
@Override
public void run(){
for (int j =0 ; j<10000;j++){
increase();
}
}
public static void main(String[] args) throws InterruptedException {
SynchronizedTest test = new SynchronizedTest();
Thread t1 = new Thread(test);
Thread t2 = new Thread(test);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
}
这里必须调用join()方法才能看到正确的结果,即输出 20000。如果不加t1.join()方法,主线程main在将线程t1 t2加入到可运行池之后(即线程变为可运行状态),会不等t1.run()执行完毕,直接就会执行System.out.println(i); 输出 0。
t.join()方法会使主线程进入等待池,并等待 t1 t2 线程执行完毕后,才会被唤醒。join() 并不影响同一时刻处在运行状态的其他线程。
分析:当两个线程同时对一个对象的一个方法进行操作,只有一个线程能够抢到锁。因为一个对象只有一把锁,一个线程获取了该对象的锁之后,其他线程无法获取该对象的锁,就不能访问该对象的其他synchronized实例方法,需要等到对象被释放后才能获取。
但是在对象没有被释放前,其他线程可以访问非synchronized修饰的方法。可以给出下面的例子做佐证
public class SynchronizedTest2 {
public synchronized void method1() {
System.out.println("Method 1 start");
try {
System.out.println("Method 1 execute");
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Method 1 end");
}
public void method2() {
System.out.println("Method 2 start");
try {
System.out.println("Method 2 execute");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Method 2 end");
}
public static void main(String[] args) {
final SynchronizedTest2 test = new SynchronizedTest2();
new Thread(test::method1).start();
new Thread(test::method2).start();
}
}
从输出结果看到当线程1还在执行时,线程2也执行了,所以当其他线程来访问非synchronized修饰的方法时是可以访问的。
Method 1 start
Method 2 start
Method 1 execute
Method 2 execute
Method 2 end
Method 1 end
1.1.2 当多个线程作用于不同的对象
public class SynchronizedTest {
public synchronized void method1() {
System.out.println("Method 1 start");
try {
System.out.println("Method 1 execute");
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Method 1 end");
}
public synchronized void method2() {
System.out.println("Method 2 start");
try {
System.out.println("Method 2 execute");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Method 2 end");
}
public static void main(String[] args) {
final SynchronizedTest test1 = new SynchronizedTest();
final SynchronizedTest test2 = new SynchronizedTest();
new Thread(test1::method1).start();
new Thread(test2::method2).start();
}
}
这种情况无法保证两个线程的执行顺序,因为两个线程作用于不同的对象,获得的是不同的锁,所以互相并不影响。但是如果两个线程传入的都是test1::method1,因为method1方法是同步方法,只有当第一个线程执行结束,第二个线程才能获得执行权。
Method 1 start
Method 1 execute
Method 2 start
Method 2 execute
Method 2 end
Method 1 end
1.2 静态同步方法
public class synchronizedTest implements Runnable {
//共享资源
static int i =0;
/**
* synchronized 修饰实例方法
*/
public static synchronized void increase(){
i++;
}
@Override
public void run(){
for (int j =0 ; j<10000;j++){
increase();
}
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(new synchronizedTest());
Thread t2 = new Thread(new synchronizedTest());
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
由例子可知,两个线程实例化两个不同的对象,但是访问的方法是静态的,两个线程发生了互斥(即一个线程访问,另一个线程只能等着),因为静态方法是依附于类而不是对象的。当synchronized修饰静态方法时,锁是class对象。如果increase()不是静态方法,由于线程竞争执行,结果就不可预测了。
1.3 同步代码块
为什么要同步代码块呢?在某些情况下,我们编写的方法体可能比较大,同时存在一些比较耗时的操作,而需要同步的代码又只有一小部分,如果直接对整个方法进行同步操作,可能会得不偿失,此时我们可以使用同步代码块的方式对需要同步的代码进行包裹,这样就无需对整个方法进行同步操作了。
public class synchronizedTest implements Runnable {
static synchronizedTest instance = new synchronizedTest();
static int i=0;
@Override
public void run() {
//省略其他耗时操作....
//使用同步代码块对变量i进行同步操作,锁对象为instance
synchronized(instance){
for(int j=0;j<10000;j++){
i++;
}
}
}
public static void main(String[] args) throws InterruptedException {
Thread t1=new Thread(instance);
Thread t2=new Thread(instance);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
}
分析:将synchronized作用于一个给定的实例对象instance,即当前实例对象就是锁对象,每次当线程进入synchronized包裹的代码块时就会要求当前线程持有instance实例对象锁,如果当前有其他线程正持有该对象锁,那么新到的线程就必须等待,这样也就保证了每次只有一个线程执行i++;操作。当然除了instance作为对象外,我们还可以使用this对象(代表当前实例)或者当前类的class对象作为锁。此时应该关注锁对象是谁,是否能够达到同步的目的。
//this,当前实例对象锁
synchronized(this){
for(int j=0;j<1000000;j++){
i++;
}
}
//class对象锁
synchronized(AccountingSync.class){
for(int j=0;j<1000000;j++){
i++;
}
}
上述例子中,如果采用this做锁,下面的代码输出结果就无法预测了。因为this对象做锁,锁是当前实例对象,下面代码new了两个SynchronizedTest3对象,因此两个线程用的不是同一个,无法达到同步的目的。
public static void main(String[] args) throws InterruptedException {
Thread t1=new Thread(new SynchronizedTest3());
Thread t2=new Thread(new SynchronizedTest3());
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
// 上述代码输出
12984
2 lock
2.1 认识Lock
Lock是concurrent包中的一个接口,它主要有以下几个方法
lock():获取锁,如果锁被暂用,则一直等待
unlock():释放锁
tryLock(): 注意返回类型是boolean,如果获取锁的时候锁被占用,不会等待,直接返回false,否则返回true。也就说这个方法无论如何都会立即返回。在拿不到锁时不会一直在那等待。
tryLock(long time, TimeUnit unit):和tryLock()方法是类似的,只不过区别在于这个方法在拿不到锁时会等待一定的时间,在时间期限之内如果还拿不到锁,就返回false。如果如果一开始拿到锁或者在等待期间内拿到了锁,则返回true。
lockInterruptibly():也是一种获取锁的方式,如果线程在获取锁的阶段进入了等待,那么可以中断此线程,先去做别的事
ReentrantLock,意思是“可重入锁”,ReentrantLock是唯一实现了Lock接口的类。下面给出一个用Lock实现同步的例子:
public class LockTest {
private Lock lock = new ReentrantLock();
//需要参与同步的方法
private void method(Thread thread) {
lock.lock();
try {
System.out.println("线程名" + thread.getName() + "获得了锁");
} catch (Exception e) {
e.printStackTrace();
} finally {
System.out.println("线程名" + thread.getName() + "释放了锁");
lock.unlock();
}
}
private void method2(Thread thread) {
if (lock.tryLock()) {
try {
System.out.println("线程名" + thread.getName() + "获得了锁");
} catch (Exception e) {
e.printStackTrace();
} finally {
System.out.println("线程名" + thread.getName() + "释放了锁");
lock.unlock();
}
} else {
System.out.println("线程名" + Thread.currentThread().getName() + "有人占着锁,我就不要啦");
}
}
public static void main(String[] args) {
LockTest lockTest = new LockTest();
//线程1
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
lockTest.method(Thread.currentThread());
}
}, "t1");
Thread t2 = new Thread(() -> {
lockTest.method(Thread.currentThread());
}, "t2");
Thread t3 = new Thread(() -> {
lockTest.method2(Thread.currentThread());
}, "t3");
t1.start();
t2.start();
t3.start();
}
}
分析:线程t3可能会打印有人占着锁,这是因为lock.tryLock()获取不到锁时,代码选择执行分支语句,不会等待锁释放。而线程t1和t2中用的是lock(),如果获取不到锁,线程会等待。
线程名t1获得了锁
线程名t3有人占着锁,我就不要啦
线程名t1释放了锁
线程名t2获得了锁
线程名t2释放了锁
2.2 锁的中断
假设某个线程要不停地处理某件事情(比如 i 一直自增),但是还有个要求:在处理事情前,先要检查下这个线程是否被中断,如果已经被中断,处理就应该结束。
public class InteruptTest {
public static void main(String[] args) {
try {
MyThread2 thread = new MyThread2();
thread.start();
Thread.sleep(20);//modify 2000 to 20
thread.interrupt();//请求中断MyThread线程
} catch (InterruptedException e) {
System.out.println("main catch");
e.printStackTrace();
}
System.out.println("end!");
}
}
class MyThread2 extends Thread {
@Override
public void run() {
super.run();
for (int i = 0; i < 500000; i++) {
if (this.interrupted()) {
System.out.println("should be stopped and exit");
break;
}
System.out.println("i=" + (i + 1));
}
//尽管线程被中断,但并没有结束运行。这行代码还是会被执行
System.out.println("this line is also executed. thread does not stopped");
}
}
当MyThread获得CPU执行时,第6行的 if 测试中,检测到中断标识被设置。即MyThread线程检测到了main线程想要中断它的请求。
大多数情况下,MyThread检测到了中断请求,对该中断的响应是:退出执行(或者说是结束执行)。
但是,上面第5至8行for循环,是执行break语句跳出for循环。但是,线程并没有结束,它只是跳出了for循环而已,它还会继续执行第12行的代码…
当然,一种更优雅的方式则是:抛出InterruptedException异常。
class MyThread3 extends Thread {
@Override
public void run() {
super.run();
try{
for (int i = 0; i < 500000; i++) {
if (this.interrupted()) {
System.out.println("should be stopped and exit");
throw new InterruptedException();
}
System.out.println("i=" + (i + 1));
}
//这行语句不会被执行!!!
System.out.println("this line cannot be executed. cause thread throws exception");
}catch(InterruptedException e){
System.out.println("catch interrupted exception");
e.printStackTrace();
}
}
}
当MyThread线程检测到中断标识为true后,在第9行抛出InterruptedException异常。这样,该线程就不能再执行其他的正常语句了(如,第13行语句不会执行)。这里表明:interrupt()方法有两个作用,一个是将线程的中断状态置位(中断状态由false变成true);另一个则是:让被中断的线程抛出InterruptedException异常。
这是很重要的。这样,对于那些阻塞方法(比如 wait() 和 sleep())而言,当另一个线程调用interrupt()中断该线程时,该线程会从阻塞状态退出并且抛出中断异常。这样,我们就可以捕捉到中断异常,并根据实际情况对该线程从阻塞方法中异常退出而进行一些处理。
2.3 探究lockInterruptibly
使用该方法获取锁的过程可以相应中断。例如线程A等待锁的过程中,线程B中执行A.interrupt(),可以中断线程A,是否被中断可以通过捕获异常来判断。
public class LockInterruptiblyTest {
private static final Lock lock = new ReentrantLock();
public static void main(String[] args) {
LockInterruptiblyTest lockInterruptiblyTest = new LockInterruptiblyTest();
Thread thread0 = new Thread(() -> {
try {
lockInterruptiblyTest.insert(Thread.currentThread());
} catch (InterruptedException e) {
System.out.println(Thread.currentThread().getName() + "被中断");
}
});
Thread thread1 = new Thread(() -> {
try {
lockInterruptiblyTest.insert(Thread.currentThread());
} catch (InterruptedException e) {
System.out.println(Thread.currentThread().getName() + "被中断");
}
});
thread0.start();
thread1.start();
try {
Thread.sleep(20);
} catch (InterruptedException e) {
e.printStackTrace();
}
thread1.interrupt();
}
public void insert(Thread thread) throws InterruptedException {
System.out.println(thread.getName() + "想拥有锁");
lock.lockInterruptibly();
System.out.println(thread.getName() + "得到了锁");
Thread.sleep(2000);
lock.unlock();
System.out.println(thread.getName() + "释放了锁");
}
}
2.4 Lock与synchronized的比较
Lock实现和synchronized不一样,后者是一种悲观锁。而Lock呢底层其实是CAS乐观锁的体现。
3 volatile
volatile是Java提供的一种轻量级的同步机制。Java 语言包含两种内在的同步机制:同步块(或方法)和 volatile 变量,相比于synchronized(synchronized通常称为重量级锁),volatile更轻量级,因为它不会引起线程上下文的切换和调度。但是volatile 变量的同步性较差(有时它更简单并且开销更低),而且其使用也更容易出错。
volatile关键字虽然从字面上理解起来比较简单,但是怎么用它呢?本文从一个实际问题出发引出volatile。由于volatile关键字与Java的内存模型有关,因此在讲述volatile关键之前,我们先来了解一下多核CPU的简化模型相,然后分析了volatile关键字要解决的问题,最后给出了几个使用volatile关键字的场景。
请尊重作者劳动成果,转载请标明原文链接:
http://www.cnblogs.com/dolphin0520/p/3920373.html
3.1 多核CPU的缓存
大家都知道,计算机在执行程序时,每条指令都是在CPU中执行的,而执行指令过程中,势必涉及到数据的读取和写入。由于程序运行过程中的临时数据是存放在主存(物理内存)当中的,这时就存在一个问题,由于CPU执行速度很快,而从内存读取数据和向内存写入数据的过程跟CPU执行指令的速度比起来要慢的多,因此如果任何时候对数据的操作都要通过和内存的交互来进行,会大大降低指令执行的速度。因此在CPU里面就有了高速缓存。
当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。多线程情况下,这可能导致一些意想不到的问题。
对于i = i+1这个操作,i初始为0,我们希望两个线程执行操作之后i==2。但是存在下面这种情况:初始时,两个线程分别读取i的值存入各自所在的CPU的高速缓存当中,然后线程1进行加1操作,然后把i的最新值1写入到内存。此时线程2的高速缓存当中 i 的值还是0,进行加1操作之后,i的值为1,然后线程2把i的值写入内存。
最终结果i的值是1,而不是2。这就是著名的缓存一致性问题。通常称这种被多个线程访问的变量为共享变量。也就是说,如果一个变量在多个CPU中都存在缓存(一般在多线程编程时才会出现),那么就可能存在缓存不一致的问题。
缓存不一致的硬件解决方式
在早期的CPU当中,是通过在总线上加LOCK#锁的形式来解决缓存不一致的问题。因为CPU和其他部件进行通信都是通过总线来进行的,如果对总线加LOCK#锁的话,也就是说阻塞了其他CPU对其他部件访问(如内存),从而使得只能有一个CPU能使用这个变量的内存。比如上面例子中 如果一个线程在执行 i = i +1,如果在执行这段代码的过程中,在总线上发出了LCOK#锁的信号,那么只有等待这段代码完全执行完毕之后,其他CPU才能从变量i所在的内存读取变量,然后进行相应的操作。这样就解决了缓存不一致的问题。
但是上面的方式会有一个问题,由于在锁住总线期间,其他CPU无法访问内存,导致效率低下。
所以就出现了缓存一致性协议。最出名的就是Intel 的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的。它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。
3.2 并发编程中的三个基本概念
在并发编程中,我们通常会遇到以下三个问题:原子性问题,可见性问题,有序性问题。必须解决这单个基本问题,多线程程序才能得到正确的结果。我们先看具体看一下这三个概念:
1.原子性
原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
一个很经典的例子就是银行账户转账问题:
比如从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。
试想一下,如果这2个操作不具备原子性,会造成什么样的后果。假如从账户A减去1000元之后,操作突然中止。然后又从B取出了500元,取出500元之后,再执行 往账户B加上1000元 的操作。这样就会导致账户A虽然减去了1000元,但是账户B没有收到这个转过来的1000元。
所以这2个操作必须要具备原子性才能保证不出现一些意外的问题。即这些操作要么都完成,要么都不完成。
2.可见性
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。举个简单的例子,看下面这段代码:
//线程1执行的代码
int i = 0;
i = 10;
//线程2执行的代码
j = i;
假若执行线程1的是CPU1,执行线程2的是CPU2。由上面的分析可知,当线程1执行 i =10这句时,会先把i的初始值加载到CPU1的高速缓存中(该告诉缓存也叫线程的工作内存),然后赋值为10,那么在CPU1的高速缓存当中i的值变为10了,却没有立即写入到主存当中。
此时线程2执行 j = i,它会先去主存读取i的值并加载到CPU2的缓存当中,注意此时内存当中i的值还是0,那么就会使得j的值为0,而不是10.
这就是可见性问题,线程1对变量 i 修改了之后,线程2没有立即看到线程1修改的值。我们希望线程1修改 i 之后,线程2能看到修改之后的值就好了。
3.有序性
有序性:即程序执行的顺序按照代码的先后顺序执行。
int i = 0;
boolean flag = false;
i = 1; //语句1
flag = true; //语句2
上面代码定义了一个int型变量,定义了一个boolean类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗?不一定,为什么呢?这里可能会发生指令重排序(Instruction Reorder)。
下面解释一下什么是指令重排序,一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。
比如上面的代码中,语句1和语句2谁先执行对最终的程序结果并没有影响,那么就有可能在执行过程中,语句2先执行而语句1后执行。
但是要注意,虽然处理器会对指令进行重排序,但是它会保证程序最终结果会和代码顺序执行结果相同,那么它靠什么保证的呢?再看下面一个例子:
int a = 10; //语句1
int r = 2; //语句2
a = a + 3; //语句3
r = a*a; //语句4
这段代码有4个语句,那么可能的一个执行顺序是:
那么可不可能是这个执行顺序呢: 语句2 语句1 语句4 语句3
不可能,因为处理器在进行重排序时是会考虑指令之间的数据依赖性,如果一个指令Instruction 2必须用到Instruction 1的结果,那么处理器会保证Instruction 1会在Instruction 2之前执行。
虽然重排序不会影响单个线程内程序执行的结果,但是多线程呢?下面看一个例子:
//线程1:
context = loadContext(); //语句1
inited = true; //语句2
//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);
上面代码中,由于语句1和语句2没有数据依赖性,因此可能会被重排序。假如发生了重排序,在线程1执行过程中先执行语句2,而此是线程2会以为初始化工作已经完成,那么就会跳出while循环,去执行doSomethingwithconfig(context)方法,而此时context并没有被初始化,就会导致程序出错。
从上面可以看出,指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。
也就是说,要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。
下面我们来讨论 Java 中如何实现原子性、可见性、有序性。
3.3 Java并发编程中的问题
在前面谈到了一些关于CPU模型以及并发编程中可能会出现的一些问题。下面我们来看一下Java内存模型,研究一下Java内存模型为我们提供了哪些方法和机制,来让我们在进行多线程编程时能够保证程序执行的正确性。
在Java虚拟机规范中试图定义一种Java内存模型(Java Memory Model,JMM)来屏蔽各个硬件平台和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。那么Java内存模型规定了哪些东西呢,它定义了程序中变量的访问规则,往大一点说是定义了程序执行的次序。注意,为了获得较好的执行性能,Java内存模型并没有限制执行引擎使用处理器的寄存器或者高速缓存来提升指令执行速度,也没有限制编译器对指令进行重排序。也就是说,在java内存模型中,也会存在缓存一致性问题和指令重排序的问题。
Java内存模型规定所有的变量都是存在主存当中(类似于前面说的物理内存),每个线程都有自己的工作内存(类似于前面的高速缓存)。线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作。并且每个线程不能访问其他线程的工作内存。
举个简单的例子:在java中,执行下面这个语句:
i = 10;
执行线程必须先在自己的工作线程中对变量i所在的缓存行进行赋值操作,然后再写入主存当中。而不是直接将数值10写入主存当中。
那么Java语言本身对 原子性、可见性以及顺序性提供了哪些保证呢?
3.3.1 原子性
在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。
上面一句话虽然看起来简单,但是理解起来并不是那么容易。看下面一个例子i:
请分析以下哪些操作是原子性操作:
x = 10; //语句1
y = x; //语句2
其实只有语句1是原子性操作,其他三个语句都不是原子性操作。
语句1是直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中。
语句2实际上包含2个操作,它先要去读取x的值,再将x的值写入工作内存,虽然读取x的值以及 将x的值写入工作内存 这2个操作都是原子性操作,但是合起来就不是原子性操作了。
也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。
不过这里有一点需要注意:在32位平台下,对64位数据的读取和赋值是需要通过两个操作来完成的,不能保证其原子性。但是好像在最新的JDK中,JVM已经保证对64位数据的读取和赋值也是原子性操作了。
总结:从上面可以看出,Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过 synchronized 和 Lock锁 来实现。由于 synchronized 和 Lock 能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。不过 synchronized 和 Lock 比较重量级,即消耗资源多。
注意,volatile 不能保证程序执行的原子性,后文会说明。
3.3.2 可见性
对于可见性,Java提供了volatile关键字来保证可见性。
当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。
而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。
下面是一个可见性相关的例子
public class HappensBeforeTest2 {
private Foo sharedFoo;
private boolean inited;
public void publisher() {
sharedFoo = new Foo(42);
inited = true;
System.out.println("publish执行: inited is " + inited);
}
public void consumer() {
System.out.println("开始循环");
while (!inited) {
}
System.out.println("结束循环");
System.out.println(sharedFoo.nr);
}
public static void main(String[] args) {
try {
HappensBeforeTest2 test = new HappensBeforeTest2();
new Thread() {
public void run() {
test.consumer();
}
}.start();
Thread.sleep(1000);
new Thread() {
public void run() {
test.publisher();
}
}.start();
} catch (Exception e) {
e.printStackTrace();
}
}
}
上述代码的输出如下
输出表明线程 publisher 确实把 inited 修改为了true,但是consumer线程仍然陷入了死循环。这意味着 consumer线程没有读到 inited 的最新值,而是读取工作内存中的旧值。所以,普通变量是不具有可见性的。
如果在 inited 变量定义时加上 volatile 关键字,publisher线程修改了 inited 之后,会把值及时刷到主存中,consumer线程也会从内存中拿到变量的最新值,便不会陷入死循环了。
另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中,因此可以保证可见性。
3.3.3 happens-before关系
happens-before原则非常重要,它是判断数据是否存在竞争、线程是否安全的主要依据,依靠这个原则,我们解决在并发环境下两操作之间是否可能存在冲突的所有问题。
下面我们就一个简单的例子稍微了解下happens-before ;
i = 1; //线程A执行
j = i ; //线程B执行
j 是否等于1呢?假定线程A的操作(i = 1)happens-before线程B的操作(j = i),那么可以确定线程B执行后 j = 1 一定成立,如果他们不存在happens-before原则,那么 j = 1 不一定成立。
happens-before原则定义
如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
下面就来具体介绍下happens-before原则(先行发生原则):
- 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
- 锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作
- volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
- 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
- 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
- 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
- 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
- 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始
这8条原则摘自《深入理解Java虚拟机》。这8条规则中,前4条规则是比较重要的,后4条规则都是显而易见的。
下面我们来解释一下前4条规则:
对于程序次序规则来说,我的理解就是一段程序代码的执行在单个线程中看起来是有序的。注意,虽然这条规则中提到“书写在前面的操作先行发生于书写在后面的操作”,这个应该是程序看起来执行的顺序是按照代码顺序执行的,因为虚拟机可能会对程序代码进行指令重排序。虽然进行重排序,但是最终执行的结果是与程序顺序执行的结果一致的,它只会对不存在数据依赖性的指令进行重排序。因此,在单个线程中,程序执行看起来是有序执行的,这一点要注意理解。事实上,这个规则是用来保证程序在单线程中执行结果的正确性,但无法保证程序在多线程中执行的正确性。
第二条规则也比较容易理解,也就是说无论在单线程中还是多线程中,同一个锁如果出于被锁定的状态,那么必须先对锁进行了释放操作,后面才能继续进行lock操作。
第三条规则是一条比较重要的规则,也是后文将要重点讲述的内容。直观地解释就是,如果一个线程先去写一个变量,然后一个线程去进行读取,那么写入操作肯定会先行发生于读操作。
第四条规则实际上就是体现happens-before原则具备传递性。
**注意:**两个操作之间存在happens-before关系,并不意味着一定要按照happens-before原则制定的顺序来执行。如果指令重排之后的执行结果与按照happens-before关系来执行的结果一致,那么这种重排序是合法的。
3.3.4 顺序性
Java 虚拟机不保证程序完全按代码的书写顺序执行,为了保证程序执行的高效,JVM 保留了 CPU 的指令重排功能。
在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行顺序,却会影响到多线程并发执行的正确性。下面是一个指令重排的佐证
class TestVolatile {
boolean inited2 = false;
Student student;
public void testVolatile() {
new Thread() {
public void run() {
student = new Student("wendy", 20);
inited2 = true;
System.out.println("线程1运行");
}
}.start();
new Thread() {
public void run() {
if (!inited2) {
System.out.println("字符串未初始化:" + student);
System.out.println("线程2先运行");
} else {
System.out.println("字符串已初始化:" + student);
System.out.println("线程2后运行");
}
}
}.start();
}
}
从输出结果看到,轮到线程2执行时,inited2为false。分析知线程1中student的初始化和System.out.println(“线程1运行”),这两条语句已经执行,但inited2 = true未执行,这显然与代码书写顺序不符,这表明已经发生了指令重排。
虽然进行重排序,但是最终执行的结果是与程序顺序执行的结果一致的,它只会对不存在数据依赖性的指令进行重排序。因此,在单个线程中,程序执行看起来是有序执行的,这一点要注意理解。事实上,这个规则是用来保证程序在单线程中执行结果的正确性,但无法保证程序在多线程中执行的正确性。
3.4 深入剖析volatile关键字
在前面讲述了很多东西,其实都是为讲述volatile关键字作铺垫,那么接下来我们就进入主题。
3.4.1 volatile保证可见性
上一节已经用代码描述了 volatile 解决可见性问题的场景,本小节讨论一下 volatile 保证可见性的原理。先看一段代码,假如线程1先执行,线程2后执行:
//线程1
boolean stop = false;
while(!stop){
doSomething();
}
//线程2
stop = true;
这段代码是很典型的一段代码,很多人在中断线程时可能都会采用这种标记办法。但是事实上,这段代码会完全运行正确么?即一定会将线程中断么?不一定,也许在大多数时候,这个代码能够把线程中断,但是也有可能会导致无法中断线程(虽然这个可能性很小,但是只要一旦发生这种情况就会造成死循环了)。
下面解释一下这段代码为何有可能导致无法中断线程。在前面已经解释过,每个线程在运行过程中都有自己的工作内存,那么线程1在运行的时候,会将stop变量的值拷贝一份放在自己的工作内存当中。
那么当线程2更改了stop变量的值之后,但是还没来得及写入主存当中,线程2转去做其他事情了,那么线程1由于不知道线程2对stop变量的更改,因此还会一直循环下去。
但是用volatile修饰之后就变得不一样了:
第一:使用volatile关键字会强制将修改的值立即写入主存;
第二:使用volatile关键字的话,当线程2进行修改时,会导致线程1的工作内存中缓存变量stop的缓存行无效(反映到硬件层的话,就是CPU的L1或者L2缓存中对应的缓存行无效);
第三:由于线程1的工作内存中缓存变量stop的缓存行无效,所以线程1再次读取变量stop的值时会去主存读取。
那么在线程2修改stop值时(当然这里包括2个操作,修改线程2工作内存中的值,然后将修改后的值写入内存),会使得线程1的工作内存中缓存变量stop的缓存行无效,然后线程1读取时,发现自己的缓存行无效,它会等待缓存行对应的主存地址被更新之后,然后去对应的主存读取最新的值。
那么线程1读取到的就是最新的正确的值。
3.4.2 volatile保证有序性
在前面提到volatile关键字能禁止指令重排序,所以volatile能在一定程度上保证有序性。
volatile关键字禁止指令重排序有两层意思:
1)当程序执行到volatile变量的读操作或者写操作时,在其前面的操作肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
2)在进行指令优化时,不能将涉及volatile变量语句前的语句放在涉及volatile变量的语句后面执行,也不能把volatile变量后面的语句放到其前面执行。
可能上面说的比较绕,举个简单的例子:
//x、y为非volatile变量
//flag为volatile变量
x = 2; //语句1
y = 0; //语句2
flag = true; //语句3
x = 4; //语句4
y = -1; //语句5
由于flag变量为volatile变量,那么在进行指令重排序的过程的时候,不会将语句3放到语句1、语句2前面,也不会将语句3放到语句4、语句5后面。但是要注意语句1和语句2的顺序、语句4和语句5的顺序是不作任何保证的。
并且volatile关键字能保证,执行到语句3时,语句1和语句2必定是执行完毕了的,且语句1和语句2的执行结果对语句3、语句4、语句5是可见的。
那么我们回到前面举的一个例子:
//线程1:
context = loadContext(); //语句1
inited = true; //语句2
//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);
前面举这个例子的时候,提到有可能语句2会在语句1之前执行,那么久可能导致context还没被初始化,而线程2中就使用未初始化的context去进行操作,导致程序出错。
这里如果用volatile关键字对inited变量进行修饰,就不会出现这种问题了,因为当执行到语句2时,必定能保证context已经初始化完毕。
3.4.3 volatile保证原子性吗?
从上面知道volatile关键字保证了操作的可见性,但是volatile能保证对变量的操作是原子性吗?先回答:不能。
public class VolatileTest2 implements Runnable{
//使用静态变量定义共享资源
static volatile int i =0;
@Override
public void run(){
for (int j =0 ; j<10000;j++){
i++;
}
System.out.println(i);
}
public static void main(String[] args) throws InterruptedException {
VolatileTest2 test = new VolatileTest2();
Thread t1 = new Thread(test);
Thread t2 = new Thread(test);
t1.start();
t2.start();
}
}
上面的输出结果不是我们想的10000与20000
10868
13751
这说明volatile不能保证原子性。
下面看一个例子:
public class Test {
public volatile int inc = 0;
public void increase() {
inc++;
}
public static void main(String[] args) {
final Test test = new Test();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
while(Thread.activeCount()>1) //保证前面的线程都执行完
Thread.yield();
System.out.println(test.inc);
}
}
大家想一下这段程序的输出结果是多少?也许有些朋友认为是10000。但是事实上运行它会发现每次运行结果都不一致,都是一个小于10000的数字。
可能有的朋友就会有疑问,不对啊,上面是对变量inc进行自增操作,由于volatile保证了可见性,那么在每个线程中对inc自增完之后,在其他线程中都能看到修改后的值啊,所以有10个线程分别进行了1000次操作,那么最终inc的值应该是1000*10=10000。
这里面就有一个误区了,volatile关键字能保证可见性没有错,但是上面的程序错在没能保证原子性。可见性只能保证每次读取的是最新的值,但是volatile没办法保证对变量操作的原子性。
在前面已经提到过,自增操作是不具备原子性的,它包括读取变量的原始值、进行加1操作、写入工作内存。那么就是说自增操作的三个子操作可能会分割开执行,就有可能导致下面这种情况出现:
假如某个时刻变量inc的值为10,
线程1对变量进行自增操作,线程1先读取了变量inc的原始值,然后线程1被阻塞了;
然后线程2对变量进行自增操作,线程2也去读取变量inc的原始值,由于线程1只是对变量inc进行读取操作,而没有对变量进行修改操作,所以不会导致线程2的工作内存中缓存变量inc的缓存行无效,所以线程2会直接去主存读取inc的值,发现inc的值时10,然后进行加1操作,并把11写入工作内存,最后写入主存。
然后线程1接着进行加1操作,由于已经读取了inc的值,注意此时在线程1的工作内存中inc的值仍然为10,所以线程1对inc进行加1操作后inc的值为11,然后将11写入工作内存,最后写入主存。
那么两个线程分别进行了一次自增操作后,inc只增加了1。
解释到这里,可能有朋友会有疑问,不对啊,前面不是保证一个变量在修改volatile变量时,会让缓存行无效吗?然后其他线程去读就会读到新的值,对,这个没错。这个就是上面的happens-before规则中的volatile变量规则,但是要注意,线程1对变量进行读取操作之后,被阻塞了的话,并没有对inc值进行修改。然后虽然volatile能保证线程2对变量inc的值读取是从内存中读取的,但是线程1没有进行修改,所以线程2根本就不会看到修改的值。
根源就在这里,自增操作不是原子性操作,而且volatile也无法保证对变量的任何操作都是原子性的。
3.4.4 如何保证原子性?
把上面的代码改成以下任何一种都可以达到效果:
采用synchronized:
public class Test {
public int inc = 0;
public synchronized void increase() {
inc++;
}
public static void main(String[] args) {
final Test test = new Test();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
while(Thread.activeCount()>1) //保证前面的线程都执行完
Thread.yield();
System.out.println(test.inc);
}
}
采用Lock:
public class Test {
public int inc = 0;
Lock lock = new ReentrantLock();
public void increase() {
lock.lock();
try {
inc++;
} finally{
lock.unlock();
}
}
public static void main(String[] args) {
final Test test = new Test();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
while(Thread.activeCount()>1) //保证前面的线程都执行完
Thread.yield();
System.out.println(test.inc);
}
}
采用AtomicInteger:
public class Test {
public AtomicInteger inc = new AtomicInteger();
public void increase() {
inc.getAndIncrement();
}
public static void main(String[] args) {
final Test test = new Test();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
while(Thread.activeCount()>1) //保证前面的线程都执行完
Thread.yield();
System.out.println(test.inc);
}
}
在java 1.5的java.util.concurrent.atomic包下提供了一些原子操作类,即对基本数据类型的 自增(加1操作),自减(减1操作)、以及加法操作(加一个数),减法操作(减一个数)进行了封装,保证这些操作是原子性操作。atomic是利用CAS来实现原子性操作的(Compare And Swap),CAS实际上是利用处理器提供的CMPXCHG指令实现的,而处理器执行CMPXCHG指令是一个原子性操作。
3.4.5 volatile的原理和实现机制
前面讲述了源于volatile关键字的一些使用,下面我们来探讨一下volatile到底如何保证可见性和禁止指令重排序的。
下面这段话摘自《深入理解Java虚拟机》:
“观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”
lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:
1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
2)它会强制将对缓存的修改操作立即写入主存;
3)如果是写操作,它会导致其他CPU中对应的缓存行无效。
3.5 使用volatile关键字的场景
synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。通常来说,使用volatile必须具备以下2个条件:
1)对变量的写操作不依赖于当前值
2)该变量没有包含在具有其他变量的不变式中
实际上,这些条件表明,可以被写入 volatile 变量的这些有效值独立于任何程序的状态,包括变量的当前状态。
事实上,我的理解就是上面的2个条件需要保证操作是原子性操作,才能保证使用volatile关键字的程序在并发时能够正确执行。
下面列举几个Java中使用volatile的几个场景。
状态标记量
volatile boolean inited = false;
//线程1:
context = loadContext();
inited = true;
//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);
双重检查
单例模式中,如要保证多线程情况下只有一个实例,可以使用 volatile
class Singleton{
private volatile static Singleton instance = null;
private Singleton() {
}
public static Singleton getInstance() {
if(instance==null) {
synchronized (Singleton.class) {
if(instance==null)
instance = new Singleton();
}
}
return instance;
}
}
上面的例子已经似乎可以保证单例了,那为什么还需要加volatile呢?
首先要理解new Singleton()做了什么。new一个对象有几个步骤。
1.看class对象是否加载,如果没有就先加载class对象;
2.分配内存空间,初始化实例;
3.调用构造函数;
4.返回地址给引用。
而cpu为了优化程序,可能会进行指令重排序,打乱这3,4这几个步骤,导致实例内存还没分配,就被使用了,当在并发的情况下,就可能出现线程B引用了线程A中还没有被完全初始化的变量。
而加了volatile之后,就保证new 不会被指令重排序。
参考文献
《Java 中的双重检查(Double-Check)》http://blog.csdn.net/dl88250/article/details/5439024
和http://www.iteye.com/topic/652440
参考资料:
《Java编程思想》
《深入理解Java虚拟机》
http://jiangzhengjun.iteye.com/blog/652532
http://blog.sina.com.cn/s/blog_7bee8dd50101fu8n.html
http://ifeve.com/volatile/
http://blog.csdn.net/ccit0519/article/details/11241403
http://blog.csdn.net/ns_code/article/details/17101369
http://www.cnblogs.com/kevinwu/archive/2012/05/02/2479464.html
http://www.cppblog.com/elva/archive/2011/01/21/139019.html
http://ifeve.com/volatile-array-visiblity/
http://www.bdqn.cn/news/201312/12579.shtml
http://exploer.blog.51cto.com/7123589/1193399
http://www.cnblogs.com/Mainz/p/3556430.html
疑问
验证volatile保证顺序性
volatile static boolean inited;
Student student;
public void testVolatile() {
new Thread() {
public void run() {
student = new Student("wendy", 20);
inited = true;
System.out.println("线程1运行");
}
}.start();
new Thread() {
public void run() {
if (inited) {
System.out.println("字符串已初始化:" + student);
System.out.println("线程2后运行");
} else {
System.out.println("字符串未初始化:" + student);
System.out.println("线程2先运行");
}
}
}.start();
}
输出
线程1运行
字符串未初始化:Student(name=wendy, age=20)
线程2先运行
预期:输出“线程1运行”,按照顺序性,inited=true 按理来说已经赋值完成,为何从结果来看 inited==false呢?