1. Java内存模型的特征
Java内存模型是围绕着在并发过程中如何处理原子性、可见性和有序性这三个特征来建立。下面逐个看下哪些操作实现这三个特性:
1.1 原子性(Atomicity)
由Java内存模型来直接保证的原子性变量操作包括 read、load、assign、use、store和 write 这六个,我们可以大致认为,基本数据类型的访问、读写都是具备原子性(例外就是 long 和 double 的非原子性协定),当然如果应用场景需要更大的范围来保证原子性,可以使用 synchronized 关键字,在 synchronized 块之间的操作也具备原子性。
1.2 可见性(Visibility)
所谓的可见性就是指当一个线程修改了共享变量的值时,其他线程能够立马知道这个修改。Java内存模型是通过在变量修改后将新值台同步回主内存,在变量读取之前从主内存刷新变量值这种依赖主内存作为传递中介的方式来实现可见性!不论是普通变量还是 volatile 变量都是一样。然后 volatile 和普通变量的区别在于,volatile 变量可以保证新值能立刻同步主内存,每次使用都是拿到最新的值。
另外 synchronized 和 final 也可以实现可见性。
1.3 有序性(Ordering)
如果在同一个线程内,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指:线程内表现为串行的语义;后半句:是指 指令重排序和工作内存与主内存同步延迟现象。
2. 线程安全、线程不安全
- 线程安全:就是说多线程访问同一代码,不会产生不确定的结果。
- 线程不安全:就是多线程访问同一代码,会产生不确定的结果,造成线程不安全有5个原因:
- 抢占式执行
- 多个线程修改同一个变量
- 修改操作,不是原子的
- 内存可见性,引起的线程不安全
- 指令重排序,引起的线程不安全
3. 如何解决线程不安全
3.1 使用synchronized 关键字
先来看一段线程不安全的代码
class Counters{
public int count = 0;
public void add(){
count++;
}
public int getCount(){
return count;
}
}
public class Demo22 {
public static void main(String[] args) throws InterruptedException {
Counters counters = new Counters();
Thread t1 = new Thread(()->{
for (int i = 0; i < 10000; i++) {
counters.add();
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 10000; i++) {
counters.add();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counters.getCount());
}
}
可以看出结果并不是我们想的 20000. 是因为当我们在开启t1线程和t2线程操作同一个变量count,但是由于 count++ 这个操作并不具备原子性,该操作是从主内存中读取值到工作内存中,然后+1,在写回到主内存中,如果在t1读取旧值和写回新值的中间,t2线程也读取了值,这个值和t1读取的是一样的,如果t2读完旧值以后进行+1,并写回主内存,此时t2再写回主内存,在这种情况下,就少了一次+1的操作!此时就造成了线程不安全!因此需要 synchronized 关键字修饰,来保证线程的安全!下面来看synchronized的使用方法。
3.1.1 修饰实例方法
作用于当前实例加锁,进入同步代码前要获得当前实例的锁
class Counter{
public int count = 0;
synchronized public void add(){
count++;
}
public int getCount(){
return count;
}
}
public class Demo21 {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread(()->{
for (int i = 0; i < 10000; i++) {
counter.add();
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 10000; i++) {
counter.add();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.getCount());
}
}
此时输出结果为20000,此时保证了,当t1线程在访问synchronzed方法时,t2线程并不能访问。这就保证了操作的原子性! 另外要注意,当一个线程正在访问一个对象的synchronized实例方法,那么其他线程并不能访问该对象的其他synchronized方法,毕竟一个对象只有一把锁,当一个线程获取了该对象的锁之后,其他线程无法获取该对象的锁,但是其他线程可以访问该实例对象的非synchronized方法。
3.1.2 修饰静态方法的时候
相当于给类加锁,会作用于这个类的所有对象实例
当一个线程A调用实例对象的非静态 synchronized方法,而线程B调用实例对象所属类的静态 synchronized方法,是允许的,不会发生互斥对象!
因为访问静态synchronized方法占用的锁是当前类的锁,而访问非静态synchronized方法占用锁是当前实例对象的锁
class Counter {
public static int count = 0;
synchronized public static void add() {
count++;
}
public int getCount() {
return count;
}
}
public class Demo21 {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Counter counter1 = new Couter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10; i++) {
counter.add();
System.out.println(counter.count);
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10; i++) {
couter1.add();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.getCount());
}
}
上述代码中,虽然创建了两个实例,但是由于count是被static修饰的,所以是类成员,所以当t1和t2线程进行操作时,操作的是同一个变量!所以当synchronized修饰静态方法的时候,锁就是当前class对象锁,就是 Counter.class。所以当两个线程进行访问时,竞争的是同一把锁,会产生互斥现象!所以保证了线程的安全性!另外如果线程t1访问的是实例对象的非静态synchronized方法时,另一个线程t2需要访问实例对象所属类的静态synchronized方法时,是不会发生互斥的,因为锁对象不同!
3.1.3 修饰代码块
指定加锁对象,对给定对象加锁,进入同步代码块前要获得给定对象的锁
class Counter {
Object locker = new Object();
public int count = 0;
public void add() {
synchronized (locker) {
count++;
}
}
public int getCount() {
return count;
}
}
public class Demo21 {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10; i++) {
counter.add();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10; i++) {
counter.add();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.getCount());
}
}
修饰代码块时候,可以在()里面填写任意对象!由于在某些情况下,我们编写的方法体可能比较大,同时存在一些比较耗时的操作,但是需要同步的代码块又是一小部分,所以此时就可以用修改代码块的方式加锁!
3.1.4 synchronized关键字总结
synchronized 关键字解决的是多个线程之间访问资源的同步性,synchronized 关键字可以保证被它修饰的代码块或者方法在任意时刻都只能有一个线程执行。
4. 使用volatile关键字
4.1 内存可见性
先看一段代码:
public class Demo23 {
public static int flag = 0;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while (flag == 0) {
}
System.out.println("循环结束! t1 结束");
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个整数:");
flag = scanner.nextInt();
});
t1.start();
t2.start();
}
}
此处可以看出程序并没有停止!利用下图来解释!
所以当你输入1的时候,由于优化,所以寄存器中的值仍为0
public class Demo23 {
volatile public static int flag = 0;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while (flag == 0) {
}
System.out.println("循环结束! t1 结束");
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个整数:");
flag = scanner.nextInt();
});
t1.start();
t2.start();
}
}
当我们在用 volatile修饰 flag之后,可以看出程序最终结果和我们预想的一样!因为 volatile 保证了内存可见性,当变量修改时,可以立即同步回内存!
总结:volatile 不保证原子性! 适用的场景,一个线程读,一个线程写!
4.2 指令重排序
可以看一段伪代码
Map configOptions;
char[] configText;
// 此变量必须定义为 volatile
volatile boolean initialized = fasle;
// 假设以下代码在线程A中执行
// 模拟读取配置信息,当读取完成后
// 将initialized设置为true,通知其他线程配置可用
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText,configOptions);
initialized = true;
// 假设以下代码在线程B执行
// 等待initialized为true,代表线程A已经把配置信息初始化
while(!initialized){
sleep();
}
// 使用线程A中初始化好的配置信息
doSomethingWithConfig();
可以试想一下,如果定义的 initialized 没有被 volatile修饰,就可能会因为指令重排序的优化,导致线程A中最后一行代码被提前执行,这样在线程B中使用配置信息就会可能出现错误,而volatile关键字就可以避免此类情况发生!
如果有错误,请留言指正~~
本文参考资料:
《深入理解Java虚拟机》