我承认这次我又冲动了,竟然买了一本这样的一本书,本以为应该是一本不错的书,看完了却发现就是「读者」上面的那些小故事的档次啊。果然下决定看一本书之前还是得去豆瓣上稍微看一些书评,这不,一不小心又看了一本励志的、成功学的书,让人好郁闷不是。

当然,如果「致加西亚的信」真的是那种成功学或励志的书,也就罢了,虽然这些书大部分的时候毫无用处,但是至少在你低落的时候,还能够给你一点强行针的效果,但是「致加西亚的信」这本书却是一点吸引力都没有,情节没有任何跌倒起伏,人物也看不出有什么特点。一个军人,完成了总统交给的命令,都能够让作者联想到现在的企业的员工太矫情,上级交给的任务不能一声不吭的就直接完成,我想作者大概是被过度洗脑了吧,当然,我也不好去恶意揣测一个一百多年前的作者的想法,但是这本书对于现在来说,真的是一无是处啊。

「致加西亚的信」企图用军人上下级之间的关系来说明企业内部的员工的上下级关系,这样的行为显然是不对的,军队是一个要求高执行力的地方,但是企业不一样,企业不仅仅要看中一个员工的执行力,还需要看看员工的创造力,而对人性的压制必然会导致创造力的泯灭,每一个员工都有自己的特点,有自己的个性,你不能要求他像一个军人一样,接到任务就一股脑去做,做一件事情之前,了解这件事情相关的东西那都是必须的,期间必然会和上级之间有一个交流的过程,不可能拿到任务过几天就直接给上级一个结果。

另外,「致加西亚的信」里面还提到员工的忠诚度的问题,这个话题刚好之前和朋友聊天的时候谈到过,这位朋友的想法和我的比较接近,我们都认为个人和公司的最基本关系就在于那一张劳动合同,无所谓忠诚度。一段能够持久下去的关系(无论这是什么关系),肯定是双方相互付出的结果,一段只有单方面付出的关系是不会长久的。所以啊,当企业的管理者们在抱怨员工的忠诚度的时候,不妨也想想自己的公司能够给员工带来什么东西,是不是符合员工在这家公司所付出的东西。(「忠诚」这个词,和「感恩」是一样的,都只能是对自己的要求,而不能要求别人「忠于你」或者对你「感恩」

但是不可否认,当我们在一家公司呆久了以后,肯定会存在这一些感情,但是如果你仔细分析这些感情,你会发现,这些感情都是你和某些同事之间的感情(难保你会和某些人相处地比较好,另一些相处地并不好),多年以后,你怀念的可能是某个深夜和同事们一起加班为项目做冲刺,可能是一次让你们非常怀念的 outing,所以,所谓感情,那是指人和人之间的感情,作为一个集体的公司,我认为就是只有利益罢了,我付出我的劳动,公司给我应有的回报,这些回报包括成长机会,工资,股票,还有其他的福利等等。

当然,「致加西亚的信」是一本销量达到 4000 万的畅销书,这样的销量肯定有其存在的理由,或许是我们这个时代的人已经和过去的时代的人大不相同了~,这是一本适合于那个时代的书,并不符合我们的时代,应该永远都留在那个时代。

每个人都有自己理想的下午,有些人喜欢在家里宅着躺在床上,被子病了得有人照顾不是?有些人喜欢在一个秋季的阳光明媚的下午,约几个好友爬山登高,流一把汗,呼吸呼吸新鲜的空气。有些人喜欢把家里打扫的干干净净,然后美滋滋地看上一部喜欢的电影。

而我的理想的下午,不在家,那必须是个安静的地方,稍微有点人气,不至于感到寂寞,看一本自己喜欢的书,把自己沉浸在一个或美好,或悲伤的故事里,或者写一些代码,精炼自己的技艺,让自己对这个世界的理解更进一步。

理想的下午,约上几个志同道合的朋友是必须的,不必太多,一两个就成,有着一些共同的想法,追求着共同的理想,相互知道对方的一些秘密,相互之间有一些默契,需要的时候交流交流彼此的想法,从对方身上学一些东西,但也不必为各自的沉默而感到尴尬。

每个人都有自己状态最棒的时候,而我感觉最好的时候,便是在这样一个理想的下午,不必为生活或者工作上的事情烦心,放下心中的焦虑与不安,那是完完全全属于你自己的下午,在这样的下午,你只干自己喜欢的事情;在这样的下午,你真真切切感受到生活的温度;在这样的下午,做回真真的自己。

对了,就像一次长跑,完全沉浸在跑步的节奏里面,你无需知道终点到底在什么地方,需要做的只是找到自己的节奏,迈开脚步向前跑~,在这种状态下,你可以放下包袱自由地思考,思考生命和星空的意义,思考“从哪里来”、“到哪里去”这样的究级的问题,而不必担心别人说你不着边际。

嗯~,给自己多几个理想的下午,算是自私一点吧,在这些理想的下午,完完全全地为自己而活~

最近工作中涉及到了一个分布式事务的产品,这个产品是在 Spring 的事务上做的,我对其中涉及到的 Spring 的事务的传播特性不是很了解,所以今天花了一个下午的时间认真了解了一下,写了一堆的测试代码。

进入正题,Spring 的事务的传播特性分为以下的七种:

  • PROPAGATION_REQUIRED
  • PROPAGATION_SUPPORTS
  • PROPAGATION_MANDATORY
  • PROPAGATION_REQUIRES_NEW
  • PROPAGATION_NOT_SUPPORTED
  • PROPAGATION_NEVER
  • PROPAGATION_NESTED

下面一种种来解释:

PROPAGATION_REQUIRED

默认的事务传播特性,通常情况下我们用这个事务传播特性就可以了。如果当前事务上下文中没有事务,那么就会新创建一个事务。如果当前的事务上下文中已经有一个事务了,那么新开启的事务会开启一个新的逻辑事务范围,但是会和原有的事务共用一个物理事务,我们暂且不关心逻辑事务和物理事务的区别,先看看这样会导致怎样的代码行为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Override
public void txRollbackInnerTxRollbackPropagationRequires() {
    transactionTemplate.execute(new TransactionCallbackWithoutResult() {
        @Override
        protected void doInTransactionWithoutResult(TransactionStatus transactionStatus) {
            jdbcTemplate.update("insert into user (name, password) values (?, ?)", "Huang",
                "1111112");
            transactionTemplate.execute(new TransactionCallbackWithoutResult() {
                @Override
                protected void doInTransactionWithoutResult(TransactionStatus status) {
                    jdbcTemplate.update("insert into user (name, password) values (?, ?)",
                        "Huang", "1111112");
                    // 内部事务设置了 setRollbackOnly,
                    status.setRollbackOnly();
                }
            });
        }
    });
}

这段代码在一个嵌套的事务内部(内外层的事务都是 PROPAGATION_REQUIRED 的)设置了回滚,那么对于外部的事务来说,它会收到一个 UnexpectedRollbackException,因为内部的事务和外部的事务是共用一个物理事务的,所以显然内部的事务必然会导致外部事务的回滚,但是因为这个回滚并不是外部事务自己设置的,所以外层事务回滚的时候会需要抛出一个 UnexpectedRollbackException,让事务的调用方知道这个回滚并不是外部事务自己想要回滚,是始料未及的。

但是,如果内层的事务不是通过设置 setRollbackOnly() 来回滚,而是抛出了 RuntimeException 来回滚,那么外层的事务接收到了内层抛出的 RuntimeException 也会跟着回滚,这个是可以预料到的行为,所以不会有 UnexpectedRollbackException

PROPAGATION_SUPPORTS

PROPAGATION_SUPPORTS 的特性是如果事务上下文中已经存在一个事务,那么新的事务(传播特性为 PROPAGATION_SUPPORTS)就会和原来的事务共用一个物理事务,其行为和 PROPAGATION_REQUIRED 一样。但是,如果当前事务上下文中没有事务,那么 PROPAGATION_SUPPORTS 就按无事务的方式执行代码:

1
2
3
4
5
6
7
8
9
10
11
@Override
public void txRollbackInnerTxRollbackPropagationSupports() {
    supportsTransactionTemplate.execute(new TransactionCallbackWithoutResult() {
        @Override
        protected void doInTransactionWithoutResult(TransactionStatus status) {
            jdbcTemplate.update("insert into user (name, password) values (?, ?)", "Huang",
                "1111112");
            throw new CustomRuntimeException();
        }
    });
}

看上面这段代码,虽然我们在事务(PROPAGATION_SUPPORTS 的)中抛出了一个 RuntimeException,但是因为其事务上下文中没有事务存在,所以这段代码实际上是以无事务的方式执行的,因此代码中的 jdbcTemplate.update() 操作也不会被回滚。

PROPAGATION_MANDATORY

PROPAGATION_MANDATORY 要求事务上下文中必须存在事务,如果事务上下文中存在事务,那么其行为和 PROPAGATION_REQUIRED 一样。如果当前事务上下文中没有事务,那么就会抛出 IllegalTransactionStateException,比如下面这段代码就会这样:

1
2
3
4
5
6
7
8
9
10
@Override
public void txRollbackInnerTxRollbackPropagationMandatory() {
    mandatoryTransactionTemplate.execute(new TransactionCallbackWithoutResult() {
        @Override
        protected void doInTransactionWithoutResult(TransactionStatus transactionStatus) {
            jdbcTemplate.update("insert into user (name, password) values (?, ?)", "Huang",
                "1111112");
        }
    });
}

PROPAGATION_REQUIRES_NEW

PROPAGATION_REQUIRES_NEW 无论当前事务上下文中有没有事务,都会开启一个新的事务,并且和原来的事务完全是隔离的,外层事务的回滚不会影响到内层的事务,内层事务的回滚也不会影响到外层的事务(这个说法得稍微打点折扣:因为如果内层抛出 RuntimeException 的话,那么外层还是会收到这个异常并且触发回滚),我们分析下几段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Override
public void txRollbackInnerTxRollbackPropagationRequiresNew() {
    transactionTemplate.execute(new TransactionCallbackWithoutResult() {
        @Override
        protected void doInTransactionWithoutResult(TransactionStatus transactionStatus) {

            requiresNewTransactionTemplate.execute(new TransactionCallbackWithoutResult() {
                @Override
                protected void doInTransactionWithoutResult(TransactionStatus status) {
                    jdbcTemplate.update("insert into user (name, password) values (?, ?)",
                        "Huang", "1111112");
                }
            });

            // 外部事务发生回滚,内部事务应该不受影响还是能够提交
            throw new RuntimeException();
        }
    });
}

这段代码外层的事务回滚了,但是不会影响到内层的事务的提交,内层事务不受外层的事务的影响。再看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Override
public void txRollbackInnerTxRollbackPropagationRequiresNew2() {
    transactionTemplate.execute(new TransactionCallbackWithoutResult() {
        @Override
        protected void doInTransactionWithoutResult(TransactionStatus transactionStatus) {
            jdbcTemplate.update("insert into user (name, password) values (?, ?)", "Huang",
                "1111112");
            // Nested transaction committed.
            requiresNewTransactionTemplate.execute(new TransactionCallbackWithoutResult() {
                @Override
                protected void doInTransactionWithoutResult(TransactionStatus status) {
                    jdbcTemplate.update("insert into user (name, password) values (?, ?)",
                        "Huang", "1111112");
                    // 内部事务发生回滚,但是外部事务不应该发生回滚
                    status.setRollbackOnly();
                }
            });
        }
    });
}

这段代码在内层事务上设置了 setRollbackOnly,内层事务肯定会回滚,但是由于内层事务和外层事务是隔离的,所以外层事务不会被回滚。再看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Override
public void txRollbackInnerTxRollbackPropagationRequiresNew3() {
    transactionTemplate.execute(new TransactionCallbackWithoutResult() {
        @Override
        protected void doInTransactionWithoutResult(TransactionStatus transactionStatus) {
            jdbcTemplate.update("insert into user (name, password) values (?, ?)", "Huang",
                "1111112");

            requiresNewTransactionTemplate.execute(new TransactionCallbackWithoutResult() {
                @Override
                protected void doInTransactionWithoutResult(TransactionStatus status) {
                    jdbcTemplate.update("insert into user (name, password) values (?, ?)",
                        "Huang", "1111112");
                    // 内部事务抛出 RuntimeException,外部事务接收到异常,依旧会发生回滚
                    throw new RuntimeException();
                }
            });
        }
    });
}

这段代码在内层事务抛出了一个 RuntimeException,虽然内层事务和外层事务在事务上是隔离,但是 RuntimeException 显然还会抛到外层去,所以外层事务也会发生回滚。

PROPAGATION_NOT_SUPPORTED

PROPAGATION_NOT_SUPPORTED 不管当前事务上下文中有没有事务,代码都会在按照无事务的方式执行,看下面这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Override
public void txRollbackInnerTxRollbackPropagationNotSupport() {
    transactionTemplate.execute(new TransactionCallbackWithoutResult() {
        @Override
        protected void doInTransactionWithoutResult(TransactionStatus transactionStatus) {
            jdbcTemplate.update("insert into user (name, password) values (?, ?)", "Huang",
                "1111112");
            notSupportedTransactionTemplate.execute(new TransactionCallbackWithoutResult() {
                @Override
                protected void doInTransactionWithoutResult(TransactionStatus status) {
                    jdbcTemplate.update("insert into user (name, password) values (?, ?)",
                        "Huang", "1111112");
                }
            });
            // 外部事务回滚,不会把内部的也连着回滚 
            transactionStatus.setRollbackOnly();
        }
    });
}

上面这段代码中虽然外部事务发生了回滚,但是由于内部的事务是 PROPAGATION_NOT_SUPPORTED,根本不在外层的事务范围内,所以内层事务不会发生回滚。

PROPAGATION_NEVER

PROPAGATION_NEVER 如果当前事务上下文中存在事务,就会抛出 IllegalTransactionStateException 异常,自己也会按照非事务的方式执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Override
public void txRollbackInnerTxRollbackPropagationNever2() {
    transactionTemplate.execute(new TransactionCallbackWithoutResult() {
        @Override
        protected void doInTransactionWithoutResult(TransactionStatus transactionStatus) {
            jdbcTemplate.update("insert into user (name, password) values (?, ?)", "Huang",
                "1111112");
            neverTransactionTemplate.execute(new TransactionCallbackWithoutResult() {
                @Override
                protected void doInTransactionWithoutResult(TransactionStatus status) {
                    jdbcTemplate.update("insert into user (name, password) values (?, ?)",
                        "Huang", "1111112");
                }
            });
        }
    });
}

比如上面这段代码,在一个 PROPAGATION_REQUIRES 里面嵌入了一个 PROPAGATION_NEVER,内层就会抛出一个 IllegalTransactionStateException,导致外层事务被回滚。

PROPAGATION_NESTED

PROPAGATION_NESTED 只能应用于像 DataSource 这样的事务,可以通过在一个事务内部开启一个 PROPAGATION_NESTED 而达到一个事务内部有多个保存点的效果,一个内嵌的事务发生回滚,只会回滚到它自己的保存点,外层事务还会继续,比如下面这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Override
public void txRollbackInnerTxRollbackPropagationNested() {
    nestedTransactionTemplate.execute(new TransactionCallbackWithoutResult() {
        @Override
        protected void doInTransactionWithoutResult(TransactionStatus transactionStatus) {
            jdbcTemplate.update("insert into user (name, password) values (?, ?)", "Huang",
                "1111112");

            nestedTransactionTemplate.execute(new TransactionCallbackWithoutResult() {
                @Override
                protected void doInTransactionWithoutResult(TransactionStatus status) {
                    jdbcTemplate.update("insert into user (name, password) values (?, ?)",
                        "Huang", "1111112");
                    // 内部事务设置了 rollbackOnly,外部事务应该不受影响,可以继续提交
                    status.setRollbackOnly();
                }
            });
        }
    });
}

内层的事务发生了回滚,只会回滚其内部的操作,不会影响到外层的事务。

总结

Spring 的事务传播特性种类繁多,大多数都来自于 EJB 的事务,大家可以自己写一些小的程序来测试 Spring 各个事务特性的行为,加深印象,我自己也写了一个工程,通过单元测试去测试各个事务传播特性的行为,大家有兴趣的话,可以下过来跑一下:https://github.com/khotyn/spring-tx-test

前几天在 HotCode 的用户群里面,有同学问起“如何将 JVM 中的 class dump 出来”,当时我下意识的回答就是“可以在 JVM 启动的时候挂一个 agent 上去,然后通过 Instrumentation API 在 class 加载的时候做拦截,把类 dump 出来。”,今天无聊在翻 R 大博客的时候,发现还可以通过 sa-jdi.jar 里面的一个类做 dump,这里就集中介绍一下这几个方法,然后介绍我在 sa-jdi.jar 基础上改的一个小工具。

采用 classLoader.getResourceAsStream()

将一个类从 JVM 中 dump 出来,最简单的方法当然就是直接从 jar 包中把对应的 class 文件找到,然后 dump 出来了,我们可以用 classLoadergetResourceAsStream 来做:

1
2
ClassLoader loader = Thread.currentThread().getContextClassLoader();
InputStream in = loader.getResourceAsStream("com/khotyn/Test.class");

拿到 InputStream 后,你就可以随便玩了。

这个方法简单是简单,但是缺点也很明显,在有些 Java 程序中,类不一定是从 Class 文件中过来,有些是在运行时生成的,有些则在载入到 JVM 之前被增强过,所以这个方法有些类是 dump 不出来的,有些类则 dump 出来不是你想要的。

采用 javaagent

另外一个方法是通过在 JVM 启动的时候挂在一个 javaagent,然后用 Instrucmentation API 在类被加载到虚拟机之前做拦截,参考代码如下:

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
29
30
31
32
33
34
35
36
package com.khotyn.test;

import java.io.File;
import java.io.IOException;
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.lang.instrument.Instrumentation;
import java.security.ProtectionDomain;

import org.apache.commons.io.FileUtils;

/**
 * A demo to demonstrate how to use JVM ti to dump class file from JVM.
 * 
 * @author khotyn.huangt 13-8-3 PM2:21
 */
public class AgentMain {

    public static void premain(String agentArgs, Instrumentation inst) {
        inst.addTransformer(new ClassFileTransformer() {

            @Override
            public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
                                    ProtectionDomain protectionDomain, byte[] classfileBuffer)
                                                                                              throws IllegalClassFormatException {
                try {
                    FileUtils.writeByteArrayToFile(new File("/tmp/" + className.replace('.', '/') + ".class"),
                                                   classfileBuffer);
                } catch (IOException e) {
                    // Quite
                }
                return null;
            }
        });
    }
}

将上面的代码打成一个 jar 包(注意把依赖的 apache commons.io 也打入,也可以直接下载我的 demo 工程:agentDumpClass.zip),然后在 jar 包的 META-INF/MANIFEST.MF 中填上如下的内容:

1
2
3
4
5
6
7
8
9
Manifest-Version: 1.0
Boot-Class-Path: agentDump.jar
Built-By: apple
Build-Jdk: 1.7.0_17
Class-Path: lib/commons-io-2.4.jar
Premain-Class: com.khotyn.test.AgentMain
Created-By: Apache Maven
Can-Redefine-Classes: true
Archiver-Version: Plexus Archiver

然后你就可以执行类似于下面的命令来进行 dump 了:

1
java -javaagent:/Users/apple/workspace/agentDumpClass/target/agentDump.jar Test

由于 premain 方法是在 java 程序的 main 方法执行之前执行的,所以这个方法几乎可以拦截到所有的类,另外,由于注册的 ClassFileTransformer 是 ClassLoader 加载 class 之后,JVM 定义 class 之前被执行的,所以无论是在运行时生成的类,还是经过增强后的类,这个方法都能够 dump 出来,比第一种方法要强很多。然而这个方法还是有一些缺点:

  • 需要在 JVM 启动时增加特别的参数。
  • 只能随 class 被加载进行 dump,不能随时进行 dump

采用 sa-jdi.jar 的 ClassDump 工具

这个方式是 R 大在博客中介绍的方法,可以说是最强大的方法,不像前面的两个方法,这个方法可以在 JVM 进程外执行,且像 javaagent 的那个方法一样,都可以将运行时和被增强过的类 dump 出来,非常方便,至于具体的用法大家就直接看 R 大的文章吧。

改进 ClassDump

ClassDump 工具虽然强大,但是命令略显繁琐,特别是当你只需要 dump 特定的类的时候,还需要专门写一个 ClassFilter 的实现类,这么好的工具,应该直接做成命令行工具才好,于是我修改了 ClassDump 的代码,让它可以支持正则表达式的方式来对需要 dump 的类进行过滤,改进后的 ClassDump 放在了我的 github 仓库上:https://github.com/khotyn/tools

大家可以直接用下面的方式来使用这个改进版:

1
sudo classDump 17118 'com.khotyn.*' dump

classDump 这个命令的第一个参数是目标 JVM 的 PID,第二个参数是一个正则表达式,表示你所要 Dump 出来的类,第三个参数可选,是 dump 的目录。

但是这个工具有一个缺点,就是目前只能在 Mac 下用(因为我用 Mac,呵呵,我把修改后的类直接打入到了 Mac 的 jdk 的 sa-jdi.jar 下面),不过要做其他的平台的也很简单啦,只要按照以下步骤来打包出自己的 sa-jdi.jar 就可以:

  • 下载我修改过的两个类:ClassDump.classRegexClassFilter.class
  • 从 jdk 目录下拷贝一份 sa-jdi.jar 出来
  • 用下面的命令将修改过的两个类打到 sa-jdi.jar 中去:jar uf sa-jdi.jar sun/jvm/hotspot/tools/jcore/ClassDump.class sun/jvm/hotspot/tools/jcore/RegexClassFilter.class
  • 然后配合仓库中的 classDump 脚本就可以用了。

上一篇文章中,我介绍了一下 sed 的基础,包括执行方式、地址选择器以及基本命令,在这一篇文章中,我们继续来了解一下 sed 的高级命令,之所以称它们为高级命令,是因为这些命令会改变 sed 的执行流,废话不说,我们来看看这些命令吧:

高级命令

N (Next)

这里要介绍的第一个命令是 N,它和我们前面介绍过的 n 命令很像,也是要读取下一行的内容,不同的是,N 读取下一行的内容并且将这些内容附加到 pattern space 当前的内容后面。这样,当你需要连着处理多行内容的时候,N 命令就会特别有用,比如,我们有下面一段文本:

1
2
3
4
one two three four
one two three 
four three two
three four

如果我们要把 two three four 替换成 2 3 4,注意例子中的 two three four 可能在不同的行中,那么我们就可以用 N 命令来处理:

1
2
3
N
s/\n/ /
s/two three four/2 3 4/

输出的内容为:

1
2
one 2 3 4 one two three
four three 2 3 4

这个结果不是我们想要的,不过的确是符合了上面的 sed 脚本的执行结果:

  1. 首先,sed 脚本读取了文本的第一行,这个时候 pattern space 中的内容为 one two three four
  2. 然后 sed 脚本执行 N 命令,将下一行读取并附加到当前的 pattern space 内容的后面,这个时候,pattern space 中的内容就变为 one two three four\none two three
  3. 下一个命令,将换行符 \n 替换成一个空格,pattern space 中的内容为 one two three four one two three
  4. 然后下一个命令,将 pattern space 中的 two three four 替换成 2 3 4,这个时候 pattern space 中的内容为 one 2 3 4 one two three
  5. 到达脚本的结尾,输出 pattern space 中的内容,也就是我们输出内容的第一行。
  6. 然后 sed 脚本读取文本的下一行,注意因为之前第二行已经被 N 命令读取了,所以 sed 脚本开始读取第三行,依旧按照前面的命令执行,最后就输出了输出内容中的第二行。

虽然这个结果不是我们想要的,不过算是了解了 N 的作用了。

D (Delete)

同样,前面我们介绍过 d 命令,它用来删除 pattern space 中的内容,并且读取下一行到 pattern space 中,sed 脚本也随之从头开始执行。D 命令和 d 命令稍微有点不同,D 命令会删除 pattern space 中的第一行的内容,它不会从文本中读取新的行进来,当然 sed 脚本还是会从头开始执行,如果我们有这么一个文本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
blank
blank


blank

blank


blank






blank

这个文本中的有些段落之间有多个空行,我们希望把多余的空行去掉,也就是如果段落之间有多个空行,就删掉只剩下一个,我们的 sed 脚本可以这么写:

1
2
3
4
/^$/{
	N
	/^\n$/D
}

这个脚本先匹配出空行,然后读取空行的下一行,如果两行都是空行的话,就把 pattern space 中的第一个空行删除掉,然后继续读取下一行到 pattern space 中,结果就是把多余的空行都删除掉,只剩下一个空行了。

P (Print)

P 命令和 p 命令也稍微不同,P 命令不像 p 命令那样会把 pattern space 中的所有内容打印出来,它只会将 pattern space 的第一行打印出来,这里就不做过多的介绍了。

h (hold), H (Hold), g (get), G (Get), x (exchange)

这里面有五个命令,之所以一起介绍是因为,这五个命令都是操作 hold space 的,之前我们已经知道了 pattern space 了,hold space 可以认为就是一个内容的临时存放点,你可以将 pattern space 中的内容放到 hold space 中,等到需要使用的时候再将 hold space 中的内容拿回到 pattern space 中,我们来看一下这五个命令的作用吧:

  • h:将 pattern space 中的内容拷贝到 hold space 中,hold space 中原来的内容会被覆盖。
  • H:将 pattern space 中的内容添加到 hold space 当前内容的后面。
  • g:将 hold space 中的内容拷贝到 pattern space 中,pattern space 中原来的内容将会被覆盖。
  • G:将 hold space 中的内容添加到 pattern space 中目前的内容后面。
  • x:交换 pattern space 和 hold space 中的内容。

下面我们来看一个简单的例子:

1
2
3
4
5
6
1

2

11

22

111

222

现在我们要将上面的 1 和 2 的位置调换,就是先出现 2 再出现 1,我们的脚本可以这么写:

1
2
3
4
5
6
7
/1/{
	h
	d
}
/2/{
	G
}

这段脚本先匹配 1 所在的行,然后放到 hold space 中,将 pattern space 中的内容清除掉,然后匹配到 2 所在的行,将 hold space 中的内容添加到 pattern space 后面,这样,pattern space 中就是先有 2,再有 1 了。

最后,我们得到的结果就是:

1
2
3
4
5
6
2
1
22
11
222
111

b

b 命令是一个跳转命令,它是无条件的,它的语法是这样的:

1
[address]b [label]

[label] 是要跳转到的标签,你可以在 sed 脚本中用 : 开头来表示一个标签,比如下面的:

1
2
3
:start
s/start/end/g
b start

如果 b 后面不带参数,那么就表示直接跳到脚本的末尾了。

t

除了 b 这样一个跳转命令以外,sed 还有一个 t 的条件跳转命令,如果在当前行有一个替换被成功执行了,那么 t 就会跳转到特定的标签上,它的语法 b 是类似的。

1
[address]t [label]

看下面这段代码:

1
2
3
:begin
s/start/end/
t begin

这条 t 命令只有在当前行的 start 成功被替换成 end 的时候才会跳转到 :begin 标签那里。

总结

sed 的高级命令相对于基本命令来说不怎么常用,但是在处理特定的问题的时候,这些命令还是很有用的。不过,不管怎么说,sed 都不是一门完备的语言,所以其适用的问题域也是比较有限的,sed 最大的优势在于逐行处理文本上,用适当的工具处理适当的问题,才能发挥出工具最大的威力。

之前写的一篇文章有提到采用 sed 来匹配不包含连续字符串的行,平时在做日志分析的时候也经常要用到 sed,但是仅仅用了 sed 的字符串替换的功能,没有系统地去学习过 sed 用法,这次找到一本叫「sed & awk」的书,便花时间对 sed 做了系统的学习。

sed 的执行方式

要了解 sed,必须了解 sed 的执行方式,sed 是一个行处理器,脱胎于 ed(ed 是一个行编辑器,awk 和 grep 也是基于 ed 的),简单地说,sed 的执行方式是这样的:sed 会从输入的文本中读取一行,放到 pattern space 中,然后用 sed 脚本去处理,处理完后继续读取下一行,继续处理。

假设我们有下面一段文本需要处理:

1
2
3
4
5
6
7
8
John Daggett, 341 King Road, Plymouth MA
Alice Ford, 22 East Broadway, Richmond VA
Orville Thomas, 11345 Oak Bridge Road, Tulsa OK
Terry Kalkas, 402 Lans Road, Beaver Falls PA
Eric Adams, 20 Post Road, Sudbury MA
Hubert Sims, 328A Brook Road, Roanoke VA
Amy Wilde, 334 Bayshore Pkwy, Mountain View CA
Sal Carpenter, 73 6th Street, Boston MA

我们要把文本中的 CA 替换成 California,OK 替换成 Oklahoma,于是我们写了下面一段 sed 脚本:

1
2
s/CA/California/
s/OK/Oklahoma/

那么 sed 的执行方式是这样的:

  1. 先读取第一行 John Daggett, 341 King Road, Plymouth MA 到 pattern space
  2. 然后执行脚本的第一行命令,将其中的 CA 替换成 California。
  3. 然后执行脚本的第二行命令,将其中的 OK 替换成 Oklahoma。
  4. 文本的第一行处理完毕,继续读取文本的下一行。
  5. 继续第 2 步和第 3 步。

当然,这只是大部分情况下 sed 的执行方式,sed 的基本命令都是按照这种方式来执行的,一些高级命令可以改变 sed 的执行流。不过在了解这些 sed 命令之前,我们先了解下 sed 的地址选择器,它是很多命令的组成部分。

sed 的地址选择器

默认的情况,sed 脚本会对文本的每一行做处理,但是有时候我们只希望我们的命令作用于特定的几行,这个时候,我们就可以用 sed 的地址选择器,sed 的地址选择器可以是一个正则表达式(sed 的正则表达式总是放在两个 / 中间),行号,或者地址符号(这个是什么东西?我也不清楚),具体的使用方式如下:

  • 如果没有指定地址选择器,那么命令默认会应用在每一行上。
  • 如果只有一个地址选择器,那么命令会作用在每一个符合这个地址选择器的行上。
  • 如果是两个用 , 分割的地址,那么命令会先作用到第一个符合第一个地址选择器的行上,然后继续作用于后续的行,直到(包括)第一个符合第二个地址选择器的行为止。
  • 地址选择器后面可以跟上一个 !,表示反向选择。

另外在一个地址选择器中,你可以用一对 {} 将多个命令包含在其中,下面我们来看一个例子:

1
2
3
4
5
/^\.TS/,/^\.TE/{
     /^$/d
     s/^\.ps 10/.ps 8/
     s/^\.vs 12/.vs 10/
}

这个 sed 脚本的第一行就是一个地址选择器,由 , 分开的两个地址选择器组成,都是正则表达式形式的,表示后面 {} 中的命令会从第一个以 .TS 开头的行一直作用到第一个以 .TE 开头的行为止。

sed 的基本命令

了解完 sed 的地址选择器后,我们就可以继续了解 sed 的基本命令了。

替换(substitution)

sed 的文本替换命令是我最常用的命令,它的语法是这样的:

1
[address]s/pattern/replacement/flags

它由这几个部分组成:

  • 最前面是一个地址选择器,是可选的。
  • 然后后面是一个命令 s,表示是替换命令。
  • 后面紧跟一个正则表达式,表示要被替换的文本。
  • 再后面是希望替换成的文本。
  • 最后是标记位。

其中标记位可以是:

  • n:一个从 1 到 512 的数字,表示只替换第 n 个符合 pattern 子串。
  • g:默认情况下,替换命令只会替换一行中第一个符合 pattern 的子串,加上 g 以后会将行中所有符合 pattern 的子串都进行替换。
  • p:将 pattern space 中的内容打印出来。
  • w file :将 pattern space 中的内容写到文件中。

举一个例子,假设我们要将第一个例子中的最后一行的 MA 换成 Massachusetts,就可以这样写:

1
$s/MA/Massachusetts/

其中的 $ 是一个地址选择器,表示最后一行。

替换命令的替换文本基本上就是一个字符串,但是还是有一些特殊字符:\&\n,其中:

  • \:转义,转义特殊字符。
  • &:代表要被替换的文本,也就是符合 pattern 的子串。
  • \n:当前面的正则表达式中有捕获部分的时候(即,正则表达式的 () 语法),可以在替换文本中用这种反向引用的方式进行引用。

这些特殊字符在当你需要将匹配的文本中的某些部分放到替换文本中的时候会特别有用。

删除(delete)

删除命令很简单,它的语法是:

1
[address]d

前面可以带一个地址选择器,后面是一个 d,表示删除命令,举一个简单的例子,假设我们要筛选出不包含 abc 的行,可以这样写:

1
/abc/d

把包含 abc 的行全部都删除掉,这样就筛选出了不包含 abc 的行。

追加,插入和变化(append,insert,change)

这三个命令的作用分别是:insert 将提供的文本插入到 pattern space 的当前行前面,append 将提供的文本追加到 pattern space 的当前行后面,change 命令替换 pattern space 中的内容,之所以将这三个命令放到一起说,是因为这三个命令需要将提供的文本放在命令的第二行,它们的语法分别是这样的:

append

[line-address]a\
text

insert

[line-address]i\

text

changae

[address]c\

text

来一个例子,我们有下面一段文本:

1
2
3
4
5
6
7
8
9
I want to see @f1(what will happen) if we put the
font change commands @f1(on a set of lines).
If I understand things (correctly), the @f1(third) line causes problems. (No?).
Is this really the case, or is it (maybe) just something else?
Let's test having two on a line @f1(here) and @f1(there) as
well as one that begins on one line and ends @f1(somewhere
on another line).
What if @f1(it is here) on the line?
Another @f1(one).

假设为了阅读的美观,我们希望段落之间能够空出一行来,段落结束的标记我们暂且简单地假设以 . 结尾,我们要做的就是在每一个以 . 结尾的行后面再插入一行,除了最后一行之外,那么我们的 sed 脚本就可以这么写:

1
2
3
4
5
$!{
	/\.$/a\
	\

}

来说明一下这段脚本,第一行是一个地址选择器,表示选择除最后一行以外的行,因为我们不希望在最后一个段落后面也加上一个空行,然后里面的命令是对所有的以 . 结尾的行运用 a 命令去追加一个空行。

列出(list)

列出命令和打印命令其实很像,不同的是列出命令会将不可见字符给列出来,比如 windows 下的换行符,假设我们有下面一段文本(^M 可以用 Ctrl+V,然后 Ctrl+M 来输入):

1
2
3
^M^M
^M
^M^M^M

上面一段文本用 sed -n 'l' 输出的内容是:

1
2
3
\r\r$
\r$
\r\r\r$

而用 sed -n 'p' 输出的内容是三个空空的行。

列出命令将不可见字符打印出来了,而打印命令则没有。

转换(transform)

转换命令和替换命令听起来是一样,但是它们还是不同的,转换命令就像是多个 tr 命令用管道连在一起作用一样,它的语法是:

1
[address]y/abc/xyz/

转换命令的一个使用的场景就是大小写的转换:

1
y/abcdefghijklmnopqrstuvwxyz/ABCDEFGHIJKLMNOPQRSTUVWXYZ/

上面这个命令会将文本中所有的小写字母转换成大写字母。

打印(print)

打印命令其实就是一个简单的 p,将 pattern space 中的内容打印出来,比如:

1
$!p

表示将除最后一行以外的内容全部打印出来。需要注意的是,默认情况下 sed 会将 pattern space 中的内容都打印出来,要关闭这个功能,可以加上一个 -n 参数,就像我在介绍列出命令的时候做的那样。

打印行号

打印行号也就是一个简单的 = 号,大家可以去试一下,这里不再多讲了。

下一个(next)

next 命令是一个 n,它的作用是将 pattern space 中的内容立即输出,然后将下一行读入到 pattern space 中,然后继续执行接下来的命令,比如:

/H1/{

n

/^$/d

}

就是先匹配到含有 H1 的行,然后将这一行打印出来,接着读取下一行到 pattern space,如果是空的话,就删除掉。

读取和写入文件(read,write)

sed 的读取文件的功能可以将文件中的内容追加到 pattern space 后面,前面那个在段落后面添加空行的例子我们可以这么做:首先创建一个只包含一个空行的文件叫做 temp,接着我们就可以用下面的命令来达到我们的目的了:

1
2
3
$!{
    /\.$/r temp
}

sed 的写入文件的功能和读取文件的功能类似,语法是:

1
[address]w file

表示将 pattern space 中的内容写入到文件。

退出(quit)

sed 的退出命令是让 sed 停止读取新的行,也停止输出,基本上就是让 sed 退出了,它的命令的语法是:

1
[line-address]q

它只能作用在单行的地址选择器上。

总结

sed 的基本命令相对来说还是比较简单的,最主要的还是要用好地址选择器,在下一篇中,我会介绍一些 sed 的高级命令。

在前一篇文章中,我讨论过如何使用使用零宽断言来匹配不包含连续字符串的行,这个方法采用了零宽断言这种不怎么常见的正则表达式用法,虽然行之有效,但是总归是个麻烦的方法,而且,零宽断言很多的正则表达式解释器都不支持,用 grep 的话,得加上 -P 参数,让 grep 采用 Perl 的方式解释正则表达式,更加遗憾的一点是 -P 参数似乎只有在 GNU 的版本中才有,在我的 Mac 上的 BSD 版本的 grep 中,并没有这个参数。

所幸的是今天无聊翻了翻 grep 的 man page,发现了几个更加方便的方法也更加通用的办法,在这里和大家分享一下:

grep 的 invert match

今天翻 grep 的 man page,发现了一个 -v 参数,它的说明是这样的:

Selected lines are those not matching any of the specified patterns.

正是我们想要,可以传入一个正则表达式,它帮你匹配不符合这个正则表达式的行,而且 -v 参数各个 grep 的版本都支持,无需担心换个系统就不能用的情况。

采用 sed 来删除符合某个 pattern 的行

其实不用 grep,用 sed 也可以做到这个需求,sed 本身就是一个强大的行处理工具,sed 可以用如下的方式把符合某个 pattern 的行给删除掉:

1
sed '/pattern/D'

怎么样?也是非常方便的吧,它可以做到和 grep 一样的功能,非常有效。

采用 sed 来打印不符合某个 pattern 的行

要用 sed 来解决这个问题,其实不止上面一个方法,还可以用以下的方法来做:

1
sed -n '/pattern/!p'

解释一下这段 sed 脚本的作用,首先是 -n 参数,大家知道 sed 的默认将处理和没有处理过的行都定向到输出流上,而 -n 参数是用来关闭这个功能,我们当然不希望 sed 将所有的行都打印出来。然后脚本的开始是一个行的选择器,前面是一个正则表达式(sed 的正则表达式都是放在两个斜杠之间的),后面的是一个!号,这样就表示选择反向选择,即选择不符合 pattern 的行,然后最后是一个 p 命令,把这样的行打印出来,这里的 pattern 当然可以是需求中的那个连续的字符串,这样,我们就达到了需求的目的了。

总结一下,推荐大家还是用 grep 的 invert match 或者 sed 来完成这个功能,零宽断言在解决这个问题上感觉有点杀鸡焉用牛刀(零宽断言还有很多适用的场景,不仅仅可以用来解决这个问题)。

最近在工作中遇到一个问题,有 N 个字符串,需要用正则表达式去过滤掉不包含某一个特定连续字符串(比如abc)的字符串。

在网上搜罗了一大把,找到了在 Perl 5 的正则表达式中有零宽断言这个东西,非常强大,先来了解下零宽断言倒是是什么?

简单的说,零宽断言是查找在某些内容之前或者之后的东西,这样解释起来可能比较抽象,我们来具体看下几种零宽断言:

  • (?=exp):这个零宽断言用来断言自身出现的位置之后能够匹配到表达式 exp,考虑下面这一个正则表达式 q(?=u),这个正则表达式表示匹配后面的字符是 u 的 q
  • (?!exp):这个零宽断言用来断言自身出现的位置之后不能够匹配到表达式 exp,看下面这一个正则表达式 q(?!u),这个正则表达式表示匹配后面的字符不是 u 的 q
  • (?<=exp):这个零宽断言用来断言自身出现的位置之前能够匹配到表达式 exp
  • (?<!exp):这个零宽断言用来断言自身出现的位置之前不能够匹配到表达式 exp

在理解零宽断言的时候需要注意的一点是它是一种断言,也就是说零宽断言只会告诉你匹不匹配,但是不会“消费”掉字符串内的内容,我用下面的这一个例子来解释这个情况:

我们有一个正则表达式 k(?=h)otyn,用它去匹配 khotyn,乍看一下这个匹配是会成功的,但是由于零宽断言只做断言,而不会”消费“掉匹配到的字符串,所以事实上,这个正则表达式匹配是一个后面是 h 的 k,并且这个 k 的后面是 otyn,这样这个正则表达式无论什么字符串都会匹配失败(正确的应该是 k(?=h)hotyn,不过这样加不加零宽断言并没有意义)。

在理解零宽断言以后,我们来看一下如何来匹配出不包含“abc”的字符串,下面是我写出的结果:

1
((?!abc).)+

首先我们看这个正则表达式里面的 (?!abc). 部分,这个部分断言一个空字符后面不能够匹配到字符串abc,并且这个空字符串后面是一个任意字符。

我们来看下下面这一段代码:

1
2
3
4
Pattern pattern = new Perl5Compiler().compile("((?!abc).)+");
Perl5Matcher matcher = new Perl5Matcher();
System.out.println(matcher.matches("abc", pattern));
System.out.println(matcher.matches("abdas dfas", pattern));

这段代码的执行结果是:

1
2
false
true

第一个匹配失败是因为在字符 ‘a’ 前面的空字符后面匹配到了字符串 “abc”,因此断言失败,从而匹配失败。

第二个匹配成功是因为没有任何一个空字符后面有出现 “abc” ,因为匹配成功。

最后加上 + 号的原因是因为能够做到完全匹配,因为任何一个字符只要其本身不是 ‘a’,并且后面不是 ‘bc’,那么就是能够匹配 “(?!abc).” 的,因此,只要一个字符串里面不包含 abc,那么它就能够完全匹配 ((?abc).)+

PS:这片文章其实是前几年写的,之前的博客被关闭了,数据丢了,幸好当时在 Iteye 上还有一份,于是就迁移过来。这几年我经常用这个方式来分析线上服务器的日志,可以说,有了零宽断言,省去了非常多的麻烦~,定位问题的速度也快了不少,零宽断言的确是一个非常犀利的东西。

cURL 这个神器相信很多人都已经用过,简单地说,cURL 就是一个和服务器端通信的工具,至于用什么协议,cURL 支持各种各样的协议,包括 HTTP,FTP,SMTP 等等协议,可以说是应有尽有。

cURL 的可用的参数非常非常多,你能想到的基本上都有,不过一般使用的就那么几个参数吧,这里就介绍下我常常用到的两个:

使用 Post 提交数据

有些服务器端限制了你只能用 POST 的方式提交数据,这个时候如果你就不能通过在 URL 上加上参数的方式来提交数据了,cURL 提供了一个 -d 参数来让你可以用 POST 的方式把数据提交上去,例如:

1
curl http://ka.178.com/receive/index -d "activity_id=3403"

这个请求只用了一个参数,多个参数可以 & 符号作链接。

使用 cURL 的另一个经常需要做的事情就是需要拿到用户的登录信息,这些登录信息往往放在 cookie 里面,这样你就需要把 cookie 信息附加在请求上提交到服务器,让服务器认为你是处于登录的状态,cURL 提供了一个 --cookie 的参数让你可以附上 cookie:

1
curl http://ka.178.com/receive/index -cookie "__utma=2582276614.13325.133516.3;"

如果嫌 --cookie 太长,可以用 -b-b--cookie 的简写。

显示出请求的头信息

对于有一些请求,比如那些返回 302 状态码的请求,cURL 默认情况下是不会输出任何内容的,这种请求下,我们就不知道它是返回了 200 而响应体里面没有任何内容还是因为是 302 而没有任何内容呢,这个时候我们就可以通过 -D--dump-header)参数来显示请求的响应头,不过 -D 参数后面要加上一个文件,如果我们想直接输出到终端,那么就可以这么干:

1
curl -D /dev/stdout http://xxxxx.com/xxxx.html

直接将内容输出到设备 stdout 下就可以(感谢 Unix,一切皆文件!)。

此篇文章是作者两年前发布在黄金档的文章。

ConcurrentHashMap 是一个线程安全的 Hash Table,它的主要功能是提供了一组和 HashTable 功能相同但是线程安全的方法。ConcurrentHashMap 可以做到读取数据不加锁,并且其内部的结构可以让其在进行写操作的时候能够将锁的粒度保持地尽量地小,不用对整个 ConcurrentHashMap 加锁。

ConcurrentHashMap 的内部结构

ConcurrentHashMap 为了提高本身的并发能力,在内部采用了一个叫做 Segment 的结构,一个 Segment 其实就是一个类 Hash Table 的结构,Segment 内部维护了一个链表数组,我们用下面这一幅图来看下 ConcurrentHashMap 的内部结构:

image

从上面的结构我们可以了解到,ConcurrentHashMap 定位一个元素的过程需要进行两次 Hash 操作,第一次 Hash 定位到 Segment,第二次 Hash 定位到元素所在的链表的头部,因此,这一种结构的带来的副作用是 Hash 的过程要比普通的 HashMap 要长,但是带来的好处是写操作的时候可以只对元素所在的 Segment 进行加锁即可,不会影响到其他的 Segment,这样,在最理想的情况下,ConcurrentHashMap 可以最高同时支持 Segment 数量大小的写操作(刚好这些写操作都非常平均地分布在所有的 Segment 上),所以,通过这一种结构,ConcurrentHashMap 的并发能力可以大大的提高。

Segment

我们再来具体了解一下Segment的数据结构:

1
2
3
4
5
6
7
static final class Segment<K,V> extends ReentrantLock implements Serializable {
    transient volatile int count;
    transient int modCount;
    transient int threshold;
    transient volatile HashEntry<K,V>[] table;
    final float loadFactor;
}

详细解释一下 Segment 里面的成员变量的意义:

  • count:Segment 中元素的数量
  • modCount:对 table 的大小造成影响的操作的数量(比如 put 或者 remove 操作)
  • threshold:阈值,Segment 里面元素的数量超过这个值依旧就会对 Segment 进行扩容
  • table:链表数组,数组中的每一个元素代表了一个链表的头部
  • loadFactor:负载因子,用于确定 threshold

ConcurrentHashMap 的初始化

下面我们来结合源代码来具体分析一下 ConcurrentHashMap 的实现,先看下初始化方法:

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
29
30
31
public ConcurrentHashMap(int initialCapacity,
                         float loadFactor, int concurrencyLevel) {
    if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
        throw new IllegalArgumentException();

    if (concurrencyLevel > MAX_SEGMENTS)
        concurrencyLevel = MAX_SEGMENTS;

    // Find power-of-two sizes best matching arguments
    int sshift = 0;
    int ssize = 1;
    while (ssize < concurrencyLevel) {
        ++sshift;
        ssize <<= 1;
    }
    segmentShift = 32 - sshift;
    segmentMask = ssize - 1;
    this.segments = Segment.newArray(ssize);

    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    int c = initialCapacity / ssize;
    if (c * ssize < initialCapacity)
        ++c;
    int cap = 1;
    while (cap < c)
        cap <<= 1;

    for (int i = 0; i < this.segments.length; ++i)
        this.segments[i] = new Segment<K,V>(cap, loadFactor);
}

CurrentHashMap 的初始化一共有三个参数,一个 initialCapacity,表示初始的容量,一个 loadFactor,表示负载参数,最后一个是 concurrentLevel,代表 ConcurrentHashMap 内部的 Segment 的数量,ConcurrentLevel 一经指定,不可改变,后续如果 ConcurrentHashMap 的元素数量增加导致 ConrruentHashMap 需要扩容,ConcurrentHashMap 不会增加 Segment 的数量,而只会增加 Segment 中链表数组的容量大小,这样的好处是扩容过程不需要对整个 ConcurrentHashMap 做 rehash,而只需要对 Segment 里面的元素做一次 rehash 就可以了。

整个 ConcurrentHashMap 的初始化方法还是非常简单的,先是根据 concurrentLevel 来 new 出 Segment,这里 Segment 的数量是不小于 concurrentLevel 的最大的 2 的指数,就是说 Segment 的数量永远是 2 的指数个,这样的好处是方便采用移位操作来进行 hash,加快 hash 的过程。接下来就是根据 intialCapacity 确定 Segment 的容量的大小,每一个 Segment 的容量大小也是 2 的指数,同样是为了加快 hash 的过程。

这边需要特别注意一下两个变量,分别是 segmentShift 和 segmentMask,这两个变量在后面将会起到很大的作用,假设构造函数确定了 Segment 的数量是 2 的 n 次方,那么 segmentShift 就等于 32 减去 n,而 segmentMask 就等于 2 的 n 次方减一。

ConcurrentHashMap 的 get 操作

前面提到过 ConcurrentHashMap 的 get 操作是不用加锁的,我们这里看一下其实现:

1
2
3
4
public V get(Object key) {
    int hash = hash(key.hashCode());
    return segmentFor(hash).get(key, hash);
}

看第三行,segmentFor 这个函数用于确定操作应该在哪一个 segment 中进行,几乎对 ConcurrentHashMap 的所有操作都需要用到这个函数,我们看下这个函数的实现:

1
2
3
final Segment<K,V> segmentFor(int hash) {
    return segments[(hash >>> segmentShift) & segmentMask];
}

这个函数用了位操作来确定 Segment,根据传入的 hash 值向右无符号右移 segmentShift 位,然后和 segmentMask 进行与操作,结合我们之前说的 segmentShift 和 segmentMask 的值,就可以得出以下结论:假设 Segment 的数量是 2 的n次方,根据元素的 hash 值的高 n 位就可以确定元素到底在哪一个 Segment 中。

在确定了需要在哪一个 segment 中进行操作以后,接下来的事情就是调用对应的 Segment 的 get 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
V get(Object key, int hash) {
    if (count != 0) { // read-volatile
        HashEntry<K,V> e = getFirst(hash);
        while (e != null) {
            if (e.hash == hash && key.equals(e.key)) {
                V v = e.value;
                if (v != null)
                    return v;
                return readValueUnderLock(e); // recheck
            }
            e = e.next;
        }
    }
    return null;
}

先看第二行代码,这里对 count 进行了一次判断,其中 count 表示 Segment 中元素的数量,我们可以来看一下 count 的定义:

1
transient volatile int count;

可以看到 count 是 volatile 的,实际上这里里面利用了 volatile 的语义:

对volatile字段的写入操作happens-before于每一个后续的同一个字段的读操作。

因为实际上 put、remove 等操作也会更新 count 的值,所以当竞争发生的时候, volatile 的语义可以保证写操作在读操作之前,也就保证了写操作对后续的读操作都是可见的,这样后面 get 的后续操作就可以拿到完整的元素内容。

然后,在第三行,调用了 getFirst() 来取得链表的头部:

1
2
3
4
HashEntry<K,V> getFirst(int hash) {
    HashEntry<K,V>[] tab = table;
    return tab[hash & (tab.length - 1)];
}

同样,这里也是用位操作来确定链表的头部,hash 值和 HashTable 的长度减一做与操作,最后的结果就是 hash 值的低 n 位,其中 n 是 HashTable 的长度以 2 为底的结果。

在确定了链表的头部以后,就可以对整个链表进行遍历,看第 4 行,取出 key 对应的 value 的值,如果拿出的 value 的值是 null,则可能这个 key,value 对正在 put 的过程中,如果出现这种情况,那么就加锁来保证取出的 value 是完整的,如果不是 null,则直接返回 value。

ConcurrentHashMap 的 put 操作

看完了 get 操作,再看下 put 操作,put 操作的前面也是确定 Segment 的过程,这里不再赘述,直接看关键的 segment 的 put 方法:

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
29
30
V put(K key, int hash, V value, boolean onlyIfAbsent) {
    lock();
    try {
        int c = count;
        if (c++ > threshold) // ensure capacity
            rehash();
        HashEntry<K,V>[] tab = table;
        int index = hash & (tab.length - 1);
        HashEntry<K,V> first = tab[index];
        HashEntry<K,V> e = first;
        while (e != null && (e.hash != hash || !key.equals(e.key)))
            e = e.next;

        V oldValue;
        if (e != null) {
            oldValue = e.value;
            if (!onlyIfAbsent)
                e.value = value;
        }
        else {
            oldValue = null;
            ++modCount;
            tab[index] = new HashEntry<K,V>(key, hash, first, value);
            count = c; // write-volatile
        }
        return oldValue;
    } finally {
        unlock();
    }
}

首先对 Segment 的 put 操作是加锁完成的,然后在第五行,如果 Segment 中元素的数量超过了阈值(由构造函数中的 loadFactor 算出)这需要进行对 Segment 扩容,并且要进行 rehash,关于 rehash 的过程大家可以自己去了解,这里不详细讲了。

第 8 和第 9 行的操作就是 getFirst 的过程,确定链表头部的位置。

第 11 行这里的这个 while 循环是在链表中寻找和要 put 的元素相同 key 的元素,如果找到,就直接更新更新 key 的 value,如果没有找到,则进入 21 行这里,生成一个新的 HashEntry 并且把它加到整个 Segment 的头部,然后再更新 count 的值。

ConcurrentHashMap 的 remove 操作

Remove 操作的前面一部分和前面的 get 和 put 操作一样,都是定位 Segment 的过程,然后再调用 Segment 的 remove 方法:

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
29
30
31
32
33
V remove(Object key, int hash, Object value) {
    lock();
    try {
        int c = count - 1;
        HashEntry<K,V>[] tab = table;
        int index = hash & (tab.length - 1);
        HashEntry<K,V> first = tab[index];
        HashEntry<K,V> e = first;
        while (e != null && (e.hash != hash || !key.equals(e.key)))
            e = e.next;

        V oldValue = null;
        if (e != null) {
            V v = e.value;
            if (value == null || value.equals(v)) {
                oldValue = v;
                // All entries following removed node can stay
                // in list, but all preceding ones need to be
                // cloned.
                ++modCount;
                HashEntry<K,V> newFirst = e.next;
                for (HashEntry<K,V> p = first; p != e; p = p.next)
                    newFirst = new HashEntry<K,V>(p.key, p.hash,
                                                  newFirst, p.value);
                tab[index] = newFirst;
                count = c; // write-volatile
            }
        }
        return oldValue;
    } finally {
        unlock();
    }
}

首先 remove 操作也是确定需要删除的元素的位置,不过这里删除元素的方法不是简单地把待删除元素的前面的一个元素的 next 指向后面一个就完事了,我们之前已经说过 HashEntry 中的 next 是 final 的,一经赋值以后就不可修改,在定位到待删除元素的位置以后,程序就将待删除元素前面的那一些元素全部复制一遍,然后再一个一个重新接到链表上去,看一下下面这一幅图来了解这个过程:

image

假设链表中原来的元素如上图所示,现在要删除元素 3,那么删除元素 3 以后的链表就如下图所示:

image

ConcurrentHashMap 的 size 操作

在前面的章节中,我们涉及到的操作都是在单个 Segment 中进行的,但是 ConcurrentHashMap 有一些操作是在多个 Segment 中进行,比如 size 操作,ConcurrentHashMap 的 size 操作也采用了一种比较巧的方式,来尽量避免对所有的 Segment 都加锁。

前面我们提到了一个 Segment 中的有一个 modCount 变量,代表的是对 Segment 中元素的数量造成影响的操作的次数,这个值只增不减,size 操作就是遍历了两次 Segment,每次记录 Segment 的 modCount 值,然后将两次的 modCount 进行比较,如果相同,则表示期间没有发生过写入操作,就将原先遍历的结果返回,如果不相同,则把这个过程再重复做一次,如果再不相同,则就需要将所有的 Segment 都锁住,然后一个一个遍历了,具体的实现大家可以看 ConcurrentHashMap 的源码,这里就不贴了。