一文读懂Java多线程并发之内存模型

 什么是内存模型?

Java内存模型(Java Memory Model)描述了Java编程语言中的线程如何与内存进行交互,是和多线程相关的一组规范,需要各个 JVM 的实现来遵守 JMM 规范,以便于开发者可以利用这些规范,更方便地开发多线程程序。有了这些规范,即便同一个程序在不同操作系统的虚拟机上运行,得到的程序结果也是一致的。如果没有这些规范,不同操作系统的虚拟机对相同关键字的解释不一致,这是不可接受的。JMM旨在解决 CPU 多级缓存、处理器优化、指令重排等导致的结果不可预期的问题。

特别注意一点,Java内存模型是与多线程并发相关的规范,不是JVM内存结构。JVM内存结构讲的是JVM如何划分运行时内存区域,比如虚拟机规范将内存区域划分为堆区、方法区、虚拟机栈、本地方法栈、程序计数器五个区域。不同虚拟机可能在实现上会略有不同,但总体是按规范实现的。比如Hotspot虚拟机栈和本地方法栈是合并实现的,JDK8方法区用元空间来实现,但JDK7以下则可能是永久代来实现。

内存模型是同步关键字原理

在Java程序中,volatile、synchronized、Lock等同步关键字或锁工具类其实现原理都涉及JMM规范,或说它们就是遵循JMM规范来实现的。

比如说,我们使用volatile来修饰一个共享变量,那么这个变量在多线程环境下,数据变更会立刻从工作内存刷新回主内存以确保变量的可见性。同时,被volatile来修饰的变量也会禁止指令重排序。synchronized也有同样的效果,保证可见性,禁止指令重排序,同时会变量添加monitor对象锁,多个线程访问被synchronized修饰的变量只能等其它线程释放锁才能访问此变量。因此,synchronized能保证变量的线程安全,相当于将变量的读写串行化了,因此能保证变量的线程安全。

JMM最重要的三个东西上面基本都已提及,即原子性、可见性与指令重排序。下面我们详细了解下这几方面的内容。

什么是原子性?

在介绍Java线程的文章中也有提及,这里再次拿出来讲述一下。具备原子性的操作被称为原子操作,原子操作可以认为是一个或一组不可分割的操作,操作要么做完了,要么就都不做,不存在只做一半的情况,原子性意味着不可分割。编程界经典操作,i++,这句代码看似只有一句代码,好像很原子了,实际则不然。下面例子你觉得会打印1还是0?

public class AddTest3 {

    private static int i;

    public static void main(String[] args) {
        System.out.println(i++);
    }
}

稍有经验的Java程序员都知道会打印0。为什么不是打印1而是0呢?我们看看反编译出来的字节码指令:java -verbose AddTest3.class

public static void main(java.lang.String[]);
         3: getstatic     #3                  // Field i:I
         6: dup
         7: iconst_1
         8: iadd
         9: putstatic     #3                  // Field i:I
        12: invokevirtual #4                  // Method java/io/PrintStream.println:(I)V
        15: return
      LineNumberTable:
        line 13: 0
        line 14: 15
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      16     0  args   [Ljava/lang/String;
}

getstatic指令把i的值压入操作栈顶,dup指令复制栈顶一个数值并将复制值重新压入栈顶,iconst_1将1压入栈顶,iadd将取出两个栈顶元素,进行加法运算,并将结果压入栈顶。putstatic将i的值同步回主内存。invokevirtual 直接打印操作栈中i的值,注意,此时操作栈中,此时的i还是一开始getstatic得到的0,因此值为0。只有再次getstatic将i的最新内存值读到操作栈顶,此时打印才会是1。看下面的源码及反编译的字节码即可发现。

public class AddTest3 {

    private static int i;

    public static void main(String[] args) {
        i++;
        System.out.println(i);
    }
}

 关键的字节码如下

 public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field i:I
         3: iconst_1
         4: iadd
         5: putstatic     #2                  // Field i:I
         8: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
        11: getstatic     #2                  // Field i:I
        14: invokevirtual #4                  // Method java/io/PrintStream.println:(I)V
        17: return
      LineNumberTable:
        line 14: 0
        line 15: 8
        line 16: 17
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      18     0  args   [Ljava/lang/String;
}

很明显,在做完加法运算并同步回主内存后,打印前是再一次的getstatic将i的最新值压入栈顶的。

以上两个例子说明了i++操作的非原子性。它至少执行了如下几步:

0: getstatic     #2                  // Field i:I
3: iconst_1
4: iadd
5: putstatic     #2                  // Field i:I

getstatic指令把静态变量i的值读取出来压入栈,iconst_1指令把常量1压入栈,iadd指令把栈顶两个元素执行相加操作。最后通过putstatic把i相加后的结果同步回主内存中。这里的操作明显不是原子操作,当指令在执行iconst_1、iadd这些指令的时候,其他线程可能已经把i的值改变了。所以出现了线程安全问题。我们知道volatile关键字能保证变量的值在读取的那刻会是最新的,但是读取到操作栈顶后,其他线程对该值的更新,不会通知其他线程进行更新,所以上面的代码就是加了volatile关键字也不能保证程序的正确,可行的办法是同步锁。

可见性

从Java内存结构我们可以知道,大体是可以分为堆区和栈区的,堆区称为主存,栈区的内存称为工作内存。线程从主内存中读取共享变量的值到虚拟机栈进行操作,更新后需要将新的值同步回主内存,以便其它线程能看到共享变量值的变化。正是由于有工作内存和主内存之间的相互同步,才会出现可见性问题。举个简单的例子:

public class WrongResult {
    volatile static int i;

    public static void main(String[] args) throws InterruptedException {
        Runnable runnable = () -> {
            for (int j = 0; j < 10000; j++) {
                i++;
            }
        };

        Thread thread1 = new Thread(runnable);
        thread1.start();
        Thread thread2 = new Thread(runnable);
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(i);
    }
}

两个线程都执行对共享变量i的自增操作,但测试结果多为小于期望值20000。原因就是共享变量i在主内存中,可能同时被两个线程读取到相同的值,然后在各自的工作内存中进行自增,然后同步到主内存。这样一来,两个线程同步的结果都是一样的,相当于少自增了一次。这个也是可见性问题,这种情况只能通过同步锁机制来解决,将读写串行化才能保证程序正确。

volatile修饰变量是可以保证变量的可见性的,也就是当它的值发生变化后会立刻同步到主内存,对一个 volatile 变量的写操作 happen-before 后面对该变量的读操作。但像i++这种非原子操作,volatile是不适用的,如果变量只有赋值和读取操作,那么volatile足以保证变量的线程安全。另外volatile可以禁止编译器的重排序。

指令重排序

最后看一下指令重排序是什么意思。我们编写程序时,代码的编写顺序就是我们期望的执行顺序,这样想是没错的。但实际上,不影响逻辑的情况下,编译器可能为了优化执行效率会对字节码的执行顺序进行重排序。如下例

public class Test4 {

    public static void main(String[] args) {
        int a = 100;
        int b = 10;
        a = a + 10;
    }
}

指令重排前,我们看下字节码指令

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=3, args_size=1
         0: bipush        100
         2: istore_1
         3: bipush        10
         5: istore_2
         6: iload_1
         7: bipush        10
         9: iadd
        10: istore_1
        11: return
      LineNumberTable:
        line 11: 0
        line 12: 3
        line 13: 6
        line 14: 11
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      12     0  args   [Ljava/lang/String;
            3       9     1     a   I
            6       6     2     b   I
}
  1. int a=100这个指令需要先将100压入操作数栈,然后保存到局部变量表slot=1的位置。
  2. int b=10这个指令需要先将10压入操作数栈,然后保存到局部变量表slot=2的位置。
  3. a=a+10这个指令需要从局部变量表slot=1的位置读取a的值100到操作数栈,然后将10也压入操作数栈顶,然后执行iadd指令将两数相加,最后把结果110保存回局部变量表slot=1的位置。

这个过程就是没做指令重排序的正常执行流程,但仔细想一下,可以发现,a被两次加载到操作数栈顶,第一次的加载a和存储到局部变量表基本就是多余的。因为完全可以把100加载到栈顶,把10加载到栈顶,运算后再保存。这样一来,既不影响程序的结果,又少操作了两步。也就是重排序后的代码像下面

public class Test4 {

    public static void main(String[] args) {
        int a = 100;
        a = a + 10;
        int b = 10;
        
    }
}

这样调整代码的执行顺序是不会影响结果的,所以一开始演示的代码可能就会被指令编译器重排序成如上的代码。

重排序可能是编译器重排序、也可能是CPU重排序。

  • 27
    点赞
  • 29
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
好的,针对你的问题,我来给你解答一下。 在Java中,泛型是一种用于编写可用代码的机制,可以让你在编译时检测类型安全。通过使用泛型,我们可以编写出更加通用的代码,提高代码的复用和可读。 在Java中,泛型类型参数可以传入类、接口和方法中。在方法中传入泛型类型参数时,可以使用以下几种方式: 1. 传入具体类型参数 例如,假设我们有一个泛型方法: ```java public <T> void print(T t) { System.out.println(t); } ``` 我们可以通过以下方式传入具体类型参数: ```java print("hello world"); // 传入String类型参数 print(123); // 传入Integer类型参数 ``` 2. 传入泛型类型参数 在某些情况下,我们可能需要在方法中传入泛型类型参数。此时,可以使用以下方式: ```java public <T> void printList(List<T> list) { for (T t : list) { System.out.println(t); } } ``` 在调用该方法时,我们可以传入任何类型的List: ```java List<String> stringList = Arrays.asList("hello", "world"); List<Integer> intList = Arrays.asList(1, 2, 3); printList(stringList); // 传入String类型的List printList(intList); // 传入Integer类型的List ``` 3. 传入通配符类型参数 有时候,我们可能需要在方法中传入一个不确定类型的List。此时,可以使用通配符类型参数: ```java public void printList(List<?> list) { for (Object obj : list) { System.out.println(obj); } } ``` 在调用该方法时,我们可以传入任何类型的List: ```java List<String> stringList = Arrays.asList("hello", "world"); List<Integer> intList = Arrays.asList(1, 2, 3); printList(stringList); // 传入String类型的List printList(intList); // 传入Integer类型的List ``` 注意,使用通配符类型参数时,我们只能对List进行读取操作,不能进行添加或删除操作。 希望这些内容能够解答你的问题。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值