关于后端:解读-Java-内存模型

1次阅读

共计 12130 个字符,预计需要花费 31 分钟才能阅读完成。

凡人之所以平凡,是因为他与他人共处顺境时,他人失去了信念,他却下决心实现本人的指标。

Java 内存模型(Java Memory Model)定义了 Java 的线程在拜访内存时会产生什么。这里针对以下几个要点进行解析:

  • 重排序
  • 可见性
  • synchronized
  • volitile
  • final
  • Double-Checked Locking

首先理解一下与 Java 内存模型交互时的指南:

* 应用 synchronized 或 volatile 来爱护在多个线程之间共享的字段
* 将常量字段设置为 final
* 不要从构造函数中泄露 this

重排序

什么是重排序

所谓重排序,英文记作 Reorder,是指编译器和 Java 虚拟机通过改变程序的解决程序来优化程序。尽管重排序被宽泛用于进步程序性能,不过开发人员简直不会意识到这一点。实际上,在运行单线程程序时咱们无奈判断是否进行了重排序。这是因为,尽管解决程序扭转了,然而标准上有很多限度能够防止程序呈现运行谬误。

然而,在多线程程序中,有时就会产生显著是由重排序导致的运行谬误。

示例程序 1

上面代码展现了一段帮忙咱们了解重排序的示例程序。在 Something 类中,有 x、y 两个字段,以及 write、read 这两个办法。x 和 y 会在最开始被初始化为 0。write 办法会将 x 赋值为 100,y 赋值为 50。而 read 办法则会比拟 x 和 y 的值,如果 x 比 y 小,则显示 x <y。

Main 类的 main 办法会创立一个 Something 的实例,并启动两个线程。写数据的线程 A 会调用 write 办法,而读数据的线程 B 则会调用 read 办法。

class Something {
    private int x = 0;
    private int y = 0;
    public void write() {
        x = 100;
        y = 50;
    }
    public void read() {if(x < y) {System.out.println("x < y");
        }
    }
}
public class Main {public static void main(String[] args) {final Something obj = new Something();
        // 写数据的线程 A
       new Thread() {public void run() {obj.write();
           }
       }.start();
       // 读数据的线程 B
       new Thread() {public void run() {obj.read();
           }
       }.start();} 
}

问题是,在运行这段程序后会显示出 ”x < y” 吗?
因为 write 办法在给 x 赋值后会接着给 y 赋值,所以 x 会先从 0 变为 100,而之后 y 则会从 0 变为 50。因而,大家可能会做出相对不可能显示 ”x < y” 的判断。然而,这么判断是谬误的。

大家应该会很吃惊,因为在 Java 内存模型中,是有可能显示出 x < y 的。起因就在于重排序。
在 write 办法中,因为对 x 的赋值和对 y 的赋值之间不存在任何依赖关系,编译器可能会扭转赋值程序。而且,在线程 A 曾经为 y 赋值,但尚未为 x 赋值之前,线程 B 也可能会去查问 x 和 y 的值并执行 if 语句进行判断,这时 x < y 的关系成立。

假如如示例程序 1 所示,对于一个字段,有“写数据的线程”和“读数据的线程”,然而咱们并没有应用 synchronized 关键字和 volatile 关键字润饰该字段来正确的同步数据时,咱们称这种没有同步的状态为“存在数据竞争”。此外,咱们称这样存在数据竞争的程序为未正确同步(incorrectly synchronized)的程序。因为未正确同步的程序不足安全性,所以必须应用 synchronized 或 volatile 来正确地进行同步。

尽管示例程序 1 是未正确同步的程序,然而讲 write 和 read 都申明为 synchronized 办法,就能够实现正确同步的程序。

可见性

什么是可见性

假如线程 A 将某个值写入到字段 x 中,而线程 B 读取到了该值。咱们称其为“线程 A 向 x 的写值对线程 B 是可见(visible)的”。“是否是可见的”这个性质就称为可见性,英文记作 visibiliy。

在单线程程序中,毋庸在意可见性。这是因为,线程总是能够看见本人写入到字段中的值。

然而,在多线程程序中必须留神可见性。这是因为,如果没有应用 synchronized 或 volatile 正确地进行同步,线程 A 写入到字段中的值可能并不会立刻对线程 B 课可见。开发人员必须十分分明地晓得在什么状况下一个线程的写值对其余线程是可见的。

示例程序 2

上面代码展现了一段因没有留神到可见性而导致程序失去生存性的示例程序。

class Runner extends Thread {
    private boolean quit = false;
    public void run() {while(!quit) {// ...}
        System.out.println("Done");
    }
    public void shutdown() {quit = true;}
}
public class Main {public static void main(String[] args) {Runner runner = new Runner();
        // 启动线程
        runner.start();
        // 终止线程
        runner.shutdown();}
}

Runner 类的 run 办法会在字段 quit 变为 true 之前始终执行 while 循环。当 quit 变为 true,while 循环完结后,会显示字段 Done。

shutdown 办法会将字段 quit 设置为 true。

Main 类的 main 办法会先调用 start 办法启动 Runner 线程,而后调用 shutdown 办法将 quit 的值设置为 true。咱们本来认为在运行这段程序时,Runner 线程会立刻显示出 Done,而后退出。然而 Java 内存模型可能会导致 Runner 线程永远在 while 循环中不停地循环也就是说,示例程序 2 可能会失去生存性。

起因是,向字段 quit 写值的线程(主线程)与读取字段 quit 的线程(Runner)是不同的线程。主线程向 quit 写入的 true 这个值可能对 Runner 线程永远不可见(非 visible)。

如果以“缓存”的思路来了解不可见的起因可能会有助于大家了解。主线程向 quit 写入的 true 这个值可能只是被保留在主线程的缓存中。而 Runner 线程从 quit 读取到的值,依然是在 Runner 线程的缓存中保留者的值 false,并没有任何变动。不过如果将 quit 申明为 volatile 字段,就能够实现正确同步的代码。

共享内存与操作

在 Java 内存模型中,线程 A 写入的值并不一定会立刻对线程 B 可见。下图展现了线程 A 和线程 B 通过字段进行数据交互的情景。

共享内存(shared memeory)是所有线程共享的存储空间,也被称为堆内存(heap memory)。因为实例会被全副保留在共享内存中,所以实例中的字段也存在与共享内存中。此外,数组的元素也被保留在共享内存中。也就是说,能够应用 new 在共享内存中调配存储空间。

局部变量不会被保留在共享内存中。通常,除局部变量外,办法的形参、catch 语句块中编写的异样处理器的参数等也不会被保留在共享内存中,而是被保留在各个线程持有的栈中。正是因为它们没有被保留在共享内存中,所以其余线程不会拜访它们。

在 Java 内存模型中,只有能够被多个线程拜访的共享内存才会产生问题。

下图展现了 6 种操作(action)。这些操作是咱们把定义内存模型时应用到的解决分类而成的。

这里,(3)~(6)的操作是进行同步(synchronization)的同步操作(synchronization action)。进行同步的操作具备防治重排序,管制可见性的成果。

normal read/normal write 操作示意的是对一般字段(volatile 以外的字段)的读写。如上图所示,这些操作是通过缓存来执行的。因而,通过 normal read 读取到的值并不一定是最新的值,通过 normal write 写入的值也不肯定会立刻对其余线程可见。

volatile read/volatile write 操作示意的是对 volatile 字段的读写。因为这些操作并不是通过缓存来执行的,所以通过 volatile read 读取到的值肯定是最新的值,通过 volatile write 写入的值也会立刻对其余线程可见。

lock/unlock 操作是当程序中应用了 synchronized 关键字时进行虎池解决的操作。lock 操作能够获取实例的锁,unlock 操作能够开释实例的锁。

之所以在 normal read/normal write 操作中应用缓存,是为了进步性能。

如果这里齐全不思考缓存的存在,定义标准是“某个线程执行的写操作的后果都必须立刻对其余线程可见”。那么,因为这项限度太过严格,Java 编译器以及 Java 虚拟机的开发人员进行优化的余地就会变的非常少。

在 Java 内存模型中,某个线程写操作的后果对其余线程可见是有条件的。因而,Java 编译器和 Java 虚拟机的开发人员能够在满足条件的范畴内自在地进行优化。后面解说的重排序就是一种优化。

那么,线程的写操作对其余线程可见的条件到底是什么,应该怎么编写程序才好呢?

为了便于大家了解这些内容,上面将依照程序解说 synchronized、volatile 以及 final 这些关键字。

synchronized

synchronized 具备“线程的互斥解决”和“同步解决”两种性能。

线程的互斥解决

如果程序中有 synchronized 关键字,线程就会进行 lock/unlock 操作。线程会在 synchronized 开始时获取锁,在 synchronized 终止时开释锁。

进行 lock/unlock 的局部并不仅仅是程序中写有 synchronized 的局部。当线程 wait 办法外部期待的时候也会开释锁。此外,当线程从 wait 办法中进去的时候还必须从新获取锁后能力持续进行。

只有一个线程可能获取某个实例的锁。因而,当线程 A 正筹备获取锁时,如果其余线程曾经获取了锁,那么线程 A 就会进入期待队列。这样就实现了线程的互斥(mutal exclusion)。

synchronized 的互斥解决如下图所示,当线程 A 执行了 unlock 操作然而还没有从中进去时,线程 B 就无奈执行 lock 操作。图中的 unlock M 和 lock M 中都写了一个 M,这示意 unlock 操作和 lock 操作是对同一个实例的监视器进行的操作。

同步解决

synchronized(lock/unlock 操作)并不仅仅进行线程的互斥解决。Java 内存模型确保了某个线程在进行 unlock M 操作前进行的所有写入操作对进行 lock M 操作的线程都是可见的。

上面,咱们应用示例程序 3 进行阐明,将示例程序 1 中的 write 和 read 批改为 synchronized 办法,这是一段可能正确地进行同步的程序,相对不可能显示出 x < y。

class Something {
    private int x = 0;
    private int y = 0;
    public synchronized void write() {
        x = 100;
        y = 50;
    }
    public synchronized void read() {if(x < y) {System.out.println("x < y");
        }
    }
}
public class Main {public static void main(String[] args) {final Something obj = new Something();
        // 写数据的线程 A
       new Thread() {public void run() {obj.write();
           }
       }.start();
       // 读数据的线程 B
       new Thread() {public void run() {obj.read();
           }
       }.start();} 
}

通过 synchronized 进行同步的情景如下图所示:

在进行如下操作时,线程 A 的写操作对线程 B 是可见的。

  • 线程 A 对字段 x 和 y 写值(normal write 操作)
  • 线程 A 进行 unlock 操作
  • 线程 B 对同一个监视器 M 进行 lock 操作
  • 线程 B 读取字段 x 和 y 的值(normal read)

大体来说就是:

  • 进行 unlock 操作后,写入缓存的内容会被强制的写入到共享内存中
  • 进行 lock 操作后,缓存中的内容会先生效,而后共享内存中的最新内容会被强制从新读取到缓存中

在示例程序 3 中不可能显示出 x < y 的起因有以下两个:

  1. 互斥解决能够避免 read 办法中断 write 办法的解决。尽管在 write 办法外部会产生重排序,然而该重排序不会对 read 办法产生任何影响。
  2. 同步解决能够确保 write 办法向字段 x、y 写入的值对运行 read 办法的线程 B 是可见的。

上图中的 release 和 acquire 示意进行同步解决的两端(synchronized-with edge)。unlock 操作是一种 release,lock 操作是一种 acquire。Java 内存模型能够确保解决是依照“release 终止后对应的 acquire 才开始”的程序进行的。

总结起来就是。只有用 synchronized 爱护会被多个线程读写的共享字段,就能够防止这些共享字段受到重排序和可见性的影响。

volatile

volatile 具备“同步解决”和“对 long 和 double 的原子操作”这两种性能。

同步解决

某个线程对 volatile 字段进行的写操作的后果对其余线程立刻可见。换言之,对 volatile 字段的写入解决并不会被缓存起来。

示例程序 4 是将示例程序 2 中的 quit 批改为 volatile 字段后的程序。

class Runner extends Thread {
    private volatile boolean quit = false;
    public void run() {while(!quit) {// ...}
        System.out.println("Done");
    }
    public void shutdown() {quit = true;}
}
public class Main {public static void main(String[] args) {Runner runner = new Runner();
        // 启动线程
        runner.start();
        // 终止线程
        runner.shutdown();}
}

volatile 字段并非只是不缓存读取和写入。如果线程 A 向 volatile 字段写入的值对线程 B 可见,那么之前向其余字段写入的所有值都对线程 B 是可见的。此外,在向 volatile 字段读取和写入后不会产生重排序。

示例程序 5
class Something {
    private int x = 0;
    private volatile boolean valid = false;
    public void write() {
        x = 123;
        valid = true;
    }
    public void read() {if(valid) {System.out.println(x);
        }
    }
}
public class Main {public static void main(String[] args) {final Something obj = new Something();
        // 写数据的线程 A
        new Thread() {public void run() {obj.write();
            }
        }.start();
        // 读数据的线程 B
        new Thread() {public void run() {obj.read();
            }
        }.start();}
}

如示例程序 5 所示,Something 类的 write 发办法在将非 volatile 字段 x 赋值为 123 后,接着又将 volatile 字段 valid 赋值为了 true。

在 read 办法中,当 valid 的值为 true 时,显示 x。
Main 类的 main 办法会启动两个线程,写数据的线程 A 会调用 write 办法,该数据的线程 B 会调用 read 办法。示例程序 5 是一段能够正确地进行同步解决的程序。

因为 valid 是 volatile 字段,所以以下两条赋值语句不会被重排序。

x = 123;        // [normal write]
valid = true;   // [normal write]

另外,上面两条语句也不会被重排序。

if(valid) {// [volatile read]
    System.out.println(x);  // [normal write]
}

从 volatile 的应用目标来看,volatile 阻止重排序是天经地义的。如示例程序 5 所示,volatile 字段多被用作判断实例是否变为了特定状态的标记。因而,当要确认 volatile 字段的值是否产生了变动时,必须确保非 volatile 的其余字段的值曾经被更新了。

如上图所示,在进行如下解决时,线程 A 向 x 以及 valid 写入的值对线程 B 是可见的。

  • 线程 A 向字段 x 写值(normal write)
  • 线程 A 向 volatile 字段 valid 写值(volatile write)
  • 线程 B 读取 volatile 字段 valid 的值(volatile read)
  • 线程 B 读取字段 x 的值(normal read)
对 long 和 double 的原子操作

Java 标准无奈确保对 long 和 double 的赋值操作的原子性。然而,即便是 long 和 double 的字段,只有它是 volatile 字段,就能够确保赋值操作的原子性。

指南:应用 synchronized 或 volatile 来爱护在多个线程之间共享的字段

final

final 字段与构建线程平安的实例

应用 final 关键字申明的字段只能被初始化一次。final 字段在创立不容许被扭转的对象时起到了十分重要的作用。

final 字段的初始化只能在“字段申明时”或是“构造函数中”进行。那么,当 final 字段的初始化完结后,无论在任何时候,它的值对其余线程都是可见的。Java 内存模型能够确保被初始化后的 final 字段在构造函数的解决完结后是可见的。也就是说,能够确保一下事件:

  • 如果构造函数的解决完结了

    • final 字段初始化后的值对所有线程都是可见的
    • 在 final 字段能够追溯到的所有范畴内都能够看到正确的值
  • 在构造函数的解决完结前

    • 可能会看到 final 字段的值是默认的初始值(0、false 或是 null)
指南:将常量字段设置为 final

Java 内存模型能够确保 final 字段在构造函数执行完结后能够正确的被看到。这样就不再须要通过 synchronized 和 volatile 进行同步了。因而,请将不心愿被扭转的字段设为 final。

指南:不要从构造函数中泄露 this

在构造函数中执行完结前,咱们可能会看到 final 字段的值发生变化。也就是说,存在首先看到“默认初始值”,而后看到“显式地初始化的值”的可能性。

上面来看示例程序 6,有一个 final 字段 x 的一个动态字段 last。

在构造函数中,final 字段 x 被显式地初始化为了 123,而动态字段 last 中保留的则是 this,咱们能够了解为将最初创立的实例保留在了 last 中。

在静态方法 print 中,如果动态字段 last 部位 null(即当初实例曾经创立实现了),这个实例的 final 字段的值就会显示进去。

Main 类的 main 办法会启动两个线程。线程 A 会创立 Something 类的实例,而线程 B 则会调用 Something.print 办法来显示 final 字段的值。

这里的问题是,运行程序后会显示出 0 吗?

class Something {
    // final 的实例字段 
    private final int x;
    // 动态字段
    private static Something last = null;
    // 构造函数
    public Something() {
        // 显式的初始化 final 字段
        x = 123;
        // 在动态字段中保留正在创立中的实例(this)last = this;
    }
    // 通过 last 显式 final 字段的值
    public static void print() {if(last!=null) {System.out.println(last.x);
        }
    }
}
public class Main {public static void main(String[] args) {final Something obj = new Something();
        // 写数据的线程 A
       new Thread() {public void run() {new Something();
           }
       }.start();
       // 读数据的线程 B
       new Thread() {public void run() {Something.print();
           }
       }.start();} 
}

咱们并没有应用 synchronized 和 volatile 对线程 Ahead 线程 B 进行同步,因而不晓得它们会依照怎么的程序执行。所以,咱们必须思考各种状况。

如果线程 B 在执行 print 办法时,看到 last 的值为 null,那么 if 语句中的条件就会变成 false,该程序什么都不会显示。

那么如果线程 B 在执行 print 办法时,看到 last 的值不是 null 会怎么呢?last.x 的值肯定是 123 吗?答案是否定的。依据 Java 内存模型,这时看到的 last.x 的值也可能会是 0. 因为线程 B 在 print 办法中看到的 last 的值,是在构造函数解决完结前获取的 this。

Java 内存模型能够确保构造函数解决完结时 final 字段的值被正确的初始化,对其余线程是可见的。总而言之,如果应用通过 new Something()获取的实例,final 字段是不会产生可见性问题的。然而,如果在构造函数的处理过程中 this 还没有创立结束,就无奈确保 final 字段的正确的值对其余线程是可见的。

如上面实例代码 7 这样批改后,就不可能会显示出 0 了。批改如下:

  • 将构造函数批改为 private,让内部无奈调用
  • 编写一个名为 create 的静态方法,在其中应用 new 关键字创立实例
  • 将动态字段 last 赋值为下面应用 new 关键字创立的实例

这样批改后,只有当那个构造函数解决完结后动态字段 last 才会被赋值,因而能够确保 final 字段被正确的初始化。

class Something {
    // final 的实例字段 
    private final int x;
    // 动态字段
    private static Something last = null;
    // 构造函数
    public Something() {
        // 显式的初始化 final 字段
        x = 123;
    }
    // 将应用 new 关键字创立的实例赋值给 this
    publicstatic Something create() {last = new Something();
        return last;
    }
     // 通过 last 显式 final 字段的值
    public static void print() {if(last!=null) {System.out.println(last.x);
        }
    }
}
public class Main {public static void main(String[] args) {final Something obj = new Something();
        // 写数据的线程 A
       new Thread() {public void run() {Something.create();
           }
       }.start();
       // 读数据的线程 B
       new Thread() {public void run() {Something.print();
           }
       }.start();} 
}

通过下面能够晓得,在构造函数中将动态字段赋值为 this 是十分危险的。因为其余线程可能会通过这个动态字段拜访正在创立中的实例。同样的,向动态字段保留的数组和汇合中保留的 this 也是十分危险的。

另外,在构造函数中进行办法调用时,以 this 为参数的办法调用也是十分危险的。因为该办法可能会将 this 放在其余线程能够拜访到的中央。

Double-Checked Locking 模式的危险性

Double-Checked Locking 模式本来实用于改善 Single Threaded Execution 模式的性能的办法之一,也被称为 test-and-test-and-set。

不过,在 Java 中应用 Double-Checked Locking 模式是很危险的。

示例程序

咱们实现一个具备以下个性的 MySystem 类。

  • MySystem 类的实例是惟一的
  • 能够通过静态方法 getInstance 获取 MySystem 类的实例
  • MySystem 类的实例中有一个字段(date)是 java.util.Date 类的实例。它的值是创立 MySystem 类的实例的工夫
  • 能够通过 MySystem 类的实例办法 getDate 获取 date 字段的值

咱们会采取三种形式来实现上述 MySystem 类:

  1. Single Threaded Execution 模式
  2. Double-Checked Locking 模式
  3. Initialization On Demand Holder 模式
实现形式 1:Single Threaded Execution 模式

思考到可能会有多个线程拜访 getInstance 办法,咱们将 getInstance 办法定义为了 synchronized 办法。因为 instance 字段被 synchronized 爱护着,所以即便多个线程调用 getInstance 办法,也能够确保 MySystem 类的实例是惟一的。
程序尽管与咱们的要求统一,然而 getInstance 是 synchronized 的,因而性能并不好。

import java.util.Date;
public class MySystem {
    private static MySystem instance = null;
    private Date date = new Date();
    private MySystem() {}
    public Date gteDate() {return date;} 
    public static synchronized MySystem getInstance() {if(instance == null) {instance = new MySystem();
        }
        return instance;
    }
}
实现形式 2:Double-Checked Locking 模式

Double-Checked Locking 模式是用于改善实现形式 1 中的性能问题的模式。

getInstance 办法不再是 synchronized 办法。取而代之的时 if 语句中编写的一段 synchronized 代码块。

// X 无奈确保可能正确地运行
import java.util.Date;
public class MySystem {
    private static MySystem instance = null;
    private Date date = new Date();
    private MySystem() {}
    public Date gteDate() {return date;} 
    public static synchronized MySystem getInstance() {if(instance == null) {// (a)第一次 test 
            synchronized(MySystem.class) {// (b)进入 synchronized 代码块
                if(instance == null) {// (c)第二次 test
                    instance = new MySystem();// (d) set
                }
            }                               // (e)退出 synchronized 代码块
        }
        return instance;                    // (f)
    }
}

下图解释了为什么上述代码可能会无奈正确的运行。

留神上图中的(A-4),这里写着“在 (d) 处创立 MySystem 的实例并将其赋值给 instance 字段”,即代码中的以下局部:

instance = new MySystem();

这里创立了一个 MySystem 的实例。在创立 MySystem 的实例时,new Date()的值会被赋给实例字段 date。如果线程 A 从 synchronized 代码块退出后,线程 B 才进入 synchronized 代码块,那么线程 B 也能够看见 date 的值。然而在(A-4)这个阶段,咱们无奈确保线程 B 能够看见线程 A 写入 date 字段的值。
接下来,咱们再假如线程 B 在(B-1)这个阶段的判断后果是 instance != null。这样的话,线程 B 将不进入 synchronized 代码块,而是立刻将 instance 的值作为返回值 return 进去。这之后,线程 B 会在(B-3)这个阶段调用 getInstance 的返回值的 getDate 办法。getDate 办法的返回值就是 date 字段的值,因线程 B 会援用 date 字段的值。然而,线程 A 还没有从 synchronized 代码块中退出,线程 B 也没有进入 synchronized 代码块。因而,咱们无奈确保 date 字段的值对线程 B 可见。

实现形式 3:Initialization On Demand Holder 模式

Initialization On Demand Holder 模式既不会像 Single Threaded Execution 模式那样升高性能,也不会带来像 Double-Checked Locking 模式那样的危险性。

Holder 类是 MySystem 的嵌套类,有一个动态字段 instance,并应用 new MySystem()来初始化该字段。
MySystem 类的静态方法 getInstance 的返回值是 Holder.instance。
这段程序会应用 Holder 的“类的初始化”来创立惟一的实例,并确保线程平安。

咱们应用了嵌套类的提早初始化(lazy initialization)。Holder 类的初始化在线程刚刚要应用该类时才会开始进行。也就是说,在调用 MySystem.getInsta 办法前,Holder 类不会被初始化,甚至连 MySystem 的实例都不会创立。因而,应用该模式能够防止内存节约。

import java.util.Date;
public class MySystem {
    private static class Holder {public static MySystem instance = new MySystem();
    }
    private Date date = new Date();
    private MySystem() {}
    public Date getDate() {return date;}
    public static MySystem getInstance() {return Holder.instance;}
}
正文完
 0