MapReduce调度与执行原理之任务执行(一)

前言 :本文旨在理清在Hadoop中一个MapReduce作业(Job)在提交到框架后的整个生命周期过程,权作总结和日后参考,如有问题,请不吝赐教。本文不涉及Hadoop的架构设计,如有兴趣请参考相关书籍和文献。在梳 理过程中,我对一些感兴趣的源码也会逐行研究学习,以期强化基础。
作者 :Jaytalent
开始日期 :2013年9月9日
参考资料:【1】《Hadoop技术内幕--深入解析MapReduce架构设计与实现原理》董西成
                  【2】   Hadoop 1.0.0 源码
                            【3】《Hadoop技术内幕--深入解析Hadoop Common和HDFS架构设计与实现原理》蔡斌 陈湘萍
一个MapReduce作业的生命周期大体分为5个阶段 【1】
1. 作业提交与初始化
2. 任务调度与监控
3. 任务运行环境准备
4. 任务执行
5. 作业完成
这篇文章将任务的准备、执行到整个作业完成的过程进行研究。

一、任务启动

任务启动过程由TaskTracker完成。启动过程如下:首先判断是否是第一次收到某个作业的任务,若是就进行作业本地化,然后创建任务目录,否则直接创建任务目录。接下来启动JVM,并从TaskTracker获取任务,进行任务本地化并执行任务。任务执行后,若该JVM没有到重用次数上限,则再次从TaskTracker获取任务,重复上述过程。总体上讲,主要包括作业本地化、任务本地化和启动任务三步。本地化的目的是为任务运行创建一个环境,包括工作目录、下载运行所需文件和设置环境变量等。作业本地化由该作业第一个任务启动时执行。下面依次来看上述步骤。

1. 作业本地化

我们先从TaskTracker(以下简称TT)收到心跳响应说起。在TT的offerService方法中,通过transmitHeartbeat方法收到心跳响应,并从中提取出需要执行的命令:
HeartbeatResponse heartbeatResponse = transmitHeartBeat(now);
TaskTrackerAction[] actions = heartbeatResponse.getActions();
if (actions != null){ 
  for(TaskTrackerAction action: actions) {
    if (action instanceof LaunchTaskAction) {
      addToTaskQueue((LaunchTaskAction)action);
    } else if (action instanceof CommitTaskAction) {
      CommitTaskAction commitAction = (CommitTaskAction)action;
    }
  }
}
addToTaskQueue方法根据任务类型选择指定的启动器:
  private void addToTaskQueue(LaunchTaskAction action) {
    if (action.getTask().isMapTask()) {
      mapLauncher.addToTaskQueue(action);
    } else {
      reduceLauncher.addToTaskQueue(action);
    }
  }
其中mapLauncher和reduceLauncher是两个TaskLauncher对象,该对象继承于Thread类,即启动每个map或reduce任务各由一个单独的线程完成。
addToTaskQueue方法将任务注册后加入待启动任务列表,并通知等待线程有新任务加入。
    public void addToTaskQueue(LaunchTaskAction action) {
      synchronized (tasksToLaunch) {
        TaskInProgress tip = registerTask(action, this);
        tasksToLaunch.add(tip);
        tasksToLaunch.notifyAll();
      }
    }
在启动线程的run方法中调用startNewTask方法启动一个任务:
  void startNewTask(final TaskInProgress tip) throws InterruptedException {
    Thread launchThread = new Thread(new Runnable() {
      @Override
      public void run() {
        try {
          RunningJob rjob = localizeJob(tip);
          tip.getTask().setJobFile(rjob.getLocalizedJobConf().toString());
          // Localization is done. Neither rjob.jobConf nor rjob.ugi can be null
          launchTaskForJob(tip, new JobConf(rjob.getJobConf()), rjob); 
        } catch (Throwable e) {
          String msg = ("Error initializing " + tip.getTask().getTaskID() + 
                        ":\n" + StringUtils.stringifyException(e));
          LOG.warn(msg);
          tip.reportDiagnosticInfo(msg);
        }
      }
    });
    launchThread.start();
  }
从上面实现看出,每个一个任务的具体启动过程是由一个单独的线程完成的。这种设计的考虑是:用户应用程序依赖的文件很大,从HDFS上下载的时间会很长,如果串行启动任务必然导致时间过长。
    synchronized (rjob) {
        if (!rjob.localized) {
          while (rjob.localizing) {
            rjob.wait();
          }
          if (!rjob.localized) {
            //this thread is localizing the job
            rjob.localizing = true;
          }
        }
      }
      if (!rjob.localized) {
        Path localJobConfPath = initializeJob(t, rjob, ttAddr);
      }
注意这段代码,每个作业有两个状态:localizing和localized,分别表示作业正在本地化和作业本地化已经完成。当第一个任务启动时,作业处于正在本地化的状态,作业不会等待,直接将localizing设置为true,并执行initializeJob执行作业本地化,最后将localized设置为true;当该作业随后的任务启动时,发现localized为true就不会再进行作业本地化,若发现有个任务正在进行作业本地化,即localizing为true,则等待rjob锁,直到该任务完成作业本地化。若发现该作业没有本地化完成,且localizing为false,仍要将其边为true,以阻塞该作业其他任务。
这样做的好处是在进行作业本地化时,不需要直接对rjob对象加锁,也就不会阻塞TT的其他线程,如MapEventsFetcherTread(而只会阻塞其他任务启动线程)。
作业本地化的过程如下(initializeJob内部):
    // save local copy of JobToken file
    final String localJobTokenFile = localizeJobTokenFile(t.getUser(), jobId);
    synchronized (rjob) {
      rjob.ugi = UserGroupInformation.createRemoteUser(t.getUser());

      Credentials ts = TokenCache.loadTokens(localJobTokenFile, conf);
      Token jt = TokenCache.getJobToken(ts);
      if (jt != null) { //could be null in the case of some unit tests
        getJobTokenSecretManager().addTokenForJob(jobId.toString(), jt);
      }
      for (Token token : ts.getAllTokens()) {
        rjob.ugi.addToken(token);
      }
    }
这一步将凭据文件JobToken和作业描述文件下载到私有文件目录中,该目录为一个用户独有,任务启动脚本也在该目录下。
    FileSystem userFs = getFS(jobFile, jobId, conf);
    // Download the job.xml for this job from the system FS
    final Path localJobFile =
      localizeJobConfFile(new Path(t.getJobFile()), userName, userFs, jobId);
这一步将作业描述文件job.xml下载到私有目录中。然后调用taskController完成剩下的初始化工作:
   taskController.initializeJob(t.getUser(), jobId.toString(), 
            new Path(localJobTokenFile), localJobFile, TaskTracker.this,
            ttAddr);
该方法负责创建作业相关的目录文件,目录主要包括:所有用户共享缓存目录、用户内部共享缓存目录、作业目录、所有任务的共享目录、作业jar文件目录和日志目录等;文件有作业配置文件、作业凭据文件和作业访问控制配置文件等。
至此,作业本地化结束。

2. 启动任务

任务启动过程可分为两步:JVM启动和任务启动。为了避免不同任务间互相干扰,TT为每个任务启动了独立的JVM。JVM的启动过程如下:
map和reduce各自启动TaskRunner,在run方法中,首先获取工作目录,环境变量,启动命令,命令参数和标准输入输出对象等:
final File workDir =
      new File(new Path(localdirs[rand.nextInt(localdirs.length)], 
          TaskTracker.getTaskWorkDir(t.getUser(), taskid.getJobID().toString(), 
          taskid.toString(),
          t.isTaskCleanupTask())).toString());

List classPaths = getClassPaths(conf, workDir,
                                              taskDistributedCacheManager);

Vector vargs = getVMArgs(taskid, workDir, classPaths, logSize);

String setup = getVMSetupCmd();

 File[] logFiles = prepareLogFiles(taskid, t.isTaskCleanupTask());
      File stdout = logFiles[0];
      File stderr = logFiles[1];

List  setupCmds = new ArrayList();
  for(Entry entry : env.entrySet()) {
        StringBuffer sb = new StringBuffer();
        sb.append("export ");
        sb.append(entry.getKey());
        sb.append("=\"");
        sb.append(entry.getValue());
        sb.append("\"");
        setupCmds.add(sb.toString());
  }
setupCmds.add(setup);
然后通过JVMManager启动一个JVM:
launchJvmAndWait(setupCmds, vargs, stdout, stderr, logSize, workDir);
注意,JvmManager使用不同的管理器管理不同类型的任务对应JVM:
  public void launchJvm(TaskRunner t, JvmEnv env
                        ) throws IOException, InterruptedException {
    if (t.getTask().isMapTask()) {
      mapJvmManager.reapJvm(t, env);
    } else {
      reduceJvmManager.reapJvm(t, env);
    }
  }
接下来以map任务为例,看JVM的启动过程,进入reapJvm方法:
该方法中,通过一个标志spawnNewJvm来表示是否需要启动新的JVM,默认是不需要。判断是否启动新JVM的依据是当前已经启动的JVM数量是否到达最大的slot数量:
boolean spawnNewJvm = false;
if (numJvmsSpawned >= maxJvms) {
...
}else {
    spawnNewJvm = true;
}
若slot不足,则不能启动新的JVM。那么遍历已经启动的JVM列表,找到一个属于当前任务所在作业的JVM。注意,每个JVM只能重用给同一个作业同种类型的任务,且JVM的ID中体现了作业ID和任务类型。当找到一个符合要求的JVM且该JVM处于空闲状态且该JVM未到达复用次数上限(ranAll方法),立即返回:
          JvmRunner jvmRunner = jvmIter.next().getValue();
          JobID jId = jvmRunner.jvmId.getJobId();
          //look for a free JVM for this job; if one exists then just break
          if (jId.equals(jobId) && !jvmRunner.isBusy() && !jvmRunner.ranAll()){
            setRunningTaskForJvm(jvmRunner.jvmId, t); //reserve the JVM
            return;
          }
如果当前JVM不符合条件,则判断其是否达到复用次数上限并且与新任务同属于一个作业,或者,它处于空闲状态但是与新任务不属于一个作业。若上述两个条件有一个成立,就杀掉该JVM,并启动新的JVM:
 if ((jId.equals(jobId) && jvmRunner.ranAll()) ||
              (!jId.equals(jobId) && !jvmRunner.isBusy())) {
            runnerToKill = jvmRunner;
            spawnNewJvm = true;
          }
 if (spawnNewJvm) {
        if (runnerToKill != null) {
          LOG.info("Killing JVM: " + runnerToKill.jvmId);
          killJvmRunner(runnerToKill);
        }
        spawnNewJvm(jobId, env, t);
        return;
      }
真正的启动是由JvmRunner来完成的。该类是一个线程类,在run方法中调用了runChild方法:
      public void runChild(JvmEnv env) throws IOException, InterruptedException{
        int exitCode = 0;
        try {
          env.vargs.add(Integer.toString(jvmId.getId()));
          TaskRunner runner = jvmToRunningTask.get(jvmId);
          if (runner != null) {
            Task task = runner.getTask();
            //Launch the task controller to run task JVM
            String user = task.getUser();
            TaskAttemptID taskAttemptId = task.getTaskID();
            String taskAttemptIdStr = task.isTaskCleanupTask() ? 
                (taskAttemptId.toString() + TaskTracker.TASK_CLEANUP_SUFFIX) :
                  taskAttemptId.toString(); 
                exitCode = tracker.getTaskController().launchTask(user,
                    jvmId.jobId.toString(), taskAttemptIdStr, env.setup,
                    env.vargs, env.workDir, env.stdout.toString(),
                    env.stderr.toString());
          }
        } catch (IOException ioe) {
        } finally { // handle the exit code
          // although the process has exited before we get here,
          // make sure the entire process group has also been killed.
          kill();
          updateOnJvmExit(jvmId, exitCode);
          deleteWorkDir(tracker, firstTask);
        }
      }
最终,调用TaskController的launchTask对象启动任务。这里TaskController的实现是DefaultTaskController。在launchTask方法中,首先创建任务工作目录和日志目录:
      // create the working-directory of the task 
      if (!currentWorkDirectory.mkdir()) {
        throw new IOException("Mkdirs failed to create " 
                    + currentWorkDirectory.toString());
      }
      
      //mkdir the loglocation
      String logLocation = TaskLog.getAttemptDir(jobId, attemptId).toString();
      if (!localFs.mkdirs(new Path(logLocation))) {
        throw new IOException("Mkdirs failed to create " 
                   + logLocation);
      }
然后将任务启动命令写入启动脚本文件:
      // get the JVM command line.
      String cmdLine = 
        TaskLog.buildCommandLine(setup, jvmArguments,
            new File(stdout), new File(stderr), logSize, true);

      // write the command to a file in the
      // task specific cache directory
      // TODO copy to user dir
      Path p = new Path(allocator.getLocalPathForWrite(
          TaskTracker.getPrivateDirTaskScriptLocation(user, jobId, attemptId),
          getConf()), COMMAND_FILE);

      String commandFile = writeCommand(cmdLine, rawFs, p);
最后以命令“bash -c taskjvm.sh”启动任务:
      shExec = new ShellCommandExecutor(new String[]{
          "bash", "-c", commandFile},
          currentWorkDirectory);
      shExec.execute();
从脚本文件中可以看出,任务最终通过org.apache.hadoop.mapred.Child类运行,在main方法中有一个while循环,不停地向TaskTracker请求任务:
      JvmTask myTask = umbilical.getTask(context);
        if (myTask.shouldDie()) {
          break;
        } else {
          if (myTask.getTask() == null) {
            taskid = null;
            currentJobSegmented = true;

            if (++idleLoopCount >= SLEEP_LONGER_COUNT) {
              //we sleep for a bigger interval when we don't receive
              //tasks for a while
              Thread.sleep(1500);
            } else {
              Thread.sleep(500);
            }
            continue;
          }
        }
若获取到任务,则要判断该任务所属作业是否不存在或被杀死。若没有获取到任务,则等待一段时间再询问。如果有了新任务,则进行任务本地化:首先根据作业的配置文件生成任务自己的配置文件;然后在工作目录中建立指向分布式缓存中所有数据文件的链接。
    final JobConf job = new JobConf(task.getJobFile());
    ...
    //setupWorkDir actually sets up the symlinks for the distributed
    //cache. After a task exits we wipe the workdir clean, and hence
    //the symlinks have to be rebuilt.
    TaskRunner.setupWorkDir(job, new File(cwd));
最后调用Task的run方法开始执行任务:
taskFinal.run(job, umbilical);        // run the task
至此任务启动完毕,任务开始执行。

二、任务执行

1. 执行过程概述

我们知道MapReduce中主要有两种任务:Map任务和Reduce任务。这一小节结合代码来详细分析这两种任务的执行过程。总的来说,一个Map任务又可以细分为5个阶段:Read,Map,Collect,Spill和Combine;一个Reduce任务也可以细分为5个阶段:Shuffle,Merge,Sort,Reduce和Write。
具体地,对于Map任务,首先通过用户提供的InputFormat将InputSplit解析成key/value对,然后交给map函数处理。接着使用Partitioner组件对map函数的输出进行分片,确定将每个键值对交给哪个Reduce任务进一步处理。然后将键值对及其分片信息写到缓冲区中,当缓冲区快满时,执行spill操作,将数据排序后写入磁盘。最后将磁盘上的数据合并成一个输出文件。
对于Reduce任务,首先通过HTTP请求获取来自各个Map任务输出文件中属于自己分片的数据,边拷贝边合并。完成拷贝后,按照key对数据进行一次排序。然后将数据交给reduce函数处理。最后,将处理结果输出到磁盘。
过程图解如下(图片来自: http://www.myexception.cn/open-source/428094.html):
MapReduce调度与执行原理之任务执行(一)_第1张图片
下面详细分析上述过程。

2. Map任务执行过程

假设使用的旧MapReduce API,那么MapTask的run方法会调用runOldMapper方法:
    if (useNewApi) {
      runNewMapper(job, splitMetaInfo, umbilical, reporter);
    } else {
      runOldMapper(job, splitMetaInfo, umbilical, reporter);
    }
runOldMapper方法生成InputSplit对象,再通过一个RecordReader对象,从各个InputSplit对象中读取数据,并解析成key/value对,传递给map函数:
  public void run(RecordReader input, OutputCollector output,
                  Reporter reporter)
    throws IOException {
    try {
      // allocate key & value instances that are re-used for all entries
      K1 key = input.createKey();
      V1 value = input.createValue();
      
      while (input.next(key, value)) {
        // map pair to output
        mapper.map(key, value, output, reporter);
        if(incrProcCount) {
          reporter.incrCounter(SkipBadRecords.COUNTER_GROUP, 
              SkipBadRecords.COUNTER_MAP_PROCESSED_RECORDS, 1);
        }
      }
    } finally {
      mapper.close();
    }
  }
这里的mapper对象是使用用户指定的MapperClass,通过反射机制生成的。其中第行的语句执行map函数。注意,map的参数中有一个output,它的类型是OutputCollector,作用是生成map的输出。该对象的实例化如下:
    MapOutputCollector collector = null;
    if (numReduceTasks > 0) {
      collector = new MapOutputBuffer(umbilical, job, reporter);
    } else { 
      collector = new DirectMapOutputCollector(umbilical, job, reporter);
    }
    runner.run(in, new OldOutputCollector(collector, conf), reporter);
collector完成map任务的collect过程。如果一个作业有reduce过程,那么就是用MapOutputBuffer输出map结果,由reduce任务进一步处理;如果该作业只有map过程,那么就使用DirectMapOutputCollector来输出到HDFS。collector对象以参数的形式构造一个OldOutputCollector对象,后者再作为run方法的参数最终交给map函数。
collect方法由用户在map函数中调用,比如:
collector.collect(key, value, partitioner.getPartition(key, value, partitions));
collect方法接受需要输出的key和value值作为参数。在执行collect之前,通过Partitioner类的getPartition方法获得一对儿key/value所属分区,一个分区对应一个reduce任务处理的数据,分区数即为reduce任务的个数。分区的目的是将map任务的结果尽量均衡地分配给每个reduce任务。默认的分区方法是hash法:
  public int getPartition(K key, V value,
                          int numReduceTasks) {
    return (key.hashCode() & Integer.MAX_VALUE) % numReduceTasks;
  }
分区算法的思想是负载均衡,MapReduce中还有一种实现是TotalOrderPartitioner。该实现基于数据采样,通过某种采样方法获得若干代表性的key值并排序,然后将这些采样数据分割成几等份,数量为reduce任务数。将分割点的key值保存在一个trie树(字典树)中,这样通过搜索该trie树即可确定一个key属于哪个分区。综上,该实现的关键在于采样算法,采样越具有代表性,负载越均衡。
分区结束后,一个key/value二元组变成一个三元组。collect方法接下来将三元组写入MapOutputBuffer的一个环形缓冲区中。在缓冲区使用量达到一定阈值时,spillThread将数据写入临时文件,这是spill过程。环形缓冲区的使用使得collect和spill过程可以并发执行,事实上,collect和spill分别是缓冲区的生产者和消费者,稍后在代码中可以看到。
缓冲区的大小可以通过io.sort.mb参数配置,默认是100MB。MapOutputBuffer的缓冲区采用二级索引机制,对应三级缓冲区。第三级级称为kvbuffer,存储的是具体的key/value值。第二级称为kvindices,其中每一个元素为一个三元组,每一元分别表示键值对所属分区,key在kvbuffer中的开始位置和value在kvbuffer中的开始位置。第一级称为kvoffsets,保存键值对信息(三元组)在kvindices中的偏移位置。由于kvoffsets每个元素只是一个整数,而kvindices的每个元素要占用三个整数,所以二者内存分配比例为1:3。为这两个数组分配的内存总大小的比例为io.sort.record,percent。kvbuffer默认最多使用95%的缓冲区。各部分缓冲区的分配如下:
      final float spillper = job.getFloat("io.sort.spill.percent",(float)0.8);
      final float recper = job.getFloat("io.sort.record.percent",(float)0.05);
      final int sortmb = job.getInt("io.sort.mb", 100);
      if (spillper > (float)1.0 || spillper < (float)0.0) {
        throw new IOException("Invalid \"io.sort.spill.percent\": " + spillper);
      }
      if (recper > (float)1.0 || recper < (float)0.01) {
        throw new IOException("Invalid \"io.sort.record.percent\": " + recper);
      }
      if ((sortmb & 0x7FF) != sortmb) {
        throw new IOException("Invalid \"io.sort.mb\": " + sortmb);
      }
      sorter = ReflectionUtils.newInstance(
            job.getClass("map.sort.class", QuickSort.class, IndexedSorter.class), job);
      LOG.info("io.sort.mb = " + sortmb);
      // buffers and accounting
      int maxMemUsage = sortmb << 20;
      int recordCapacity = (int)(maxMemUsage * recper);
      recordCapacity -= recordCapacity % RECSIZE;
      kvbuffer = new byte[maxMemUsage - recordCapacity];
      bufvoid = kvbuffer.length;
      recordCapacity /= RECSIZE;
      kvoffsets = new int[recordCapacity];
      kvindices = new int[recordCapacity * ACCTSIZE];
下面看看数据写入缓冲区的过程。
1. kvoffsets的写入。
这里定义了几个指针:kvstart表示存有数据内存段的初始位置,kvindex表示未存储数据内存段的初始位置,kvend用于spill时指示需要写入磁盘的范围为[kvstart, kvend),此时kvend=kvindex,而正常写入时kvstart=kvend。
下一个写入位置即为kvindex,则确定下一个kvindex位置:
final int kvnext = (kvindex + 1) % kvoffsets.length;
同时还要判断是否满足spill条件:
    kvfull = kvnext == kvstart;
    final boolean kvsoftlimit = ((kvnext > kvend)
        ? kvnext - kvend > softRecordLimit
        : kvend - kvnext <= kvoffsets.length - softRecordLimit);
    if (kvstart == kvend && kvsoftlimit) {
      LOG.info("Spilling map output: record full = " + kvsoftlimit);
      startSpill();
    }
若使用空间超过了80%,则溢写。后面关注startSpill函数。
2. kvbuffer的写入。
操作该缓冲区的指针包括:bufstart,bufend,bufvoid,bufindex,bufmark等。其中bufstart,bufend和bufindex含义与kvstart,kvend和kvindex的含义相同。bufvoid指向kvbuffer中有效内存结束为止,kvmark表示最后写入一个完整的键值对结束的位置。
写入一个key和value后都要移动bufindex指针:
System.arraycopy(b, off, kvbuffer, bufindex, len);
bufindex += len;
一个完整的key/value写完后,要移动bufmark:
bufmark = bufindex;
kvbuffer缓冲区到达阈值在kvoffsets检查时已经发现,执行spill操作,写之前:
bufend = bufmark;
写过后:
bufstart = bufend;
以上是正常情况下的写入,再考虑几种特殊的情况:
首先是达到spill的阈值了,需要溢写:
                final boolean bufsoftlimit = (bufindex > bufend)
                  ? bufindex - bufend > softBufferLimit
                  : bufend - bufindex < bufvoid - softBufferLimit;
                if (bufsoftlimit || (buffull && !wrap)) {
                  LOG.info("Spilling map output: buffer full= " + bufsoftlimit);
                  startSpill();
                }
当写入某个key时,尾部剩余空间不足以容纳key值,则将一部分存储到缓冲区头部:
            if (bufstart <= bufend && bufend <= bufindex) {
              buffull = bufindex + len > bufvoid;
              wrap = (bufvoid - bufindex) + bufstart > len;
            } 
以wrap变量判断是否需要写入头部。但是,由于key是排序的关键字,需要交给RawComparator排序,它要求key在内存中连续存储,因此不能跨行。为解决这个问题,将跨行的key移动到缓冲区头部:
        int headbytelen = bufvoid - bufmark;
        bufvoid = bufmark;
        if (bufindex + headbytelen < bufstart) {
          System.arraycopy(kvbuffer, 0, kvbuffer, headbytelen, bufindex);
          System.arraycopy(kvbuffer, bufvoid, kvbuffer, 0, headbytelen);
          bufindex += headbytelen;
        } else {
          byte[] keytmp = new byte[bufindex];
          System.arraycopy(kvbuffer, 0, keytmp, 0, bufindex);
          bufindex = 0;
          out.write(kvbuffer, bufmark, headbytelen);
          out.write(keytmp);
        }
另外,如果某个key或value太多不能让如缓冲区,则抛出异常,将其输出到一个文件:
                final int size = ((bufend <= bufindex)
                  ? bufindex - bufend
                  : (bufvoid - bufend) + bufindex) + len;
                bufstart = bufend = bufindex = bufmark = 0;
                kvstart = kvend = kvindex = 0;
                bufvoid = kvbuffer.length;
                throw new MapBufferTooSmallException(size + " bytes");
插一句, collect过程在将key写入缓冲区时调用的是keySerializer的serialize方法。那么该方法的内部是如何做的呢?首先keySerializer在初始化时是这样的:
keySerializer = serializationFactory.getSerializer(keyClass);
keySerializer.open(bb);
其中serializationFactory使用WritableSerialization类来生成序列化器。open方法的实现如下:
    public void open(OutputStream out) {
      if (out instanceof DataOutputStream) {
        dataOut = (DataOutputStream) out;
      } else {
        dataOut = new DataOutputStream(out);
      }
    }
也就是通过out参数获得一个DataOutputStream对象dataOut。这里传入的bb为一个BlockingBuffer对象,它继承于DataOutputStream类,并使用MapOutputBuffer的Buffer对象构造。然后serailize方法如下:
public void serialize(Writable w) throws IOException {
      w.write(dataOut);
    }
这样write方法实际上调用的就是Buffer.write方法,并数据写入kvbuffer中,从而Buffer称为缓冲区的生产者。
下篇内容继续Map任务的结束以及Reduce任务的执行过程。



































你可能感兴趣的:(Hadoop技术,Hadoop源码学习研究)