java内存模型

发布在 java, jvm

非原创声明

本文并非我的原创文章,而是我学习jvm时的笔记。文中的材料与数据大部分来自于其它资料,详细请查看本文的引用章节。

JAVA内存模型

犹记得大学时操作系统课上,我们迷茫的眼神注视着带着厚眼镜教授向我们一遍遍的强调,一个程序最少有一个进程组成,进程是操作系统提供独立资源供应用程序运行的基本单位。另外老师向我们讲到,为了更好的提高计算机的并行计算能力,计算机科学家们又设计了线程。线程是比进程更小的单位,一个进程可以由多个线程组成。同时线程也是在得到cpu时间片时可运行的最小的单位。尤记得在这些理论基础下,我慢慢的学会了使用C在LINUX环境下使用多进程和多线程进行编程。这些并发API都是LINUX提供的标准API,程序在编译链接之后可以直接调用通过系统内核创建进程或线程,在类UNIX操作系统下这样去做可以让程序有更高的性能。但是这种编程方式所编写出来的代码是与操作系统绑定的,我在linux下明明可以完美运行的代码,在windows下连编译甚至都做不到。除非是用 windows API重新把与操作系统进行交互的那些代码给替换掉,否则就不要想着让程序去跨平台运行了。
在进行并发编程时相对于C,我更喜欢java的编程体验。java消除操作系统之间的差异,原生的对多线程应用提供了很好的支持,特别是在jdk1.5之后,jdk还提供了currency包,更好的让程序员们无需去关注并发的难点与细节,更专心的关注应用的业务需求实现。
在多线程编程中最长遇到的问题就是线程安全问题,其中又以内存中的数据安全问题最为常见(这个数据安全指的是发生脏读、幻读等并发编程中会遇到的错误)。为了消除不同硬件和操作系统对内存操作的差异,在硬件设备和操作系统的内存模型之上,java虚拟机规范定义了一种java内存模型(JAVA Memory Model,简称JMM),将内存分为了工作内存和主内存。JMM主要定义了JVM中在内存中操作变量的规则和细节,用来解决在并发竞争对变量操作时可能会发生的各种问题。JMM完全兼容CPU的多级cache机制,并且支持编译器的代码重排序。
注意:JMM内存模型的概念不同于jvm中6大内存区域的概念,两者不可强行混为一谈。

主内存和工作内存

JMM规定了所有的全局变量都存于主内存(Main Memory)中,一般情况下这些全局变量都会被保存到堆里。每个线程都有自己的工作内存(Working Memory),工作内存中会保存当前线程所需用到的主内存中全局变量的拷贝和自己的局部变量。一般情况下工作内存指的是栈内存,从物理上来讲,工作内存一般情况下都会工作于cpu的cache里。一个线程对全局变量的任何操作必须在自己的工作内存中进行,不允许直接操作工作内存。不同线程不能访问对方的工作内容,只能通过主内存进行数据交换。

工作内存在对主内存中变量做拷贝时,如果变量是基本类型的,则会拷贝其值;如果是引用类型的,那么仅仅会拷贝其引用

JMM内存操作

JMM定义了8种主内存与工作内存之间的具体交互操作,虚拟机在实现JMM时必须保证每种内存操作都是原子的。表1详细列出了JMM的8种内存操作。

指令 操作名 作用区域 描述
lock 锁定 主内存 把一个变量标识为一个线程独占的状态
unlock 解锁 主内存 把一个处于锁定状态的变量释放出来
read 读取 主内存 把一个变量的值从主内存读取到工作内存中,以便于随后的load指令使用
load 载入 工作内存 把read指令从主内存中读取的变量值放入到工作内存的变量副本中
use 使用 工作内存 将工作内存中一个变量的值传递给执行引擎
assign 赋值 工作内存 把一个从执行引擎接收到的值赋给工作内存的变量
store 存储 工作内存 把一个工作内存中变量的值传送到主内存中,以便于随后的write指令使用
write 写入 主内存 把store指令从工作内存传出的变量的值写入到主内存的变量中

表1 JMM内存操作指令表

20170702 01:48 编辑到JMM内存操作指令表

JMM规定了在使用以上8种内存操作时必须遵守以下规则:

  • read,load或store,write必须成对出现。如:执行read操作从主内存读取一个变量后,必须在工作内存使用load载入这个变量。两者之间的顺序不可错,但两者之间可以穿插其它指令。同理,在工作内存中对一个变量使用了store指令,必须在主内存中使用write指令进行写入。
  • 不允许一个线程丢弃它最近做的assign操作。变量在工作内存中改变了之后必须将这个变化同步到主内存中。
  • 不允许一个线程没有发生过assign操作就将数据从工作内存同步到主内存。
  • 只能在主内存中新建全局变量,不能在工作内存中直接使用一个未被初始化的变量。可以使用assign和load指令在工作内存对一个变量进行初始化操作。
  • 一个变量在同一时刻只能被一个线程执行lock操作。
  • 如果对一个变量执行lock,那将清空工作内存中此变量的值。在执行引擎使用这个变量之前,需要重新load或assign重新初始化变量的值。
  • unlock只能解锁被本线程锁定的变量。
  • 对一个变量执行unlock之前,必须将此变量同步回主内存。

volatile 关键字

volatile是java语言所提供的关键字,使java最轻量级的内存同步的措施。与 synchronized 块相比,volatile 变量所需的编码较少,并且运行时开销也较少,但是它所能实现的功能也仅是 synchronized 的一部分。

java中锁提供了两种主要特性:互斥(mutual exclusion) 和可见性(visibility)。互斥即一次只允许一个线程持有某个特定的锁,因此可使用该特性实现对共享数据的协调访问协议,这样,一次就只有一个线程能够使用该共享数据。可见性要更加复杂一些,它必须确保释放锁之前对共享数据做出的更改对于随后获得该锁的另一个线程是可见的 。
volatile 变量具有 synchronized 的可见性特性,但是不具备锁的原子特性。所以即便volatile没有不一致的问题,但volatile变量在并发的运算下并不是原子操作,所以依然可能会有安全问题。

package com.github.weiwei02.jvm.jmm.volatile_test;

/**
* volatile 线程安全性测试
* @author Wang Weiwei <email>weiwei02@vip.qq.com / weiwei.wang@100credit.com</email>
* @version 1.0
* @sine 2017/7/2
*/
public class VolatileTest {
public static volatile int race = 0;
public static final int THREAD_COUNT=20;

public static void main(String[] args) {
    Thread threads[] = new Thread[THREAD_COUNT];
    System.out.println(race);
    for (int i =0; i < THREAD_COUNT; i++){
        threads[i] = new Thread(() -> {
            for (int j = 0; j < 1000 ; j++) {
                increase();
            }
        });
        threads[i].start();
    }


    //等待所有累加线程都结束
    while (Thread.activeCount() > 1){
        Thread.yield();
    }

    //等待所有线程执行完毕之后,打印race的最终值
    System.out.println(race);
}

private static void increase() {
    race++;
}
}

代码1 volatile 线程安全测试

程序的执行结果如图2所示。
volatile 线程安全测试
图2 volatile 线程安全测试

这个程序执行的结果应该是20000,可实际执行所得到的结果只有18439.并且多次执行本程序都会得到不一样的结果。所以会出现这种情况的原因是 race++ 这个操作并不是原子操作,volatile关键字只能保证在使用race变量时,工作线程能够从主内存中拿取最新的race的值,并不能保证进行过++操作之后写回到主内存期间其它线程没有对race变量进行修改。
volatile关键字只能保证可见性,只有在以下两种条件下,我们可以认为volatile是线程安全的;

  1. 运算结果并不依赖变量的当前值,或者能够保证只有一个线程修改变量的值。
  2. 变量不需要与其它的变脸共同参与不变约束。

    当不符合这两种条件时,想要保证volatile变量的原子性,只能通过 机制来实现。

    volatile变量的第二个重要作用是禁止编译器进行指令重排序。普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。从硬件层面上来讲,是指CPU允许多条指令不按程序规定的顺序分开发送到个相应的电路单元来处理。CPU必须要正确的处理指令的依赖情况,不能将指令任意的排序。比如指令1把地址A中的值加10,指令2把地址A中的值乘以2,指令3将地址B中的值减去3。在这一系列指令中,指令1,2之间是有依赖关系的,不能进行重排序。但指令3的执行顺序可以改到指令1之前也可以放到指令1之后,只要能够保证在执行完毕这三条指令程序中A,B的值是正确的即可。volatile在某些情况下性能高于锁,由于虚拟机对锁实行有多种消除和优化,我们很难衡量volatile会比锁快多少。

原子性、可见性和有序性

20170803 23:11:51 原子性、可见性和有序性
JAVA内存模型的核心问题时为了解决多线程应用中的原子性、可见性与有序性的问题,保证了这三点才能使多线程应用有数据安全性可言。

  • 原子性 (Atomicity)
    由java内存模型来直接保证原子性的操作包括read、load、assign、use、store和write。对于一个变量的基本读写操作都是具有原子性的。如果需要对多种这几种基本读写操作的操作集合保证原子性,可以使用lock和unlock。
  • 可见性(Visibility)
    可见性是指当变量的值被一个线程修改了之后,其它线程能够立即知道这个修改。JAVA内存模型时通过变量内容修改后立刻同步到主内存,读取变量的值之前先从内存刷新这种方式来保证可见性。JAVA中除了volatile之外还有synchronized、final两个关键字能够实现内存可见性。synchronized的可见性是由”对一个变量执行unlock操作之前,必须先把此变量同步会主内存中(执行store,write操作)”这条规则获得的。final的可见性是由于被final修饰的变量在构造器中一旦被初始化完成,并且构造器没有吧this的引用传递出去(this引用是一件非常危险的事,其它线程可能通过这个引用访问到初始化一半的对象),,那在其它线程中就能看见final变量的值。
  • 有序性
    如果在本线程内观察,所有的操作都是有序的,如果在一个线程中观察另外一个线程,所有的操作都是无序的。前半句指的是“线程内表现为串行的语义”(Within-Thread As-If-Serial Semantics),后半句指的是“指令重排序”现象和“工作内存与主内存同步延迟”现象。

先行发生原则

20170804 02:02:23 先行发生原则

先行发生原则(Happens-before)是JAVA内存模型中定义的两项操作之间的偏序关系,如果操作A先行发生于操作B,操作A造成的影响就能被操作B观察到。JAVA只能怪的先行发生原则无需任何同步协助,可以直接编码使用。以下列出JAVA中的先行发生原则,如果两个操作之间的关系不在下面的规则中,且无法通过下面的规则推导出来,虚拟机就能随意的对他们进行指令重排序。

  • 程序次序规则(Program Order Rule)
    :在一个线程内,按照程序的代码顺序,书写在前的操作先于书写在后面的操作发生。准确的说,应该是控制流顺序而不是程序代码顺序,因为需要考虑分支、循环等结构。

  • 管程锁定规则(Monitor Lock Rule)
    : 一个unlocak操作先行发生于后面对同一个锁的lock操作。

  • volatile变量规则(Volatile Variable Rule)
    :对一个volatile的写操作,先行发生于后面对这个变量的读操作。

  • 线程启动规则(Thread Start Rule)
    :线程的start方法先行发生于此线程的每一个动作。

  • 线程终止规则(Thread Termination Rule)
    : 线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.jion()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。

  • 线程中断原则(Thread Interruption Rule)
    :对线程的interrupt()方法的调用先行发生于被中断的线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。

  • 对象终结规则(Finalizer Rule)
    :一个对象的初始化完成(够构造函数执行结束)先行发生于它的fnalize()方法开始。

  • 传递性(Transitivity)
    :如果A操作先行发生于B操作,B操作先行发生于C操作,就可以得出A先行发生于C的结论。

    时间先后顺序与先行发生原则之间基本没有关系,所以当我们需要衡量并发安全问题的时候不要受到时间顺序的干扰,一切必须以先行发生原则为准。

    20170804 23:59:46 先行发生原则

引用

本文是对class文件的学习笔记,笔记的内容并非是原创,而是大量参考其它资料。在写作本文的过程中引用了以下资料,为为在此深深谢过以下资料的作者。

  1. 《The Java Virtual Machine Specification》
  2. 《深入理解Java虚拟机:JVM高级特性与最佳实践/周志明著.——2版.——北京:机械工业出版社,2013.6》

关于

原文链接 https://weiwei02.github.io/2017/07/02/jvm/001-java%E5%86%85%E5%AD%98%E6%A8%A1%E5%9E%8B/
我的github: https://github.com/weiwei02/
我相信技术能够改变世界。

评论和共享

关于MapReduce

MapReduce是一种可用于数据处理的编程模型。MapReduce程序本质上是并行运行的,
因此可以将大规模的数据分析任务分发给任何一个拥有足够多机器的数据中心,充分利用Hadoop
提供的并行计算的优势。

使用Hadoop来分析数据

MapReduce任务过程分为两个处理阶段:map阶段和reduce阶段。每个阶段都以键/值对作为
输入和输出,其类型由程序员来选择。程序员还需要写两个函数,map函数和reduce函数.
示例取自《Hadoop权威指南-第三版》

1. 创建查找最高气温的Mapper类

package com.hadoopbook.ch02;

import org.apache.hadoop.io.IntWritable;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Mapper;

import java.io.IOException;

/**
 * [@author](https://my.oschina.net/arthor) WangWeiwei
 * [@version](https://my.oschina.net/u/931210) 1.0
 * [@sine](https://my.oschina.net/mysine) 17-2-4
 * 查找最高气温的mapper类
 */
public class MaxTemperatureMapper extends  Mapper<LongWritable,Text,Text,IntWritable> {
    private static final int MISSING = 9999;

    @Override
    protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException {
        String line = value.toString();
        String year = line.substring(15,19);
        int airTemperature;
        if (line.charAt(87) == '+'){
            // parseInt doesn't like leading plus signs
            airTemperature = Integer.parseInt(line.substring(88, 92));
        }else {
            airTemperature = Integer.parseInt(line.substring(87, 92));
        }
        String quality = line.substring(92, 93);
        if (airTemperature != MISSING && quality.matches("[01459]")) {
            context.write(new Text(year), new IntWritable(airTemperature));
        }
    }

    public MaxTemperatureMapper() {
        super();
    }
}

这个mapper类是一个泛型类型,它有四个形参类型,分别指定map函数的输入键/输入值/输出键和输出值的类型。
Hadoop本身提供了一套可优化网络序列化传输的基本类型,而不是直接使用java内嵌的类型。这些类型都在
org.apache.hadoop.io包里。
map() 方法的输入是一个键和一个值,方法还提供了context实例用于输出内容的写入。

2. 查找最高气温的reducer类

类似于上的方法,使用Reducer来定义reduce函数.

package com.hadoopbook.ch02;

import org.apache.hadoop.io.IntWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Reducer;

import java.io.IOException;

/**
 * [@author](https://my.oschina.net/arthor) WangWeiwei
 * @version 1.0
 * @sine 17-2-4
 * 查找最高气温的Reducer类
 */
public class MaxTemperatureReducer extends Reducer<Text,IntWritable,Text,IntWritable> {
    public MaxTemperatureReducer() {
        super();
    }

    @Override
    protected void reduce(Text key, Iterable<IntWritable> values, Context context) throws IOException, InterruptedException {
        int maxValue = Integer.MIN_VALUE;
        for (IntWritable value : values){
            maxValue = Math.max(maxValue, value.get());
        }
        context.write(key,new IntWritable(maxValue));
    }
}

同样,reduce函数也有四个形式参数类型用于指定输入和输出类型。reduce函数的输入类型必须匹配map函数
的输出类型:即TEXT类型和IntWritable类型。

3. MapReduce作业

指定一个作业对象,在这个应用中用来在气象数据集中找出最高气温

package com.hadoopbook.ch02;

import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.IntWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;

/**
 * @author WangWeiwei
 * @version 1.0
 * @sine 17-2-4
 * MaxTemperature Application to find the maximum temperature in the weather dataset
 */
public class MaxTemperature {
    public static void main(String[] args) throws Exception{
        if (args.length != 2){
            System.err.println("Usage: MaxTemperature <input path> <output path>");
            System.exit(-1);
        }

        Job job = new Job();
        job.setJarByClass(MaxTemperature.class);
        job.setJobName("Max Temperature");

        FileInputFormat.addInputPath(job,new Path(args[0]));
        FileOutputFormat.setOutputPath(job,new Path(args[1]));

        job.setMapperClass(MaxTemperatureMapper.class);
        job.setReducerClass(MaxTemperatureReducer.class);

        job.setOutputKeyClass(Text.class);
        job.setOutputValueClass(IntWritable.class);

        System.exit(job.waitForCompletion(true) ? 0 : 1);
    }
}

Job对象指定作业的执行规范。我们可以使用它来控制整个作业的运行。我们在Hadoop集群上运行这个作业时,
要把它打包成一个JAR文件(Hadoop在集群上发布这个文件)。不必明确指定JAR文件的名称,在Job对象的
setJarByClass方法中传递一个类即可,Hadoop利用这个类来查找包含它的JAR文件,进而找到相关的JAR文件。

构造Job对象之后,需要指定输入和输出数据路径。调用FileInputFormat类的静态方法addInputPath()
来定义输入数据的路径,这个路径可以是单个文件/一个目录(此时目录下的所有文件当作输入)或符合特定文件模式的一系列文件。
由函数名可知,可以多次调用addInputPath()方法。

调用FIleOutFormat类中的静态方法setOutputPath()来指定输出路径,只能有一个输出路径。这个方法指定的是
reduce函数输出文件的写入目录。在运行作业前该目录是不应该存在的,否则Hadoop会报错,并拒绝运行作业。
这种预防措施的目的是防止数据丢失(长时间运行的作业如果结果被意外覆盖,肯定是非常恼人的)

接着通过setMapperClass()和setReducerClass()指定map类型和reduce类型。

setOutputKeyClass() 和 setOutputValueClass() 控制map和reduce函数的输出类型。

在设置定义map和reduce函数的类后,可以开始运行作业。job中的waitForCompletion()方法返回一个布尔值。

评论和共享

Copyrights © 2017 weiwei02. All Rights Reserved. github空间地址: https://weiwei02.github.io/ 国内空间地址: https://weiwei02.cording.me/
作者的图片

weiwei02

技术,改变世界


软件工程师


北京,海淀
国外主站 国内主站