0%

磁盘分区形式

常用的磁盘分区形式如下表所示,并且针对Linux操作系统,不同的磁盘分区形式需要选择不同的分区工具。

磁盘分区形式 支持最大磁盘容量 支持分区数量 Linux分区工具
主启动记录分区(MBR) 2TB
  • 4个主分区
  • 3个主分区和1个扩展分区

  • 说明:
    MBR分区包含主分区和扩展分
    区,其中扩展分区里面可以包
    含若干个逻辑分区。以创建六
    个分区为例,以下两种分区情
    况供参考:
  • 3个主分区,一个扩展分区,
    其中扩展分区包含3个逻辑分
    区。
  • 1个主分区,1个扩展分区,
    其中扩展分区中包含5个逻辑
    分区。
  • 以下两种工具均可以使用:
  • fdisk工具
  • parted工具
  • 全局分区表
    (GPT, Guid Partition Table)
    18EB
    (1EB=1048576TB)
    不限制分区数量
    说明:
    GPT格式下没有主分区、扩展分区以及逻辑分区之分
    parted工具

    注意事项:
    MBR格式分区最大支持的容量是2TB,如果硬盘的容量大于2TB,则必须选择GPT的分区形式,并且使用parted进行分区。当磁盘已经投入使用后,此时切换磁盘分区形式时,磁盘上的原有数据将会被清除,因此请在磁盘初始化时谨慎选择磁盘分区形式。

    使用fdisk工具初始化磁盘(只支持小于2TB的硬盘)

    1. fdisk -l查看新增数据盘的信息,一般可以看到例如/dev/sdb这样的新增盘,主要依据是根据显示的信息中显示的硬盘大小以及是否有分区。
      一般会显示如下信息(以下信息为分区后的情况,若新硬盘会看到一个没有分区的磁盘):

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      磁盘 /dev/sda:1000.2 GB, 1000204886016 字节,1953525168 个扇区
      Units = 扇区 of 1 * 512 = 512 bytes
      扇区大小(逻辑/物理):512 字节 / 4096 字节
      I/O 大小(最小/最佳):4096 字节 / 4096 字节
      磁盘标签类型:dos
      磁盘标识符:0x000c5dbf

      设备 Boot Start End Blocks Id System
      /dev/sda1 2048 125001727 62499840 82 Linux swap / Solaris
      /dev/sda2 * 125001728 1953523711 914260992 83 Linux

      磁盘 /dev/sdb:6001.2 GB, 6001175126016 字节,11721045168 个扇区
      Units = 扇区 of 1 * 512 = 512 bytes
      扇区大小(逻辑/物理):512 字节 / 4096 字节
      I/O 大小(最小/最佳):4096 字节 / 4096 字节
      磁盘标签类型:gpt
      Disk identifier: E9D9D767-8701-4230-BFAC-07F103EBB35A


      \# Start End Size Type Name
      1 2048 11721043967 5.5T Microsoft basic data
    2. fdisk 新增数据盘,例如fdisk /dev/sdb

    3. 输入n,按Enter开始新建分区,这里n代表new

    4. 接下来会让我们选择p还是e,p代表主要分区,e代表延伸分区,对于新增加的硬盘一般选择p

    5. 选择分区号,对于新增硬盘,一般选择1

    6. 选择初始磁柱编号,新增硬盘选择默认的2048即可

    7. 选择截止磁柱区域,选择默认的最大的磁柱区域即可,表示只建立一个分区,这个分区使用了硬盘的所有容量

    8. 输入p,可以看到我们已经新建好的硬盘分区为/dev/sdb1

    9. 输入w,按Enter将分区结果写入分区表

    10. 执行partprobe将新的分区表变更同步至操作系统

    使用parted工具初始化磁盘(可以支持大于2TB的硬盘)

    1. lsblk查看新增数据盘信息,和上一个介绍的工具的fdisk -l的效果是一样的,可以看到例如/dev/sdb这样的新增盘,并且这种方式显示出来的结果更加直观一点,冗余的信息比较少。
      一般会显示如下信息(以下信息为分区后的情况,若新硬盘会看到一个没有分区的磁盘):

      1
      2
      3
      4
      5
      6
      NAME   MAJ:MIN RM   SIZE RO TYPE MOUNTPOINT
      sda 8:16 0 931.5G 0 disk
      ├─sda1 8:17 0 59.6G 0 part
      └─sda2 8:18 0 871.9G 0 part /
      sdb 8:0 0 5.5T 0 disk
      └─sdb1 8:1 0 5.5T 0 part /mnt/data
    2. parted 新增数据盘,例如parted /dev/sdb

    3. 输入p,按Enter查看当前磁盘的分区形式

    4. 输入以下命令,设置磁盘分区形式。mklabel 磁盘分区形式,这里磁盘分区形式可以选择GPT和MBR,当然,大于2TB的硬盘只能选择GPT

    5. 输入unit s,按Enter,设置磁盘的计量单位为磁柱

    6. 以为整个磁盘创建一个分区为例,输入mkparted data 2048s 100%,按Enter。这里data为分区名称,2048s表示初始磁柱位置,100%表示从初始磁柱位置开始,占用100%的磁盘容量进行新建分区

    7. 输入q,按Enter,退出parted

    格式化数据盘及挂载数据盘

    数据盘分区好之后是不能直接挂载的,会显示unknown filesystem,需要进行格式化指定一个磁盘文件格式,以ext4格式为例,使用如下指令进行格式化:

    1
    mkfs -t ext4 /dev/sdb1

    之后便可以新建一个文件夹,例如/mnt/data将新硬盘挂载到该文件夹下,输入如下指令:

    1
    mount /dev/sdb1 /mnt/data

    这样做只能即使生效,重启后需要重新输入指令进行挂载,十分不方便,可以将磁盘挂载写入到/etc/fstab文件当中,这样就可以省去每次开机挂载的繁琐操作。首先使用blkid /dev/sdb1来查看新增分区的UUID号是多少,复制UUID,编辑/etc/fstab文件,在末尾加入一行:

    1
    UUID=1851e23f-1c57-40ab-86bb-5fc5fc606ffa /mnt/data      ext4 defaults     0   2

    ansible是一个自动化运维非常重要的工具,对于做大数据开发的人来说,ansible是一个管理集群的利器,我们可以用它来在集群中批量执行一些指令,而不需要对服务器一台一台进行操作。ansible的功能非常多,但是对于我来说一般只用来在集群中批量执行一些命令,例如批量安装软件、批量删除文件、批量新建文件夹等,具体详细的功能可以参考这个中文文档 ansible中文权威指南

    用ansible对集群中文件进行管理

    有时候我们可能需要在集群中批量删除某些文件,例如有时候spark slave节点上的work文件夹中的无用文件太多,又没有被自动清除,我们可以使用如下ansible指令来对文件夹中的文件进行批量删除:

    1
    ansible spark -m shell -a "rm -rf /usr/local/spark/work/*"

    这样做确实能正确将work文件夹中的内容都删除掉,但是会收到一个警告信息:

    1
    2
    [WARNING]: Consider using the file module with state=absent rather than running rm.  If you need to use command because file is insufficient you can add warn=False to this command task or set command_warnings=False in ansible.cfg to get
    rid of this message.

    其实,这里-m后面接的参数就是使用的module,这里用了ansible的shell模块在每台机器上运行shell command进行删除操作,warning中也告诉了我们,ansible中有一个file模块可以用来做文件处理的操作。

    file模块

    file模块的功能非常多,官方文档是这样描述的:

    1
    Sets attributes of files, symlinks, and directories, or removes files/symlinks/directories. Many other modules support the same options as the `file' module - including [copy], [template], and [assemble].

    我们可以使用ansible-doc file来查看file模块的详细使用文档(精简版可以使用ansible-doc -s file),这里举几个常用的例子来介绍file模块的使用。

    file模块删除文件或目录

    正如前面所说的删除文件的方法,用file模块的使用方法是:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    $ ansible spark -m file -a "path=/usr/local/spark/work/* state=absent"
    dell-r730-2 | SUCCESS => {
    "changed": false,
    "path": "/usr/local/spark/work/*",
    "state": "absent"
    }
    dell-r730-4 | SUCCESS => {
    "changed": false,
    "path": "/usr/local/spark/work/*",
    "state": "absent"
    }
    dell-r730-3 | SUCCESS => {
    "changed": false,
    "path": "/usr/local/spark/work/*",
    "state": "absent"
    }
    dell-r730-1 | SUCCESS => {
    "changed": false,
    "path": "/usr/local/spark/work/*",
    "state": "absent"
    }

    这样就能达到将/usr/local/spark/work目录中的app-xxx目录删除的目的,这里-m参数后面指定了使用file模块,-a参数后面为模块的参数,指定了文件或目录的路径path,以及状态state=absent,表示将指定的path进行删除处理。

    file模块新建一个文件或目录

    1
    ansible spark -m file -a "path=/usr/local/spark/conf/slaves state=touch"

    这里state=touch表示新建文件,而state=directory则表示新建目录。这里也可以是用mode来指定创建的文件或目录的权限mode='u=rw,g=r,o=r'或者mode=0755

    file模块递归设置文件的属主或属组

    1
    ansible spark -m file -a "path=/usr/local/spark owner=spark group=spark recurse=yes"

    owner设置属主,group设置属组,recurse设置是否对目录进行递归进行设置。

    在windows上制作Linux的U盘启动盘非常容易,有很多工具,比如软碟通UltralISO、deepin深度启动盘制作工具等,只要正确使用这些工具就能很容易地制作一个U盘启动盘。

    换了Mac电脑后,有时候要给服务器装Linux操作系统,还是找一台windows电脑来制作U盘启动盘,十分不方便。其实用Mac也能很容易地制作U盘启动盘,并且不需要借助什么工具,用dd命令就能完成U盘启动盘的制作。

    步骤

    1. 插入U盘,使用diskutil指令来查看U盘的挂载点

      1
      $ diskutil list

      系统输出类似如下的内容

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      # joey @ joey-Mac in ~ [9:45:27]
      $ diskutil list
      /dev/disk0 (internal):
      #: TYPE NAME SIZE IDENTIFIER
      0: GUID_partition_scheme 24.0 GB disk0
      1: EFI EFI 314.6 MB disk0s1
      2: Apple_APFS 23.6 GB disk0s2

      /dev/disk1 (internal, physical):
      #: TYPE NAME SIZE IDENTIFIER
      0: GUID_partition_scheme *1.0 TB disk1
      1: EFI EFI 209.7 MB disk1s1
      2: Apple_APFS Container disk2 900.7 GB disk1s2
      3: Microsoft Basic Data BOOTCAMP 99.3 GB disk1s3

      /dev/disk2 (synthesized):
      #: TYPE NAME SIZE IDENTIFIER
      0: APFS Container Scheme - +900.7 GB disk2
      Physical Store disk1s2
      1: APFS Volume CoreStorage Fusion 576.8 GB disk2s1
      2: APFS Volume Preboot 44.0 MB disk2s2
      3: APFS Volume Recovery 512.4 MB disk2s3
      4: APFS Volume VM 6.4 GB disk2s4

      /dev/disk3 (external, physical):
      #: TYPE NAME SIZE IDENTIFIER
      0: FDisk_partition_scheme *15.7 GB disk3
      1: Windows_NTFS joey 15.7 GB disk3s1

      可以看到,我的U盘大小是16GB,对应上面输出的挂载点应该是位于/dev/disk3

    2. 在写入系统镜像前,首先需要umount这个U盘,使用如下指令

      1
      $ diskutil unmountDisk /dev/disk3

      会输出如下内容表示正确unmount

      1
      2
      3
      # joey @ joey-Mac in ~ [9:45:34]
      $ diskutil unmountDisk /dev/disk3
      Unmount of all volumes on disk3 was successful

      此时,Mac的Finder中已经看不到之前挂载上的U盘了,但是使用diskutil list命令还是能够看到U盘的信息

    3. 使用dd指令写入Linux系统镜像到U盘

      1
      $ sudo dd if=/Users/joey/Downloads/CentOS-7-x86_64-DVD-1804.iso of=/dev/disk3 bs=1M

      这里有一点需要注意,有些教程可能写的bs=1m,这里如果你的电脑装了GNU Coreutils,就是那个让ls能够彩色化输出的软件,这里用1m就会报错,报错内容为dd: invalid number: ‘1m’,这里把m大写就能很容易解决这个问题,但是究竟是什么原因造成1m不行的我也不是很清楚,只知道一个解决的办法。

      之后会输出如下,表示写入完成,大概要等几分钟

      1
      2
      3
      4
      5
      6
      # joey @ joey-Mac in ~ [9:49:37] C:1
      $ sudo dd if=/Users/joey/Downloads/CentOS-7-x86_64-DVD-1804.iso of=/dev/disk3 bs=1M
      Password:
      4263+0 records in
      4263+0 records out
      4470079488 bytes (4.5 GB, 4.2 GiB) copied, 446.25 s, 10.0 MB/s

      经过如上几个步骤,用Mac制作的U盘启动盘就制作完成了。

    一些问题

    U盘制作启动盘之后U盘的可能无法被Mac系统读取了,会报一个错误,可见空间也会变的很小,这时候可以重新格式化U盘来进行恢复

    最近在看别人写的shell脚本时看到这样一个sed指令:

    1
    sed -i.bak -e's/\(LOBJS=.*\)/\1 bwtindex.o rle.o rope.o bwt.o is.o/g' bwa/Makefile

    这里面使用了sed 's/要被取代的字符串/新的字符串/g' file的方式来对文本中指定的字符串进行全局替换,这里g代表的是global,之前面试又被问到过sed全局如何替换,当时就是忘记了最后应该要加/g,在这个命令中,新的字符串的内容中有一个\1之前从来没有遇到过,不知道具体的作用是什么,于是就去查找了一些资料进行学习。

    在sed中,要被匹配的字符串可以用正则匹配来进行模式匹配,而用括号括起来的一个正则匹配串可以称为一个模式,而\1-9就是用来指代第一个、第二个、……、第九个模式在匹配到的字符串中的内容。在sed中一共可以记录9个模式,在某些需要保留原有字符串的一部分并添加一部分内容的时候就会很有用。

    举个例子:

    1
    echo abc123 | sed 's/\([a-z]*\)\([0-9]{3}\)/\2\1/g'

    这个例子的输出就是123abc,先来看下正则匹配的内容,一共有2个位于括号中的模式,第一个模式匹配的是出现次数任意多的小写字母,第二个模式匹配的是出现三次的数字,模式都用括号括起来,但是括号要使用反斜杠进行转义。在这个正则匹配中,\1代表的就是第一个模式匹配的内容,即abc,\2代表的是第二个模式匹配的内容,即123,然后替换成什么内容呢?就是交换这两个匹配的模式,把\2放到\1前面,就是123在前面,abc在后面,变成了123abc,就是这样简单,这是一个简单的交换匹配到的内容的例子,我们还可以在模式中插入内容,举例子来说:

    1
    echo abc123 | sed 's/\([a-z]*\)\([0-9]{3}\)/\1xx\2/g'

    这个例子的输出就是在abc和123之间插入了两个字母xx,最终的结果是abcxx123,知道C当中的printf函数的同学可以把这里的\1\2理解成两个占位符,具体的内容由前面匹配到的模式的内容来进行填充,按照正则匹配中的顺序进行记录,最多记录9个。

    这里在说一点题外话,sed -i我们知道是直接在原文件中进行修改,但是-i后面其实是可以加参数的,在最上面那个例子里面就是加了.bak作为-i的参数,这里的意思是,直接在原文件进行修改,但是将原文件保存为filename.bak,即在最后加上.bak进行备份。

    写过JNI代码的同学应该都有遇到过这样的问题,我们可以很方便的使用Maven、Gradle等工具将我们写好的Java代码打包成一个jar包进行发布,但是当我们的代码当中包含有C/C++的代码时,我们可能需要额外再去运行一次make指令去编译C/C++的代码,并将编译出的库和jar包一起发布(在大多数情况下so文件和jar包是分开的)。实际上,我们可以使用Gradle在打包的同时自动编译我们的C/C++代码,同时将so文件打包到jar包之中,这样在运行jar包时不需要指定java.library.path也能正确读取到so文件,并且发布时只有一个文件,更为方便。

    Exec Task

    我们知道,Gradle的执行过程可以分为很多的Task进行,要实现Gradle自动编译C/C++代码我们需要首先了解一下Exec Task。Exec Task是用来执行一个命令行语句的,例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    task stopTomcat(type: Exec) {
    workingDir '../tomcat/bin'

    //on windows:
    commandLine 'cmd', '/c', 'stop.bat'

    //on linux:
    commandLine './stop.sh'

    //store the output instead of printing to the console:
    standardOutput = new ByteArrayOutputStream()

    //extension method stopTomcat.output() can be used to obtain the output:
    ext.output = {
    return standardOutput.toString()
    }
    }

    具体有关这个Task的详细说明可以看这个:https://docs.gradle.org/current/dsl/org.gradle.api.tasks.Exec.html

    使用Exec Task来编译C/C++代码

    当然,Exec Task用来编译C/C++代码使用的是make指令,如果使用的是cmake,也可以先执行cmake再执行make,无非是多加几个Exec Task。这里具体的写法如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    String cpath = "native"
    String libname = "libpairhmm"
    task buildLib(type: Exec) {
    workingDir "$cpath"
    outputs.files "$cpath/libpairhmm*"
    outputs.dir "$cpath/pairhmm"
    commandLine "make all"
    String home = System.properties."java.home"
    //strip the trailing jre
    String corrected = home.endsWith("jre") ? home.substring(0, home.length() - 4) : home
    environment JAVA_HOME : corrected
    doFirst { println "using $home -> $corrected as JAVA_HOME" }
    }

    clean {
    delete "$cpath/pairhmm"
    delete "$cpath/$libname*"
    delete fileTree("$cpath") { include "$libname*", "*.o" }
    }

    processResources {
    dependsOn buildLib
    from cpath
    include "$libname*"
    }

    这里buildLib这个task就是一个Exec Task,其中定义的流程就是执行make all指令来编译C/C++代码,clean的作用就是清除编译产生的lib文件和o文件(在调用gradle clean的时候调用清除工作),processResources即处理资源文件,其作用就是将编译产生的库文件拷贝到jar包当中。

    我们在写Spark程序的时候免不了要对我们的代码进行debug,在代码当中打上断点来查看程序执行过程中各个变量的变化情况。我一般使用Intellij IDEA来写Spark程序,可以直接在其中以local的方式运行Spark程序,也可以在其中打上断点进行调试,但这样做有一些问题:

    1. 我们只能对Spark driver端的程序进行打断点debug;
    2. Spark很多代码都是惰性执行的,很多代码都需要有action才能触发,在这之前打断点没有意义,真正的Task执行逻辑位于Executor当中;
    3. 对于某些运算量比较大或者内存消耗比较多的程序来说,本地电脑不能运行;
    4. 这样只能观察代码在local模式下运行的是否正确,无法对在集群中运行的代码进行调试。

    之前所说的几点问题我在之前一般采用比较低效率的方式来进行debug,即在代码当中加入一些log信息,利用log信息来调试代码,这样当Spark程序在集群中运行时,可以在web UI的Executor的stdout和stderr中查看我们留下的log信息。这样做可以解决一些问题,但是十分低效,debug的信息需要添加改动时都需要重新编译程序,并且打印log信息的方式并不能很完整地观察到所有变量的变化情况。但这样做确实解决了一些问题,比如我们可以对Executor真正执行Task逻辑的代码进行调试,也无需考虑惰性执行的过程,Spark的所有RDD的transform的行为都会反映到每个Executor执行的stdout和stderr信息当中;这些代码都可以在服务器集群当中运行进行调试,不用担心本地电脑性能不够的问题,本地电脑只需要打开浏览器查看Spark的web UI即可,或者使用终端来查看一些信息;可以在集群当中调试代码,不需要局限于local模式下。

    但我始终认为这样的debug方式是低效的,并且不是一个正常的程序员应该有的debug方式,之前有想过肯定有具体的方法来解决这样的调试问题,Intellij IDEA当中功能非常多,肯定有这样的功能来解决这个问题。近期又要写一些Spark程序,并且输入数据非常大,计算量也很大,我在本地电脑上根本没办法debug,于是想到了去查找一些资料来解决远程调试Spark程序的问题。其实Intellij IDEA或者Eclipse这样的IED都会有remote debug这样的功能,以Intellij IDEA为例,在Run-Edit Configurations菜单中我们可以添加一个Remote的Configurations,这就是Intellij IDEA为我们提供的远程调试的功能。这里对Spark程序的调试主要分两种——Driver程序调试和Executor程序调试。

    Driver程序调试

    Driver程序在远程进行调试时,需要在spark-submit的参数中增加一个配置:

    1
    --conf spark.driver.extraJavaOptions=-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=5005
    阅读全文 »

    spark的运行原理在大数据开发岗面试过程中是经常被问到的一个问题,我第一次被问到这个问题的时候有点摸不着头脑,这么大的一个问题我究竟应该怎样回答呢?是去描述一下spark的架构组成还是说一下底层的调用细节?后来查找了一些资料,看了一些书之后对这个问题有了一些理解,其实提这个问题的人可能最希望我们回答的是Spark运行的过程细节,简单来说就是把某个Spark程序从提交到执行完成中间经历了哪些步骤描述出来。如果在描述的过程中能够加入一些对Spark底层源码细节的解释会给提问者留下比较好的印象,认为你不仅仅是停留在使用Spark上,还对底层源码的原理有所了解。

    简单描述Spark的运行原理

    用户使用spark-submit提交一个作业之后,会首先启动一个driver进程,driver进程会向集群管理器(standalone、YARN、Mesos)申请本次运行所需要的资源(这里的资源包括core和memory,可以在spark-submit的参数中进行设置),集群管理器会根据我们需要的参数在各个节点上启动executor。申请到对应资源之后,driver进程就会开始调度和执行我们编写的作业代码。作业会被提交给DAGScheduler,DAGScheduler会根据作业中RDD的依赖关系将作业拆分成多个stage,拆分的原则就是根据是否出现了宽依赖,每个stage当中都会尽可能多的包含连续的窄依赖。每个stage都包含了作业的一部分,会生成一个TaskSet提交给底层调度器TaskScheduler,TaskScheduler会把TaskSet提交到集群当中由executor进行执行。Task的划分是根据数据的partition进行划分,一个partition会划分为一个task。如此循环往复,直至执行完编写的driver程序的所有代码逻辑,并且计算完所有的数据。

    简单的运行流程如下图:

    图一 spark运行流程

    SparkContext

    Spark程序的整个运行过程都是围绕spark driver程序展开的,spark driver程序当中最重要的一个部分就是SparkContext,SparkContext的初始化是为了准备Spark应用程序的运行环境,SparkContext主要是负责与集群进行通信、向集群管理器申请资源、任务的分配和监控等。

    driver与worker之间的架构如下图,driver负责向worker分发任务,worker将处理好的结果返回给driver。

    图二 driver架构

    SparkContext的核心作用是初始化Spark应用程序运行所需要的核心组件,包括高层调度器DAGScheduler、底层调度器TaskScheduler和调度器的通信终端SchedulerBackend,同时还会负责Spark程序向Master注册程序等。Spark应用当中的RDD是由SparkContext进行创建的,例如通过SparkContext.textFile()、SparkContext.parallel()等这些API。运行流程当中提及的向集群管理器Cluster Manager申请计算资源也是由SparkContext产生的对象来申请的。接下来我们从源码的角度学习一下SparkContext,关于SparkContext创建的各种组件,在SparkContext类中有这样一段代码来创建这些组件:

    阅读全文 »

    最近做了一个Java项目,老板让我们将核心部分的代码进行混淆,防止jar包被反编译出来。Java项目是基于Gradle进行构建的,使用了shadowJar这个插件将源码生成的jar包和所有的依赖的jar包打包到一起,称为一个fat-jar。我之前单独使用过proguard的gui,也使用过maven的proguard plugin以及sbt的plugin,都踩了很多坑最终混淆成功了,以为这次应该很轻松能完成任务,但事实上我遇到了很多之前没有遇到过的问题,现在将我解决这个问题的每个阶段记录下来。

    阶段一

    下载了最新的proguard6.0.3,执行proguardgui.sh,图形界面出来之后,写好一个配置文件并load进去,配置文件中将包含依赖的fat-jar作为输入,libraryjars只添加了jre/lib/rt.jar,因为其他库文件都包含在了fat-jar包当中,这样混淆有一个问题就是会去混淆依赖的库,虽然可以通过keep class来保持依赖的库不被混淆,但是proguard还是会去遍历所有的依赖库中的内容,导致混淆的时间非常长。这对于我来说是不能接受的,我现在都还不知道我写的混淆配置文件能不能让混淆后的jar包正常运行,如果测试一次要花这么长时间,肯定是不能按时完成任务的,而且整个调试的过程会非常痛苦。我看了一下jar包有200M左右,但实际上我们源码对应的jar包只有5M左右,其他的内容都是依赖的库,实际上我是不需要去混淆这些依赖,proguard花费时间去遍历这些依赖是没有意义的。

    阶段二

    我先不用shadowJar进行打包,只使用jar任务编译出一个不包含依赖的jar包,只对这个不包含依赖的jar包进行混淆,把其依赖的库通过proguard配置文件中的-libraryjars参数添加进去(不添加进去会出现找不到依赖的库的问题)。这样proguard就只会混淆我们所写的代码,不会涉及到依赖的库,代码很快就混淆完了。混淆后的库文件中包含有MAINIFEST.MF文件,Class-Path中记录了所依赖的库文件的路径,使得独立的jar包也能正常运行。我写的混淆配置文件混淆的力度并不是很大,我以为程序能够正常运行,但是却并没有如我所愿。

    阶段三

    独立混淆的jar包在混淆环节并没有出错,但是执行的时候却遇到了一个很奇怪的问题,我追踪代码发现在某一个地方使用了ClassLoader.getResource(packageName)方法去获取在packageName包下的所有资源,这个方法在jar包没有混淆之前是能正确找到packageName下的所有资源,但是混淆之后这个方法就什么都获取不到了。为了探究原因,我关闭了proguard的所有功能,包括optimize、obfuscate、shrink,相当于不对输入的jar包做任何处理,最后输出的jar包还是会有这个问题。同时,我把混淆前的jar包和不开启proguard任何功能输出的jar包使用JAPICC进行比较,发现里面的内容是完全一致的。查找资料发现proguard会对jar包进行优化,以期减少其大小。默认情况下,proguard会删除jar中的目录元素,导致ClassLoader().getResource()方法找不到对应的资源,只需要在使用时加上-keepdirectories选项即可。附上官方文档的说明:

    -keepdirectories [directory_filter]
    Specifies the directories to be kept in the output jars (or aars, wars, ears, zips, apks, or directories). By default, directory entries are removed. This reduces the jar size, but it may break your program if the code tries to find them with constructs like “com.example.MyClass.class.getResource(“”)”. You’ll then want to keep the directory corresponding to the package, “-keepdirectories com.example”. If the option is specified without a filter, all directories are kept. With a filter, only matching directories are kept. For instance, “-keepdirectories mydirectory” matches the specified directory, “-keepdirectories mydirectory/*” matches its immediate subdirectories, and “-keepdirectories mydirectory/**” matches all of its subdirectories.

    阶段四

    最后的要求还是需要将源码和依赖的库打包到一起,需要在shadowJar打包之前先将源码产生的jar包进行混淆,shadowJar任务的输入改成这个混淆后的jar包即可。proguard实际上也能作为gradle的一个插件进行使用,可以在build.gradle当中加入一个proguard的task进行混淆,proguard官网提供了一种使用方法:

    1
    2
    3
    4
    5
    6
    7
    8
    buildscript {
    repositories {
    flatDir dirs: '/usr/local/java/proguard/lib'
    }
    dependencies {
    classpath ':proguard:'
    }
    }

    定义task的方式如下:

    1
    2
    3
    task myProguardTask(type: proguard.gradle.ProGuardTask) {
    .....
    }

    但这样需要自己手动下载proguard,并存放在编译gradle的服务器上,十分不方便。还有一种方式比较方便,每次会自动下载需要的jar包:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    buildscript {
    repositories {
    mavenCentral()
    jcenter() // for shadow plugin
    }
    dependencies {
    classpath 'net.sf.proguard:proguard-gradle:6.0.3'
    }
    }

    我所定义的proguard的混淆任务如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    task obfuscate(type: proguard.gradle.ProGuardTask) {
    injars jar
    outjars "$buildDir/libs/${project.name}-pg.jar"
    libraryjars "${System.getProperty('java.home')}/lib/rt.jar"
    libraryjars files(configurations.compile.collect())

    useuniqueclassmembernames

    dontshrink
    dontoptimize
    dontnote
    dontwarn

    //keepnames 'class ** { *; }'
    configuration 'proguard.pro'
    }

    这里injars直接写jar即可,会得到jar任务的输出(即源码编译产生的jar),outjars输出到build/libs路径下,rt.jar也许要添加,jre的路径可以使用${System.getProperty('java.home')}获得。另外,依赖的所有库可以通过一种很简洁的方式表述出来,不需要一个依赖一个依赖的添加,libraryjars files(configurations.compile.collect()),这句话会把compile环节所依赖的所有库文件的获取到,并添加到libraryjar当中。proguard的配置参数可以直接在gradle的task中写,一般来说是将普通的proguard参数去掉前面的-,参数的值需要写到一个字符串当中,遇到配置字符串需要换行的配置情况需要在最后加上一个\。
    同时,还需要将混淆产生的jar包作为shadowJar任务的输入才能将这个混淆的jar包和依赖打包到一起,具体写法如下:

    1
    2
    3
    4
    5
    6
    7
    task myShadow(type: ShadowJar) {
    baseName = jar.baseName
    from obfuscate
    configurations = [project.configurations.runtime]
    classifier = 'shadow'
    ...
    }

    from指明了需要打包的jar的来源,这里指定obfuscate就是之前写的obfuscate任务的输出,configurations指定了配置文件,指定之后会根据这个配置文件找到所有的依赖库文件,这里指定的是打包compile环节依赖的库文件,并且[project.configurations.runtime]实际上是default shadowJar task的默认配置。

    这里有一个坑需要注意,如果你使用了默认的shadowJar任务(shadowJar),最后生成的fat-jar会包含有依赖库、没混淆的代码、混淆的代码三部分,正如Stack Overflow上这个问题所描述的一样:https://stackoverflow.com/questions/43643609/gradle-shadowjar-output-contains-obfuscated-and-non-obfuscated-classes?utm_medium=organic&utm_source=google_rich_qa&utm_campaign=google_rich_qa
    这里产生这种情况的原因是,默认的shadowJar任务总会将main文件夹中的源文件添加到输入当中,要解决这个问题就是自己定义一个type为shadowJar的task,不要去使用默认的shadowJar任务,其实这个问题在shadowJar官方说明文档当中也写到了:

    The built in shadowJar task only provides an output for the main source set of the project. It is possible to add arbitrary ShadowJar tasks to a project. When doing so, ensure that the configurations property is specified to inform Shadow which dependencies to merge into the output.

    官方提供了一个例子可以将test中的源文件与testRuntime中依赖的库文件进行打包的方法,也说到了默认的shadowJar任务只能将main中的源文件进行打包,也提示了我们如果要用proguard混淆之后的jar作为输入需要自己定义shadowJar任务,不能使用默认的shadowJar任务。

    1
    2
    3
    4
    5
    task testJar(type: ShadowJar) {
    classifier = 'tests'
    from sourceSets.test.output
    configurations = [project.configurations.testRuntime]
    }

    参考资料

    最近需要在CentOS 7.2上安装matlab,发现matlabR2017b Linux版本安装需要gcc 4.9.x,但是CentOS 7.2 使用yum安装的gcc版本最高为4.8.5,于是决定将gcc版本进行升级。升级gcc一般建议采用编译安装的方式,但是这种方式比较麻烦,需要先编译安装mpfr、gmp、mpc等,于是在网上找到了一种通过yum比较方便的升级方式,而且可以随时在bash、zsh等当中切换各种gcc版本,特记录在此。

    gcc 4.9安装

    1
    2
    3
    sudo yum install centos-release-scl
    sudo yum install devtoolset-3-toolchain
    scl enable devtoolset-3 bash

    这里scl enable就是用来切换不同版本的gcc的。这个切换是临时的,表示在bash中临时切换到gcc4.9的工作环境,当使用exit指令之后,就会回退到原始的gcc版本,可以使用scl -l来查看所有可以切换的开发工具集。

    gcc 5.2

    1
    2
    3
    sudo yum install centos-release-scl
    sudo yum install devtoolset-4-toolchain
    scl enable devtoolset-4 bash

    Spark提供了一种将RDD持久化的方式(cache、persist),这种方式适用于需要多次执行action操作的RDD,因为持久化之后的RDD中的内容不需要重新计算,可以直接使用,对于多次执行action的RDD来说,这样做能省下许多重复计算的时间。Task在启动之初读取一个分区的时候,会先判断这个分区是否已经被持久化,如果没有则会再去检查是否存在Checkpoint,还没有找到的话会根据血统重新计算。RDD的缓存是一种特殊的持久化操作,即RDD.cache()等同于RDD.persist(MEMORY_ONLY)即缓存是一种只将RDD持久化到内存当中的方式。本文基于Spark 2.1版本的源码对RDD的缓存过程进行了分析,文章中涉及到的源码文件主要有以下几个:

    RDD缓存分析

    RDD在缓存到内存之前,Partition中的数据一般以迭代器(Iterator)的数据结构来访问,通过Iterator可以获得分区中每一条序列化或者非序列化的Record,这些Record在访问的时候占用的是JVM堆内存中other部分的内存区域,同一个Partition的不同Record的空间并不是连续的。RDD被缓存之后,会由Partition转化为Block,并且存储位置变为了Storage Memory区域,并且此时Block中的Record所占用的内存空间是连续的。我们可以在Spark的源码当中多次看到unroll这个词,字面意思是展开,在Spark当中的意义就是将存储在Partition中的Record由不连续的存储空间转换为连续存储空间的过程。Unroll操作的时候需要在Storage Memory当中通过reserveUnrollMemoryForThisTask来申请Unroll操作所需要的内存,使用完毕之后,又通过releaseUnrollMemoryForThisTask方法来释放这部分内存。这与1.6.0版本之前固定Unroll内存的方式不同,是动态申请的,因为这部分内存只在Unroll的时候有用,动态申请这块内存能够在不需要Unroll的时候将这块内存区域用于其他的用途上,提升内存资源的利用率。Block有两种存储方式,分别为序列化存储和非序列化存储,这两种存储方式具有其对应的Entry,在MemoryStore类中通过一个LinkedHashMap来存储堆内和对外内存中的所有Block对象的实例:

    1
    2
    3
    4
    // Note: all changes to memory allocations, notably putting blocks, evicting blocks, and
    // acquiring or releasing unroll memory, must be synchronized on `memoryManager`!

    private val entries = new LinkedHashMap[BlockId, MemoryEntry[_]](32, 0.75f, true)

    通过这段源码的注释我们也可以知道,对这个map的数据结构进行操作的时候需要严格遵循同步的原则,因为一个Executor会对应一个MemoryStore,而一个Executor有多个core的时候会并行执行Task,就会有多个线程共享使用一块Storage Memory,即共享使用这一个LinkedHashMap,修改LinkedHashMap时需要做到同步。
    因为不能保证存储空间可以一次容纳 Iterator 中的所有数据,当前的计算任务在 Unroll 时要向 MemoryManager 申请足够的 Unroll 空间来临时占位,空间不足则 Unroll 失败,空间足够时可以继续进行。至于为何要选择LinkedHashMap来存储也是有原因的,因为LinkedHashMap能够很好地支持LRU算法(最近最少使用,常用于页面置换算法),我们可以看到定义LinkedHashMap的第三个参数accessOrder=true,即基于访问顺序,被访问到的元素会被加到LinkedHashMap的最后。基于这个特性,当新Block加入的时候发现内存空间不足的时候,会按照最近最少使用的顺序淘汰LinkedHashMap中的Block。

    序列化存储

    序列化存储使用了一个名为SerializedMemoryEntry的case class:

    1
    2
    3
    4
    5
    6
    private case class SerializedMemoryEntry[T](
    buffer: ChunkedByteBuffer,
    memoryMode: MemoryMode,
    classTag: ClassTag[T]) extends MemoryEntry[T] {
    def size: Long = buffer.size
    }

    这里的主要存储结构为ChunkedByteBuffer,实际上这个类是Spark自己实现的用于存储ByteBuffer的数据结构,其本质为Array[ByteBuffer],Array的每一个元素被称为一个chunk。对于已经序列化的Partition在转化为Block进行存储时,因为在存储时就已经知道序列化的ByteBuffer的size,其所需要的Unroll空间可以直接累加计算,一次申请。存储所使用的方法为putBytes,需要输入Block的ID、占用的内存空间大小、存储模式为堆内内存还是堆外内存以及存放序列化数据的ByteBuffer,其返回的内容指示了是否缓存成功:

    1
    2
    3
    4
    5
    def putBytes[T: ClassTag](
    blockId: BlockId,
    size: Long,
    memoryMode: MemoryMode,
    _bytes: () => ChunkedByteBuffer): Boolean

    获取序列化缓存的内容可以直接使用getBytes方法,输入Block的ID,获取对应的ChunkByteBuffer。

    阅读全文 »