java 多线程操作是我们日常频繁使用的技术之一, 然而我们在熟练使用多线程开发的同时, 也要注意基础的夯实, 关于 java 线程在虚拟机层面及操作系统层面的技术支持, 也应当有一个清楚的了解;
在 java.lang.Thread 类中定义了 6 种状态:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15public enum State {
// 线程创建
NEW,
// 等待获取内置锁
BLOCKED,
// 无限期等待另一个线程执行特定动作唤醒自己
WAITING,
// 有时间期限地等待另一个线程执行特定动作唤醒自己
TIMED_WAITING,
// 包括正在运行的, 就绪状态等待被调度的,
// 以及除了 BLOCKED, WAITING, TIMED_WAITING 之外的其他阻塞状态
RUNNABLE,
// 线程结束
TERMINATED;
}
从上面 Thread.State 枚举的注释中可以看出来, java 站在虚拟机的层面针对 java.lang.Thread 的状态设计了一套独立的体系, 其与 os 层面的线程状态没有直接的关联; 准确的说, java.lang.Thread 的状态只与 “java 语言层面的行为” 有关, 而与操作系统的调度, I/O, 事件, 中断等没有直接关系; 那么, 什么是 java 语言层面的行为? 对于不同的行为, 状态如何转移? 下面我就给出一个 Thread 状态转移大图:
在上图中, 各个圆圈代表了线程的状态, 圆圈之间的箭头是状态转移的方向, 箭头上标注的是状态转移的条件, 其中每一行条件都是独立的, 箭头上有几行就代表该状态转移存在几种可能的情况;
当要进入 synchronized
代码块或被 synchronized
关键字修饰的方法时, 如果目标对象的监视器已被其他线程持有, 则线程状态转为 BLOCKED, 并被挂到监视器的 _EntryList 队列中排队; BLOCKED 状态是无等待期限的, 在正在持有监视器的线程及 _EntryList 队列中排在自己前面的线程让出监视器之前, 该线程将一直处于睡眠状态, 且 BLOCKED 状态的线程不可中断;
WAITING 与 TIMED_WAITING 状态, 相同点在于, 它们都处于睡眠状态, 等待另一个线程执行特定动作以唤醒自己, 在等待过程中如果被其他线程中断则会抛出 InterruptException
异常; 不同点在于, WAITING 是无限期等待, 而 TIMED_WAITING 是有时间期限地等待, 如果超时则放弃等待, 线程被唤醒并继续执行 (混淆点注意: 这里的超时并不会抛出 TimeoutException
异常); 由这个不同点我们可以观察出从 RUNNING 转移到 WAITING / TIMED_WAITING 的条件差异:
(1) 转移到 WAITING 的条件是不带 timeout 参数的方法:1
2
3java.lang.Object#wait();
java.lang.Thread#join();
java.lang.LockSupport#park();
(2) 转移到 TIMED_WAITING 的条件是带 timeout 参数的方法:1
2
3
4
5
6
7
8java.lang.Thread#sleep(long);
java.lang.Thread#sleep(long, int);
java.lang.Object#wait(long);
java.lang.Object#wait(long, int);
java.lang.Thread#join(long);
java.lang.Thread#join(long, int);
java.util.concurrent.locks.LockSupport#parkNanos(Object, long);
java.util.concurrent.locks.LockSupport#parkUntil(Object, long);
对于以上不同的方法, 其所等待其他线程执行的 “特定动作” 分别如下:
有两个注意点需要额外补充一下:
synchronized(targetObject){}
代码块内或被 synchronized
关键字修饰的目标对象方法内) 后才能调用, 否则会抛出如下异常:1 | java.lang.IllegalMonitorStateException: current thread not owner |
上一节讲到了调用了 Object#wait 而进入 WAITING / TIMED_WAITING 状态的线程可由 Object#notify 或 Object#notifyAll 唤醒, 但唤醒后接下来会转移到什么状态还是要看具体的锁竞争情况:
上一小节已经提及, Thread.state 是独立于 os 线程状态而设计的, 不过这并不代表 java 线程与 os 线程完全没有关系; 我们知道, 当我们调用 Thread#start 方法启动一个线程时, jvm 底层会调用 pthread_create 方法在内核创建唯一一个与之对应的 os 线程; 事实上, 当 Thread 的状态发生变化时, 一般会引起对应 os 线程的状态变化, 而 os 线程的状态变化, 却未必会引起对应的 Thread 状态变化, 下面我给出一个关系对应大图:
图中分为两大部分, 上方为 jvm 层面 (和上一小节中的 Thread 状态转移图是一样的), 下方为 os 层面, 上下两部分之间的双向箭头表示了 Thread 状态与 os 线程状态的对应关系;
由上图可以看到, java Thread 的 RUNNABLE 状态对应了 os 的如下状态:
Ready 与 Running 自不必说, 关键是后面两个状态, 也就是图中的两个绿色箭头, 容易引起混淆: 明明是 sleep 睡眠状态, 为什么 java Thread 会处于 RUNNABLE 状态? 其实可以参考上一小节, Thread 转移到 WAITING, TIMED_WAITING, BLOCKED 状态的条件皆是与线程协作, 线程竞争相关的操作, 而诸如磁盘 I/O 所引起 os 线程进入不可中断睡眠或与之类似的网络 I/O 所引起 os 线程进入可中断睡眠等动作, 皆与之完全没有关系, 如果不将其归类到 RUNNABLE 中, 我们会发现并没有其它合适的状态可以分给它们;
事实上, jvm 作为运行在操作系统之上的高层面的进程, 对于一个 java.lang.Thread 来说, 与之对应的底层操作系统线程, 无论是在运行中, 还是磁盘 I/O, 网络 I/O, 本质上都是在给它提供必要的服务, 那么将其当做 RUNNABLE 也就是合理的了;
另外还要注意到 Interruptible Sleep 可中断睡眠状态只有部分情况对应到 Thread 的 RUNNABLE 中, 在下一小节中将看到它对应到其它 java.lang.Thread 状态的情况;
除了刚才所说的 RUNNABLE, 会部分对应到 os 的 Interruptible Sleep 状态之外, WAITING, TIMED_WAITING, BLOCKED 这三种 Thread 状态都与可中断睡眠对应; 这里的可中断要与 java 语言层面的中断区分开, 这也是容易引起混淆的点: 上文提及 WAITING 和 TIMED_WAITING 在 java 语言层面是可中断的, BLOCKED 在 java 语言层面是不可中断的, 而在操作系统层面上, 这三种状态对应的 os 线程都是可中断的;
我们开发过程中可能会遇到同一个项目有多个分支在并行开发, 在开发其中一个分支的时候, 另一个分支突然需要做点什么事情; 如果此时代码刚写了一半, 提交也不合适, 撤销也不舍得, 没有类似 git stash 的工具就显得很尴尬; git stash 正是用来解决此类问题的有效解决方案; git stash 灵活的堆栈风格及列表风格的命令, 让我们处理分支间并行跳跃式开发变得游刃有余;
查看所有快照简要信息:1
git stash list
仅查看改动的类及改动行数:1
2
3
4# 查看最新的快照
git stash show
# 查看第 n 个快照的信息
git stash show stash@{n}
查看具体的改动 diff:1
2
3
4# 查看最新的快照
git stash show -p
# 查看第 n 个快照的信息
git stash show -p stash@{n}
1 | # 不指定 message 直接贮存(默认使用 HEAD 的 commit id 与 commit message 作为 stash message) |
1 | # 使用指定 message 保存当前修改上下文 |
如果有的时候我们只是想贮存部分修改的文件, 而继续编辑剩下的文件, 可以按如下操作:1
2
3
4
5
6# 1. 先把不要贮存的改动加入索引
git add file_path
# 2. 使用 –-keep-index 选项将未加入 index 的改动贮存起来
git stash save –-keep-index "stash_message"
# 3. 撤销加入索引的改动
git reset HEAD .
这里使用了 –-keep-index 选项以避免 stash 暂存区的修改, 局限性是我们必须先将目标文件加入索引, stash 完成后再将其从索引中撤销, 增添了一些复杂性;
1 | # 使用最新的快照恢复, 并将其弹出存储堆栈 |
1 | # 使用最新的快照恢复, 但不将其弹出存储堆栈 |
1 | # 删除最新的快照 |
git stash 虽然很灵活, 但是也有一些使用上的坑: git stash 不会保留贮存前的 git 状态, 恢复后统一变为工作区状态, 举两个例子:
Changes not staged for commit
, 已经区分不出来哪些变更曾加过索引了;All conflicts fixed but you are still merging
, 如果我们不及时提交此次合并的修改, 而将其 stash 起来, 等后面恢复的时候, 灾难就开始了:HashMap 可能已经被各大技术博文讲烂了, 在各种面试中也是频繁被问到; 本文不会再把前辈们的话复述一遍, 而是根据我的面试经历和一些使用心得, 总结一下 HashMap 源码中一些极少被注意到 (但仔细研究发现十分精妙) 的设计细节及使用注意事项;
首先统一申明两个概念:
HashMap 维护了一个数组类型的内部成员 table, 其中的每一个元素, 背后都是一个存放 hash 冲突的 KV 键值对的链表, jdk 的开发者将其称作 bin
或 bucket
, 中文译作 “桶”, 故本文将统一使用 “桶” 作为相关概念的代称;
在 HashMap 中 KV 键值对信息被维护在一个继承自 Map.Entry 的内部封装结构中, jdk 的开发者将其称作 entry
, 中文译作 “条目”, 由于中文含义容易引起歧义, 故本文将沿用 “entry” 作为相关概念的代称;
HashMap 从 1998 年 jdk 1.2 诞生以来, 经历了多次重构, 愈加完善; 不过 jdk 1.6 之前的源码已经很难再找到 (只能在 github 上找到一些非官方的 民间收藏版本, 想要准确对比 HashMap 在各个 jdk 版本中的实现差异已比较困难, 在网上仔细查阅各种资料文章, 竟发现在很多细节上互相冲突, 却也无从考证; 故本节将尽量总结设计思想方面的演进, 而尽量不陷入代码细节的纠结;
在 jdk 1.2/1.3 时代, HashMap 更多的是作为一种产品原型而存在, 关键的 hash 定位逻辑设计得比较简略:
%
, 效率比较低, 当然与之对应的, 此时的 HashMap 还没有强制使用 2 的幂次方作为容量;1 | hash = key.hashCode(); |
大神 Doug Lea 似乎对 jdk 原始的 hash 定位逻辑很不满意, 开始了大刀阔斧的重构, 从 jdk 1.4 开始, Doug Lea 成为了 HashMap 的第一作者;
首先是 hash 计算逻辑被单独抽出来治理, 从 jdk 1.4 到 jdk 1.7, 经历了两三次算法迭代; 这些算法的原理是类似的, 区别在于参数的调优, 算法主要是通过移位与异或运算, 以做到对 key.hashcode 充分打散, 组合其高位与低位的不同特征, 尽可能求出一个与其他 entry 不同的 hash 值;
jdk 1.4 的实现:1
2
3
4
5
6
7
8static int hash(Object x) {
int h = x.hashCode();
h += ~(h << 9);
h ^= (h >>> 14);
h += (h << 4);
h ^= (h >>> 10);
return h;
}
jdk 1.5 和 1.6 的实现:1
2
3
4
5final int hash(Object k) {
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
jdk 1.7 在 1.5/1.6 的基础之上增加了针对 String 类型的 key 的优化: sun.misc.Hashing.stringHash32 函数会以类似于 Murmur hash 的算法对传入的字符串快速算出一个 32 位 hash 值; Murmur hash 算法对于微小变化的输出扰动非常明显, 其已经在各种新型存储系统的散列功能领域里占领江湖, HashMap 借用该类库, 省时省力, 不用重复造轮子, 可谓再好不过;1
2
3
4
5
6
7
8
9final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
除了 hash 函数的重点治理之外, Doug Lea 还针对 hash 值做取模运算确定下标的逻辑作了极致优化, 并专门抽出了一个方法:1
2
3static int indexFor(int h, int length) {
return h & (length-1);
}
这个方法的设计非常巧妙, 它利用二进制的特性, 根据以下定理将比较高级的取模运算转化为了低级的逻辑与运算:
约定 $x \in N, n \in N$, 令 $c = 2^n$, 则有 $x \ \% \ c = x \ \& \ (c - 1)$
这个定理从二进制的角度上看, 理解起来很直观:
证:
约定 $bin(x)$ 的含义为 $x$ 的二进制表示;
$\because c = 2^n = 1 << n$ $\quad \therefore bin(c) = 1 \underbrace{00 … 0}_{n \ 个 \ 0}, bin(c-1) = \underbrace{11 … 1}_{n \ 个 \ 1}$;
情况 1: $0 < x < c$
此时 $x \ \% \ c = x$ 自不必说, 同时 $bin(x)$ 的位数 (去除前导 0) 必然小于等于 $n$ 位;
由于 $bin(c-1)$ 的 $n$ 位皆为 $1$, 根据逻辑与的特性, $x \ \& \ (c-1) = x = x \ \% \ c$
情况 2: $x \geq c$
设 $bin(x) = \underbrace{a_1a_2 … a_m}_{m \ 位}\underbrace{b_1b_2 … b_n}_{n \ 位}$, 其中 $m \geq 1$;
令 $x = a << n + b$, 其中 $a > 0$, $0 \leq b \leq c$;
则 $bin(a) = \underbrace{a_1a_2 … a_m}_{m \ 位}$, $\quad$ $bin(b) = \underbrace{b_1b_2 … b_n}_{n \ 位}$, $\quad$ $x \ \% \ c = b$
同理, 由于 $bin(c-1)$ 的 $n$ 位皆为 $1$, 根据逻辑与的特性, $x \ \& \ (c-1) = b = x \ \% \ c$
综上, $x \ \% \ c = x \ \& \ (c - 1)$
为了能够确保利用该定理给取模运算提效, Doug Lea 规定了 HashMap table 数组的 capacity 必须始终为 2 的幂次方, 并在各处加以卡控:
1 | static final int tableSizeFor(int cap) { |
所以无论在什么情况下, capacity 一定是 2 的幂次方, 确保满足了定理中的条件, 这便是 indexFor 得以高效计算的前提;
jdk 1.7 在哈希散列这个事情上下足了功夫, 因为 jdk 的开发者想尽力避免 key 寻址冲突迫使 HashMap 退化为链表; 而在 jdk 1.8/1.9 里, 却突然走起了回头路: 只是简单得让 key.hashcode 的高 16 位与低 16 位做一下异或就草草了事了, 其他优化都省了!1
2
3
4static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
我想这么做的原因是 jdk 1.8 优化了 key 寻址冲突排队入链的逻辑, 也就是下面小节将提到的 treeify (树化): 在一定条件下将链表进化为红黑树; 有了这样的优化, HashMap 查询时间复杂度退化为 O(n) 的问题解决了, jdk 的开发者便不再看重 hash 函数的冲突优化了, 所以就把 hash 函数的计算逻辑简化了, 这样能顺便提升一些性能;1
2
3
4
5
6
7
8final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
...
}
另外, jdk 1.8 不再单独拆出一个 indexFor 方法, 而是直接将这一精巧的取模算法内联到各个方法中了, 比如上面代码片段中的 tab[i = (n - 1) & hash]
, 降低了代码的可读性, 不过在非 JIT 优化的环境下可以减少一点调用开销;
除了大名鼎鼎的 treeify 是 jdk 1.8 中人尽皆知的优化, 还有一处不引人注目的小优化其实也值得被提及: jdk 1.8 基于一个与 indexFor
方法使用的同宗同源的小定理, 优化了触发扩容时 entry 重新定位的逻辑;
先介绍一下该定理, 约定 $bin(x)$ 的含义为 $x$ 的二进制表示, $bin_idx(x, n)$ 的含义为 $x$ 的二进制表示中右起第 $n$ 位, 则可以引入如下定理:
若 $x \ \% \ 2^n = k$, 其中 $n \in N, x \in N$, 则有 $x \ \% \ 2^{n + 1} = \begin{cases} k & bin_idx(x, n + 1) = 0 \\ 2^n + k & bin_idx(x, n + 1) = 1 \end{cases}$
该定理其实是第一节中 $x \ \% \ 2^n = x \ \& \ (2^n - 1)$ 的衍生定理, 推演如下:
证: 由第一节的定理可知:
$x \ \% \ 2^n = x \ \& \ (2^n - 1) = k$
$x \ \% \ 2^{n+1} = x \ \& \ (2^{n+1} - 1)$
又知:
$bin(2^n-1) = \underbrace{11 … 1}_{n \ 个 \ 1}$, $\quad bin(2^{n+1}-1) = \underbrace{111 … 1}_{n + 1 \ 个 \ 1}$, $\quad bin(2^n) = \underbrace{1}_{第 n+1 位} \underbrace{00 … 0}_{n \ 个 \ 0}$
当 $bin_idx(x, n + 1) = 0$ 时:
$x \ \& \ (2^{n+1} - 1)$ 的 $n+1$ 位为 $0$, 而前 $n$ 位与 $x \ \& \ (2^n - 1)$ 相同, 则 $x \ \% \ 2^{n + 1} = 0 + k = k$;
当 $bin_idx(x, n + 1) = 1$ 时:
$x \ \& \ (2^{n+1} - 1)$ 的 $n+1$ 位为 $1$, 即第 $n+1$ 位的逻辑与计算结果左移 $n$ 位后为 $ 2^n$, 同时前 $n$ 位与 $x \ \& \ (2^n - 1)$ 相同, 逻辑与计算结果为 $k$, 则 $x \ \% \ 2^{n + 1} = 2^n + k$;
推演完毕;
定理中的表述已经清楚的反映了它在 HashMap 扩容重定位时的含义:
设 HashMap 的 capacity 为 $2^n$, 当发生扩容时, 第 $k$ 个桶内的 entry:
如果其 hash 的第 $n+1$ 位为 $0$, 则该 entry 还应放在第 $k$ 个桶里;
如果其 hash 的第 $n+1$ 位为 $1$, 则该 entry 应该放在扩容后的第 $2^n + k$ 个桶里;
对应的 jdk 1.8 源码如下: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// java.util.HashMap#resize
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
// 通过 e.hash & oldCap 计算第 n + 1 位的值
if ((e.hash & oldCap) == 0) { // 值为 0 放入第一条链
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
} else { // 值为 1 放入第二条链
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead; // 第一条链放在原有的第 j 个桶里
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead; // 第二条链放在扩容后的第 j + 2^n 个桶里
}
除了对于普通的冲突链表, 还要考虑到已经树化的桶, 扩容期间在拆分成两棵子树时, 也要保持逻辑一致, 使用上述定理执行优化; 按理说对一棵树做拆分, 实现上应该比链表要复杂一些, 不过 jdk 的作者做了一个小小的抽象复用, 把复杂性解决了:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23// HashMap
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
......
}
// LinkedHashMap
static class Entry<K,V> extends HashMap.Node<K,V> {
Entry<K,V> before, after;
......
}
// HashMap
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;
}
jdk 的作者对 TreeNode 的注释里写到:
Entry for Tree bins. Extends LinkedHashMap.Entry (which in turn extends Node) so can be used as extension of either regular or linked node.
可以看到, jdk 1.8 之前的 HashMap.Entry 类被改名为 Node
, 这么做主要是为树化后需要继承它的 TreeNode
做铺垫 (树结构中一般叫做节点, TreeEntry 的叫法不太合适), 这个 TreeNode
继承自 LinkedHashMap.Entry
, 从而间接继承了 Node
类, 那么它便具有了像链表一样链接前后节点的功能, 同时这并不妨碍它作为一个树节点拥有左右孩子构建出红黑树, 所以 TreeNode
既是树, 也是链表, 这为扩容时使用上述定理优化提供了便利:
1 | // TreeNode 拆分逻辑 |
总体而言, 相比于之前对每个元素都重新计算一遍下标值, jdk 1.8 改进后的算法在理论效率上还是有显著提升的, 不过由于在实际使用中, 冲突本就不会很严重, 同时我们为了避免扩容, 经验上会根据预估容量在初始化时确定一个合适的 capacity (比如 guava 的 Maps.newHashMapWithExpectedSize(int) 方法), 所以在实际生产环境里性能提升没有理论上那么显著, 但是这种顺手的优化也算是 “油多不坏菜” 吧!
我们都知道 HashMap 是线程不安全的, 而且在以前我们总会被告诫: 如果对一个 HashMap 使用多线程并发操作, 轻则抛 ConcurrentModificationException
异常, 重则 cpu 打满, 请求无响应; 抛 ConcurrentModificationException
是 HashMap 对多线程操作的主动 check, 属于可控情况, 而 cpu 打满请求无响应则是某个桶内的冲突链表形成了死循环链, 程序已失控; 本节重点讨论一下 jdk 1.7 及之前版本死循环链的形成机制以及 jdk 1.8 对于此等情况的避免;
简化起见, 设定一个 HashMap 当前的 capacity 为 2, load_factor 为 1.0, 当前已有元素 a 和元素 b 被插入, 分布如下, 显然, 其已处于扩容前的临界状态:
此时有两个线程 (thread1, thread2) 都想向其中插入新元素, 在插入之前首先它们需要面对的是 resize 方法; jdk 1.7 的 resize 方法中进而调用了一个关键的 transfer 方法:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
// 关键的一行代码
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
注意到上方我注释的那行代码 Entry<K,V> next = e.next
, 假设 thread1 执行完该行代码, 用完了自己的时间片, 线程对应的内核线程状态切换为 READY
, 此时在 thread1 的本地工作内存里, 变量 e 被赋值为 a, 变量 next 被赋值为 b;
cpu 将计算资源调度给 thread2, 然后 thread2 很幸运, 在它的时间片内, 它执行完了 resize 方法的所有逻辑, 并将本地工作内存内的执行结果刷回主内存, 借用上一小节的说法, 我们设定 $bin_idx(a, n + 1) = bin_idx(b, n + 1) = 1$, 则此时 HashMap 的状态如下:
cpu 再次将计算资源调度给 thread1, 下面好戏开场了:
第一步: 从它被 cpu 切换前执行完的那行的下一行代码开始, 跑完 while 循环里剩余的逻辑, 此时在 thread1 的本地工作内存里, 变量 e 被赋值为 b, a.next 被赋值为 null, 此时 HashMap 状态如下:
第二步: 假设此时 thread1 将主内存中的更新 (b.next 被赋值为 a) 及时刷回自己的本地工作内存, 又因为 e = b != null
, 所以 while 循环再次被执行一轮, 此时在 thread1 的本地工作内存里, 变量 e 再次被赋值为 a, 此时 HashMap 状态如下:
第三步: 因为 e = a != null
, while 循环又会被执行一轮, 然而这是异常情况, 本不应该发生; 等跑完此轮 while 循环, 在 thread1 的本地工作内存里, a.next 被赋值为 b, 此时 HashMap 即出现死循环链:
以上便是 jdk 1.7 及之前版本死循环链的形成过程, 我们可以发现, 死循环产生的根本原因是 jdk 1.7 采用头插法更新链表, 导致 resize 方法将冲突链表中的元素顺序作了倒置, 当某个线程抢先将链表转置的结果刷新至另一个滞后的线程本地工作内存时, 阴差阳错的事情就发生了!
所以 jdk 1.8 改成了尾插法, 当链表不再发生转置, 死循环链的情况自然便不复存在了; 当然这并不是说采用尾插法的 jdk 1.8 HashMap 就可以放心地使用多线程操作了:
ConcurrentModificationException
依然是避免不了的, 这是 HashMap 的主动检查;总之一句话: HashMap 没有加锁, 没有同步, 就不可能提供线程安全的环境, 它的定位就是线程不安全的, 无论在算法逻辑上怎么变化, 我们都不要抱以幻想;
负载因子 load_factor 是面试官经常问到的问题, jdk 的作者对这一参数的介绍略偏于定性:
As a general rule, the default load factor (0.75) offers a good tradeoff between time and space costs. Higher values decrease the space overhead but increase the lookup cost.
这只能勉强解释, 为什么不能取值太大或太小, 是因为时间代价与空间代价的权衡, 但是如果面试官锱铢必较, 非要作定量分析: 都是差不多的取值范围, 为什么默认的负载因子偏偏是 0.75, 而不是 0.70 或 0.80? 这就要求我们仔细推敲一下作者的取值依据了;
扩容是为了尽可能避免 hash 冲突, 虽然偶尔的 hash 冲突很正常, 但是无论如何, hash 冲突都是我们不期望发生的事情, 如果不及时扩容, 随着 entry 源源不断得被放入 HashMap, 总有一刻 hash 冲突发生的概率会大到我们无法接受的程度! 扩容, 便成了 hash 冲突概率大到一定程度后的必要措施;
这里有必要说明一下, 目前所说的 hash 冲突, 是指当一个桶里放入两个 entries 时就算, 至于一个桶里被放入三个甚至更多 entries 等更加严重的冲突, 我们就无需再考虑了; 为了便于求解当前的问题, 我将问题定义稍作转化:
假定一个 HashMap 当前的总容量为 $c$, 已经放入了 $n$ 个 entries, 求 $\frac n c$ 的最大值, 使得: 给定一个桶, 满足其当前没有任何 entry 的概率 $P(0) \geq \delta$ $(0 < \delta < 1)$, 其中 $\delta$ 是主观上认为能够尽量避免发生 hash 冲突的临界概率 (注: 桶内没有任何 entry 时, 将一个 entry 放入才不会冲突);
为何是求最大值? 因为在 $c$ 不变的情况下, $n$ 越大, 占有的桶越多, $P(0)$ 必然越小, $\frac n c$ 与 $P(0)$ 呈负相关变化, 给定了 $P(0)$ 的最小值, $\frac n c$ 必然有最大值, 而 $\frac n c$ 的最大值便是 HashMap 理想的负载因子 load_factor;
如何定量研究这个问题呢? 我们应当认定以下事实:
其中事实 1 和事实 2 的描述表征了 “将 entry 放入 HashMap” 的过程满足 n重伯努利实验
的条件, 由此可以自然引出二项分布概率模型:
我们尝试带入相关参数得到问题抽象:
已知 $\delta$ 为常数, 且 $0 < \delta < 1$, $c \in N$, $n \in N$, 求 $\frac n c$ 的最大值, 使得 $P(0)=C_n^0 (\frac 1c)^0 (1-\frac 1 c)^{n-0} \geq \delta$;
这看起来并非是一个可以轻松求解的问题, 需要使用一点等价替换的小技巧, 我花了点时间推导出来, 下面给出完整的求解过程:
解:
$P(0)=C_n^0 (\frac 1c)^0 (1-\frac 1 c)^{n-0}=(1-\frac 1 c)^n \geq \delta$
$\because n > 0, c \geq 16 \quad \therefore (1- \frac 1 c)^n > 0$
定义变量 $a$, 对不等式左右两边同时取 $a$ 的对数, 令 $a > 1$ 以满足单调递增的函数性质, 则有:
$log_a (1- \frac 1 c)^n \geq log_a \delta $
$\Rightarrow nlog_a \frac {c-1} c \geq log_a \delta$
$\because 0 < \frac {c-1} c < 1, \quad a > 1, \quad 0 < \delta < 1$
$\therefore log_a \frac {c-1} c < 0, \quad log_a \delta < 0$
$\therefore n \leq \frac {log_a \delta} {log_a \frac {c-1} c} \Rightarrow n \leq \frac {-log_a \frac 1 \delta} {log_a \frac {c-1} c} \Rightarrow n \leq \frac {log_a \frac 1 \delta} {log_a \frac c {c-1}}$
$\Rightarrow \frac n c \leq \frac {log_a \frac 1 \delta} {clog_a \frac c {c-1}}$
化简到此处看似已无法继续, 不过对其再稍作变形便可豁然开朗:
令 $k = \frac {log_a \frac 1 \delta} {clog_a \frac c {c-1}} = \frac {log_a \frac 1 \delta} {log_a (\frac {c-1+1} {c-1})^{c-1+1}} = \frac {log_a \frac 1 \delta} {log_a (1 + \frac 1 {c-1})^{c-1} + log_a (1 + \frac 1 {c-1})}$
令 $t = \frac 1 {c-1}$, 则有 $k = \frac {log_a \frac 1 \delta} {log_a (1 + t)^{\frac 1 t} + log_a (1 + t)} \quad (0 < t \leq \frac 1 {15})$
令 $a=e$, 此时满足 $a > 1$ 的条件, 则 $k = \frac {ln \frac 1 \delta} {ln (1 + t)^{\frac 1 t} + ln (1 + t)}$
到这里已经很明晰了, 表达式里出现了两个可以替换的等价无穷小:
当 $c \to \infty$ 时, $t \to 0$, 有:
$(1 + t)^{\frac 1 t} \sim e, \quad ln(1+t) \sim t$
则有 $lim_{t \to 0} \frac {ln \frac 1 \delta} {ln (1 + t)^{\frac 1 t} + ln (1 + t)} = lim_{t \to 0} \frac {ln \frac 1 \delta} {ln e + t} = ln \frac 1 \delta$
即: $\frac n c \leq k \to ln \frac 1 \delta$
综上, $\frac n c$ 的最大值为 $ln \frac 1 \delta$
至此我们求得了 load_factor 关于 “冲突临界概率 $\delta$” 的函数, 我们便可以从量化的角度重新审视一下 HashMap 负载因子的取值依据了; $\delta$ 既然被定义为 “主观上认为能够尽量避免发生 hash 冲突的临界概率”, 那么这个概率取多少比较合适呢?
从感性的角度看, 如果一个事件发生的概率大于 50%, 我们便倾向于事件会发生, 反之则不认为; 那么 50% 的 hash 冲突概率可以作为 $\delta$ 的参考; 取 $\delta = 0.5$, 则 load_factor = $ln \frac 1 \delta = ln 2 \approx 0.693$, 好像比 HashMap 默认的 0.75 小了一点;
如果根据 HashMap 默认的 0.75 反过来推 $\delta$ 呢? $ ln \frac 1 \delta = 0.75 \quad \Rightarrow \frac 1 \delta = e^{0.75} \approx 2.117 \quad \Rightarrow \delta \approx 0.472$, 确实比 0.5 小了一点点, 不过在付出了 3% 的概率损耗之后, 却收获了如下好处:
这或许便是 jdk 开发者倾向于使用 0.75 作为默认负载因子的考量, 当然第二个理由有点勉强, 因为我查阅了 HashMap 的历史版本, 早在 1998 年 jdk 1.2 第一版 HashMap 诞生时, load_factor 就已经被默认赋为 0.75 了, 而那时默认的 capacity 是 101 (一个诡异的数字);
面试官对于这样的回答是不是可以不用再追问了?
在第一小节已经提到了 jdk 1.8 里, 在一定条件下, HashMap 会将因为寻址冲突而构造的链表转化为红黑树; 那么这个条件 HashMap 是如何界定的呢? 我们可以看到在 HashMap 的源码中定义了几个阈值:1
2
3static final int TREEIFY_THRESHOLD = 8;
static final int UNTREEIFY_THRESHOLD = 6;
static final int MIN_TREEIFY_CAPACITY = 64;
MIN_TREEIFY_CAPACITY
定义了触发转化红黑树的最小总容量是 64;TREEIFY_THRESHOLD
定义了针对某个桶, 触发转化为红黑树的冲突链表长度为 8;HashMap 中定义了如下方法以支持转化:1
final void treeifyBin(Node<K,V>[] tab, int hash);
该方法主要在 putVal
, compute
/computeIfAbsent
, merge
等涉及插入 KV 的方法中, 在判断条件满足之后被执行;
关于上面提及的三个阈值, 也有一个细节值得讨论: 第一个 TREEIFY_THRESHOLD
被设置为 8, 是经过了严谨的数学计算得出来的:
我们可以从 jdk 的开发者角度去想: 我们为什么要树化一个链表? 因为其对应的桶里元素冲突太大, 严重影响了 HashMap 的查询性能; 冲突本是在所难免的, 偶尔冲突也很正常, 针对这种正常的冲突, 我们没有必要对其做树化; 我们真正要树化的是那些异常情况, 比如因为 key 不合理导致的大量 hash 冲突, 甚至是有人恶意发起的哈希冲突攻击; 我们现在要给定一个阈值, 用以区分正常情况和异常情况, 冲突量小于阈值时暂且判定其属于正常, 不做树化, 只有当冲突量大于等于阈值时才触发树化; 这个阈值要满足, 正常的情况下, 冲突数量达到这个阈值的可能性几乎为 0, 以避免错判而产生不必要的树化开销;
在第一节的讨论中我们已经知道, “将 entry 放入 HashMap” 的过程是服从二项分布的, 但是在一个 HashMap 的生命周期中, 放入其中的 entry 数量 $n$ 是不确定的; 在第一节中, 我们只关心 “目标事件发生 0 次的概率” 这一特殊的场景, 从而简化了参数带入, 并且我们当时的目标并非求得具体的数值, 而是推算 $\frac n c$ 的比值, 此值与 $n$ 的具体值无关, 因此我们成功套用了二项分布的公式求得了最终结果; 但本小节的场景已完全不一样, 不可能继续套用;
不过好在, 当 entry 放入的数量足够多时, 我们可以进一步将 二项分布
转化为 泊松分布
(通常当实验次数 $n \geq 20$ 且目标事件发生概率 $p \leq 0.05$ 时, 可以用泊松分布近似二项分布), 而这正是我们寻找合理阈值的关键模型:
将以上泊松分布的概率分布公式对应到我们具体的问题中去, 其中 $k$ 表示有 $k$ 个 entries 发生 hash 冲突被放入同一个桶中, $P_k$ 表示发生这种冲突的概率; $\lambda$ 是数学期望, 表示一个桶平均有多少个 KV entry ; 由此我们的问题可以抽象为:
已知 $\lambda \geq 0$, 求 $k$ 的值, 使得 $P_k = \frac{\lambda^k}{k!} e^{−\lambda}\approx 0$;
要求解 $k$, 我们须先获得数学期望 $\lambda$; 不过这并非一件容易的事情, 泊松分布是二项分布的近似, 尽管我们知道二项分布的数学期望 $E(x)=\sum _{k=1}^{n}k{p_k}=\sum _{k=1}^{n} k C_n^k p^k(1-p)^{n-k}=np$, 但是这个公式无法直接套用到泊松分布里, 因为泊松分布的 $n$ 是不确定的, 正因此大学概率统计课本上关于泊松分布的题目, 大多是 “根据经验观察” 直接给定一个 $\lambda$, 然后求其他的问题; 不过, 我们可以换个思路, 既然泊松分布的 $n$ 是一个变量 ($n \in N$), 我们不妨根据 HashMap 的特性先建立一个关于 $n$ 的数学期望函数 $f(n)$:
我们手上有三个条件, (1) 第一节所述, 事实 3 中描述的事件发生概率 $p=\frac 1 c$; (2) HashMap 默认的初始容量 $c=16$; (3) HashMap 默认的负载因子 load_factor = $0.75$;
当 $0 \leq n \leq 12$ 时, $c=16$, $p = \frac 1 {16}$, $\lambda = np=\frac 1 {16}n$;
当 $n>12$ 时, 开始触发扩容, 当 $n$ 增加到 $0.75c$ 时, 立即触发 c *= 2
, 由此可做如下归纳:
n 的取值范围 | c 的值 | 补充说明 |
---|---|---|
$\big[0,12\big]$ | $16$ | 不适用于最后一行的归纳 |
$\big(12,24\big]$ | $32$ | / |
$\big(24,48\big]$ | $64$ | / |
$\big(48,96\big]$ | $128$ | / |
… | … | / |
$\big(12 \cdot 2^t,24 \cdot 2^t \big]$ | $2^{t+5}$ | $t \in N$ |
令 $n=12 \cdot 2^t+i \quad (0 \leq i < 12 \cdot 2^t)$, 则 $p=\frac 1 {2^{t+5}}, \lambda=np=\frac {12 \cdot 2^t+i}{2^{t+5}}=\frac 3 8 + \frac i {32 \cdot 2^t}$
于是我们可以建立函数:
我们应当注意, 该函数的建立与 HashMap 的负载因子 load_factor 存在密切联系, 如果有使用者自定义了 load_factor, 函数的参数会发生显著变化; 不过极少有情况需要我们去定制 load_factor, 在上一小节我们已经证明了 HashMap 默认的负载因子 0.75 是一个比较合理科学的值, 本着简化问题的目的, 我们暂且将 load_factor = 0.75 作为固定系数;
$0 \leq n \leq 12$ 时的图像:
显然, $f(n)$ 在定义域上是线性递增的, 且 $0 \leq f(n) \leq \frac 3 4$;
$n>12$ 时将 $n$ 由 $i$ 带入的图像:
依托 HashMap 的特性, 我们发现 $n > 12$ 的情况下, 给定参数 $t$, $f(i)$ 在定义域上是线性递增的; 若将 $t$ 拓展到整个定义域, 可以绘制出完整的图像:
在参数 $t$ 的每一个取值所对应的 $n$ 的取值范围上, $f(n)$ 都分别呈现出线性递增的特性, 且 $\frac 3 8 < f(n) \leq \frac 3 4$;
至此, 关于实验次数 $n$ 的数学期望函数 $f(n)$ 的基本特征已经明晰了; 试验次数 $n$ 是随机而不可预测的, 也不存在一个可以估计的概率, 一种可能的处理方法是取平均值: 当 $0 \leq n \leq 12$ 时, $\overline{\lambda}=\frac {0+\frac 3 4}2=0.375$; 当 $n>12$ 时, $\overline{\lambda}=\frac {\frac 3 8 + \frac 3 4}2=0.5625$; 当然, 在不追求精确的场景下, 完全可以取一个范数来代表, 比如 jdk 在 HashMap 的类注释里就说明了, 在默认 load_factor = 0.75 的条件下, 他们将指定泊松分布的数学期望参数 $\lambda$ 设定为了 $0.5$:
Ideally, under random hashCodes, the frequency of nodes in bins follows a Poisson distribution with a parameter of about 0.5 on average for the default resizing threshold of 0.75, although with a large variance because of resizing granularity.
带入 $\lambda=0.5$ 至上述方程中: $\frac{0.5^k}{k!} e^{−0.5}\approx 0$, 这时就可以求解 $k$ 了, 于是 jdk 在类注释中列举了不同 $k$ 值对应的概率 $P_k$:
0: 0.60653066
1: 0.30326533
2: 0.07581633
3: 0.01263606
4: 0.00157952
5: 0.00015795
6: 0.00001316
7: 0.00000094
8: 0.00000006
more: less than 1 in ten million
由以上计算结果可知, 当 $k=8$ 时, $P_k \approx 0$, 即正常情况下一个桶内的 hash 冲突数达到 8 的概率几乎为 0, 一旦达到, 即可判定此为异常情况, 应当触发树化逻辑; 至此, 我们终于找到了 TREEIFY_THRESHOLD
被设置为 8 的由来;
除此之外, 还有两个与 treeify 相关的参数:MIN_TREEIFY_CAPACITY
是触发树化的 entry 总数阈值, 当被放入的 entry 数量小于此值时, 只做扩容缓解冲突, 不做树化, entry 数量达到此值才会真正触发树化 (能用钱解决的就不用拳头, 最后没办法了再死磕);UNTREEIFY_THRESHOLD
是触发由红黑树退化回链表的桶内 entry 数量阈值, 被设定为 6 而不是更接近于 TREEIFY_THRESHOLD
的 7, 是为了防止发生反复, 导致在链表与红黑树之间来回频繁转化消耗计算资源;
根据经验, 面试官尤其喜欢问一些关于 HashMap 的实现对比, 比如 jdk 1.7 和 1.8 之间的实现差异等等, 其实很多已经在上面的篇幅中提及了, 我再作一个总结:
LinkedHashMap.Entry
与 HashMap.TreeNode
的基类 (具体见 扩容逻辑的优化);lombok 绝对是开发者的好朋友, 其帮助我们节省了大量枯燥的重复代码量, 节约了开发时间; 使用 lombok 有很多技巧, 同时也会存在一些问题, 本文就着重总结一下;
在认识 lombok 之前, 我们一直使用 idea 自带的生成工具构建 getter, setter, 甚至还有拓展插件支持构建 builder 代码; 但自从 lombok 出现后, 包括 builder 在内, 所有这些代码自动生成工具都 “失宠” 了, 毕竟工具再方便那也要生成一大堆代码, 这肯定没有简洁的几个注解看着舒服呀!
关于 @Builder, 有一些小技巧:
@Builder 还有一个大坑需要注意, 我在下面小节中讨论: @Builder 易踩的坑;
一般情况下我们会直接在 @Value / @Data 里打包一众 getter, 不过有的时候我们也可能会对某个字段的 getter 作一些特别定制, 比如以下操作:1
true) (value = lombok.AccessLevel.PRIVATE, lazy =
这里涉及到了 @Getter 的两个选项, value 用以设置该 getter 的访问属性, lazy 用以设置该 getter 是否需要懒加载; value 选项自不用多说, 而 lazy 选项则需要详细讨论一下, 先看一个例子:1
2
3
4
5
6
7
8// lombok 增强前
true) (lazy =
private String str = lazyGetStr();
private String lazyGetStr() {
// heavy cpu compute ignore...
return "";
}
1 | // lombok 增强后 |
可以看见, 使用 lazy = true 选项之后, lombok 直接将目标字段包装成了一个 AtomicReference, 并在 getter 中使用双重检查确保了并发安全, 并调用加载方法实现了 lazy get;
与 guava Supplier 等相比, 虽然其最终生成的代码并不算太简洁, 但毕竟编译之前的源码使用 lombok 注解完全屏蔽了这些逻辑, 考虑到其保证了并发安全, 并且确实实现了懒加载的功能, 所以用 @Getter(lazy = true) 作为懒加载实现方案也是一种不错的选择;
lombok 理论上是不应该被打包的, 因为它的任务都在编译阶段 annotation-processing 做完了, 运行时跑的是早就生成好的字节码, 所以使用 provided
scope 便足矣:1
2
3
4
5
6 <dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<scope>provided</scope>
</dependency>
对于一个 client-server 结构的系统来说, 我们需要给调用者暴露我们的 client api; 在 client 端那些被 lombok 注解修饰过的代码, 会在 annotation processing 下生成与本身源码 “不匹配” 的字节码; 当调用者引入了我们的 jar 包在 idea 中打开源码, 便会在第一行看到如下非常刺眼的提示:1
Library source does not match the bytecode for class XXX
正常情况下, 这不会造成什么实质影响, 但是确实给人一种不舒服的感觉, 总想把这个提示消灭掉才好! 也有的情况下, 调用者可能引用了我们的 client 包后直接编译报错, 如下面小节所提及的问题: maven-compile-plugin 与 lombok 的版本匹配, 这么一看, 把 lombok 暴露给 client 端的问题就有点严重了!
那么, lombok-maven-plugin 就提供了这样的一种解决方案: 帮我们输出 annotation-processing 中生成的中间代码, 并移除所有的 lombok 注解, 让 lombok 对调用方透明: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<properties>
<src.dir>${project.build.directory}/generated-sources/delombok</src.dir>
</properties>
<build>
<!-- 让 maven-source-plugin 打包 lombok-maven-plugin 生成的源码 -->
<sourceDirectory>${src.dir}</sourceDirectory>
<plugins>
<plugin>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-maven-plugin</artifactId>
<version>${lombok.plugin.version}</version>
<configuration>
<encoding>UTF-8</encoding>
</configuration>
<executions>
<execution>
<phase>generate-sources</phase>
<goals>
<goal>delombok</goal>
</goals>
<configuration>
<addOutputDirectory>false</addOutputDirectory>
<sourceDirectory>src/main/java</sourceDirectory>
<formatPreferences>
<pretty/>
</formatPreferences>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
以上插件配置的核心是 delombok, 其作用是处理 sourceDirectory
标签下的源码, 将 lombok 生成的增强后的源码输出到 ${project.build.directory}/generated-sources/delombok
目录下; 所以光有以上配置是不够的, 因为我们需要让 maven-source-plugin 去这个 delombok 目录下, 这样才能真正将其打包到源码 jar 包中; 而 maven-source-plugin 只认 sourceDirectory
标签指定的源文件路径, 这个标签的默认值是 src/main/java
, 于是我们需要覆盖它:1
2
3<build>
<sourceDirectory>${project.build.directory}/generated-sources/delombok</sourceDirectory>
</build>
这样打出的 sources.jar 便是被 delombok 加强处理后的代码了;
这里需要注意的是, 业内还存在一种说法: 如果不使用 lombok-maven-plugin, 只含有 lombok 注解的代码文件会导致调试的时候断点与实际代码行位置不匹配; 事实上这个说法是不准确的, 虽然 idea 的确告诉了我们源码和字节码不匹配, 但是 idea 只是将 .class 文件反编译后和源码作的对比, 那自然是对不上的; 可我们知道编译 java 源代码生成 .class 文件时, javac 是根据最开始的源文件而不是 annotation-processing 生成的中间代码去生成行号映射表, 所以只要我们确定拿来调试的代码和用来编译的代码是相同的, 就一定可以让断点断到正确的位置上;
与此论调恰巧相反的是, 如果我们使用 lombok-maven-plugin 的方式不正确, 反倒是有可能导致断点与源码匹配不上; 在下面的小节 lombok-maven-plugin 灵活指定 sourceDirectory 中我将继续讨论与之相关的 lombok-maven-plugin 引起的 sourceDirectory 混乱的解决方案;
我们可以在工程的根目录下面创建一个 lombok.config 文件, 用作 lombok 的全局配置, 举例如下:1
2
3
4
5
6# 在类构造器上自动添加 @java.bean.ConstructorProperties 注解
lombok.anyConstructor.addConstructorProperties = true
# 生成链式 setter, 将 setter 的返回值由 void 改为 this
lombok.accessors.chain = true
lombok.singular.auto = true
lombok.singular.useGuava = true
更多详细的选项可以使用如下命令查询:1
java -jar lombok.jar config -g --verbose
@Data 与 @Value 都能够帮助我们生成一个标准 java 类的众多基础方法, 包括:1
2
3
4toString();
equals(final Object o);
hashCode();
getter
那么这两者的区别是什么呢? 宽泛得概括一下: @Data 生成的是可变的 pojo, 而 @Value 生成的是一个不可变的 pojo; 详细得看, 主要有如下区别:
区别之一在于其生成的构造器:
区别之二在于 @Value 还打包了一个 @FieldDefaults(makeFinal=true, level=AccessLevel.PRIVATE), 主要是用于自动添加 final / private 关键字, 也就是说 @Value 修饰的类所有的字段默认都会给我们加上 private final, 除非主动在字段上设置属性才会改变:1
false, level=AccessLevel.PUBLIC) (makeFinal=
这一细节我们一定要知悉, 正因此 @Value 默认是不生成 setter 的 (全是 final, 常规操作不能改变), 而 @Data 则会为所有非 final 字段生成 setter;
另外, 由于 @Value 修饰的字段都是 private final 的, 如果我们需要对 @Value 修饰的类使用 json 反序列化工具, 需要尤其注意:
但这并不是说 @Value 就不适用于反序列化, 以 jackson 为例: jackson 有一个注解叫做 @JsonCreator
, 其作用是调用指定的有参构造器去初始化对象; 所以针对被 @Value 修饰的类, 可以使用 @JsonCreator 配合 @JsonProperty 实现属性的值注入; 不过, 如果每个类都要自己去实现 @JsonCreator 逻辑未免显得太繁琐, 还好 jackson 还支持 javabean 标准注解 @java.bean.ConstructorProperties
, 其功能等效于 @JsonCreator, 那么在 上一小节 中已经提到了, 可以在 lombok.config 中添加 lombok.anyConstructor.addConstructorProperties = true
以实现自动为每个类添加 @java.bean.ConstructorProperties
注解;
区别之三在于 @Value 生成的类也是被 final 修饰的, 意味着其不可被继承;
我们可能有这样的场景: 为一个类使用 lombok 生成 builder 方法, 其中有部分字段我们预设了默认值, 最自然的写法可能如下:1
2
3
4
public class Test {
private String str = "default";
}
然而以上代码会被渲染成如下样子:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20public class Test {
private String str = "default";
Test(final String str) {this.str = str;}
public static class TestBuilder {
private String str;
TestBuilder() {}
public TestBuilder str(final String str) {
this.str = str;
return this;
}
public Test build() {return new Test(str);}
}
public static TestBuilder builder() {return new TestBuilder();}
}
很显然, 如果在使用 builder 方法构建对象时没有为 str 赋值, str 就是个 null, 而不是给定的默认值, 对此 lombok 给出的解决的办法是, 为需要默认值的字段添加 @Builder.Default 注解:1
2
3
4
5
public class Test {
.Default
private String str = "default";
}
以上代码确实能使默认值在 builder 中生效, 但是又导致另一个坑: 如果不使用 builder 而是自己 new 出对象, 默认值依旧不生效, 这里需要特别注意;
有的项目一旦引入了 lombok 或间接引入 lombok 的 jar 包, 使用 maven-compile-plugin 编译时会报如下奇怪的错误:1
XXX.java:[xx,yy] error: cannot find symbol
看起来像是编译时没有正常做 annotation-processing, 导致调用本该是 lombok 生成的方法时找不到了; 经过搜索, 发现了 lombok 低版本的一个 bug: stackoverflow 地址, 这个 bug 导致 2.3.2 版本以下的 maven-compiler-plugin 与 lombok 1.14 ~ 1.16 版本发生冲突! 这个 stackoverflow 提问引起了 lombok 官方注意, 并于 lombok 1.16.9 版本修复了该问题: always return ShadowClassLoader. #1138;
所以从当前来看, 解决这个问题的最好办法就是将 lombok 版本升级到 1.16.9+; 当然, 我们在上面的小节中已经提及了该问题: 使用 lombok-maven-plugin, 如果我们有办法消除素有的 lombok 注解, 也就不存在这个问题了;
在上面的小节中已经提到, 如果使用 lombok-maven-plugin 的方式不正确, 就有可能导致断点与源码匹配不上; 具体来说, 就是当我们重新以 delombok 目录覆盖了默认的 sourceDirectory 配置之后, javac 便以新的路径作为源码目录生成对应的行号映射表, 这对于 client 端来说是没问题的, 但是对于 server 端的代码, 我们在调试时用的是 lombok 增强之前我们自己写的代码, 这便与编译使用的代码不一致了, 自然就导致了调试走不到断点里去的问题, 为了能正确调试 server 端的代码, 我们需要配置其在 server 模块不使用 delombok, 从而让 javac 去我们自己的源文件目录编译:1
2
3
4<!-- server 模块覆盖父 pom 的 properties 配置 -->
<properties>
<src.dir>src/main/java</src.dir>
</properties>
另外, 使用 lombok-maven-plugin 还有一个副作用, 其在 ${project.build.directory}/generated-sources/delombok
目录中生成了与我们自己源文件相同签名的源代码, idea 会检测到重复类并导致大量红色波浪线错误提示, 本地无法正常构建, 无法单元测试; 要想解决这个问题, 我们只能使用 profile 来区分不同的编译环境, 本地编译我们使用一套特殊 profile, 不作 delombok, 线上打包发布使用另一套 (不指定 profile 即可), 引导其使用 delombok 作代码增强:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23<profiles>
<!-- mvn -U clean compile -Dmaven.test.skip=true -Denforce.skip=true -P ide -->
<!-- 本地环境用以上命令编译, 可以避免 lombok-maven-plugin 导致的 sourceDirectory 混乱 -->
<profile>
<id>ide</id>
<properties>
<src.dir>src/main/java</src.dir>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-maven-plugin</artifactId>
<version>1.16.22.0</version>
<configuration>
<!-- 本地环境下编译不用 delombok -->
<skip>true</skip>
</configuration>
</plugin>
</plugins>
</build>
</profile>
</profiles>
其实不仅仅是 aspectj, 只要是需要在编译上做手脚的工具, 都可能与 lombok 发生冲突; 毕竟 lombok 的工作时机也是在编译期, 如果不协调出一个执行顺序, 就有可能互相覆盖, 导致编译错误;
lombok 与 aspectj 的冲突属于比较典型的场景, 因为这两者都比较常用, stackoverflow 上有很多遇到类似问题的人; 鉴于这个问题的解决主要在于 aspectj 的配置改变, 本文便不过多讨论, 相关内容请见另一篇文章: asepctj 使用总结;
最近做的项目涉及到 server 端的服务注册与 client 端的服务发现, 其中大量使用到了 zookeeper; 在实践过程中不可避免得遇到了很多问题与坑, 历经数月的打磨与沉淀, 总算是步入了一个稳定的阶段, 至此总结一番是十分必要的;
由于我在项目中直接使用了 curator (v4.1.0) 作为 zookeeper 客户端, 所以这篇文章便叫做了 “curator 使用注意点总结”; 然而, curator 对原生 zkclient 的良好封装, 使得很多原生的坑被处理掉了, 面向 curator 编程是感知不到的, 所以出于刨根问底, 我又写了一篇文章分析 curator 对 zookeeper 原生客户端的封装以及坑的处理: curator 对 zookeeper 的封装逻辑梳理;
无论将 zookeeper 用作什么场景, 在系统关闭时, 都应该调用 zkclient 的 close 接口 (如 curator 的 close() 方法); 或许在部分场景下不调用 close 不会导致业务上的问题, 但这个语义理应被当做规范强制执行;
那么什么情况不调用 close 会导致严重问题呢? 当系统需要创建临时节点时!
举一个典型的例子, 将 zookeeper 当作服务发布与发现的注册中心, 这种场景需要 service provider 将自身信息以临时节点的方式写入 zk, service consumer 订阅 zk 的节点变更, 以及时发现服务提供者的地址; provider 维护的 zkclient 在 zk server 上维持一个 session, 如果 provider 在系统关闭时没有及时 close zkclient, 这个 session 将一直保持直到设定的 sessionTimeout 过期时间, 而后才会通知 consumer 有 provider 节点下线; 那么从 provider 关闭到 sessionTimeout 这个时间段内, 实际上 provider 已经无法提供服务了, 但 zookeeper 却无法及时通知到 consumer, 一旦这个时间比较长且 consumer 没有调用失败后的 failover 或熔断, 此场景将导致服务调用大量失败;
以上例子虽然杀伤力挺大的, 但毕竟还是一把明枪, 至少我们可以很快发现; 而下面我将举的第二个例子, 虽然杀伤力没有第一个例子这么猛烈, 但却是一支难防的暗箭, 不易察觉!
zkclient 与 zk server 的一次正常连接通过一个 session 来维持, 每个 session 都有自己的唯一的 sessionId; 对于 zk 临时节点 (EPHEMERAL) 而言, 有一个重要的特性是排他性: 相同 path 下的节点同一时间只允许有一个 session 占有它, 我们可以通过 zkclient 观察一个临时节点的 stat:1
2
3
4
5
6
7
8
9
10
11cZxid = 0x2641accf49
ctime = Fri Aug 16 17:08:45 CST 2019
mZxid = 0x2664d0fb75
mtime = Tue Aug 20 14:36:43 CST 2019
pZxid = 0x2641accf49
cversion = 0
dataVersion = 14
aclVersion = 0
ephemeralOwner = 0x96cf2ab2799b53e
dataLength = 46
numChildren = 0
其中有一个属性叫做 ephemeralOwner
, 对于临时节点, 这个属性总会被赋值为一个 sessionId, 表示这个节点目前唯一属于这个 session, 若有其他 session 想要设置此节点是不生效的 (删除除外);
那么对于服务注册与发现的场景, 会存在一种情况: provider 系统快速重启, 且没有 close zkclient, 在其试图重新注册相同路径下的临时节点时, 由于之前的旧 session 还没有过期, 根据排他性, 新注册的节点是不生效的; 等到旧 session timeout 节点下线之后, 这个 provider 提供的服务就无法被 client 端发现了; 如果我们只观察业务监控指标, 几乎不会有任何异样, 最多就是 rt 涨了点 (毕竟少了一台机器), 这个问题只有去观察机器指标, 才能发现有一台机器的网络流量和 cpu/load 掉下去了, 而等我们发现时, 可能已经过了很长时间了;
以上两个例子, 如果在系统结束的时候正确得关闭 zkclient, 便可以及时关闭 session, 下线节点, 避免问题的发生!
上一小节中指出了及时关闭 zkclient 可以有效避免 “无用临时节点” 与 “节点无故下线” 的问题, 但是这并非是能够完全避免问题的办法, 有的时候会有一些更极端的情况发生, 比如说: 服务假死, 不响应任何请求, 包括 kill -15 信号; 这个时候我们可能要使用 kill -9 强制杀死进程, 这个过程是不会给进程机会去作 zkclient close 的, 那么就仍然存在可能性导致上一小节提到的第二种情况 “节点无故下线”;
所以我们要如何操作才能确保避免类似问题的发生呢? 其实很简单, 我们只需要主动在创建节点的时候检查节点是否已经存在, 如果存在, 先删除之, 然后再创建, 就可以避免了; 临时节点的排他性只针对 set 操作, 对于删除操作是没有限制的;
如果像如下代码, 指定 path 创建一个节点, 而不带任何数据, 会导致什么事情发生呢?1
2// CuratorFramework zkClient
zkClient.create().creatingParentsIfNeeded().withMode(createMode).forPath(nodePath);
v4.x 的 curator 会默认将 zkclient 所在机器的 IP 地址作为内容写入节点中, 且看下方我截出来的关键源码:1
2
3
4
5// CreateBuilderImpl, 不给定初始化数据便会使用 CuratorFrameworkImpl 给定的 defaultData
public String forPath(String path) throws Exception {
return forPath(path, client.getDefaultData());
}
1 | // CuratorFrameworkImpl, 使用 CuratorFrameworkFactory.Builder 中的 defaultData 作为默认值 |
1 | public class CuratorFrameworkFactory { |
如果这里使用节点的作用是服务注册, client 端接收到了节点变更并试图解析, 读出了这个硬生生的 IP 地址很可能并不符合 client - server 约定的数据协议格式, 那么就只能解析报错了; 所以为了不埋坑, 我们在创建节点时一定要带上我们自己控制的初始化数据:1
2// CuratorFramework zkClient
zkClient.create().creatingParentsIfNeeded().withMode(createMode).forPath(nodePath, data);
zkclient 有两个后台线程: IO 和心跳线程 (SendThread) 与事件处理线程 (EventThread), 均为单线程, 且互相独立; 如果多个事件在短时间内一起到来, 会在 EventThread 中串行执行, 当有耗时的事件回调任务长时间占用线程资源时, 后续的事件便会处于饥饿状态而得不到及时处理, 在一些场景下会发生比较严重的问题 (如节点上下线);
不过需要指出的是, 由于 SendThread 与 EventThread 互为独立, 当事件饥饿现象发生时, 并不会影响 zkclient 的心跳;
在正常情况下, 每隔一个 server 端配置的 tickTime 时间间隔, zkclient 便会向 server 发送心跳以保持 session; 在遇到环境的波动 (网络抖动, 长时间 FGC, 调试断点等) 时, 发送心跳失败, zkclient 会接收到 state 为 Disconnected 的事件, 接下来 zkclient 会尽可能重试 (当然, 长时间 FGC, 调试断点会导致无法重试) 直到再次连接上 server 并重新接收到 state 为 SyncConnected 的事件; 而如果直到 sessionTimeout 都没有重新连上 server, 便会收到 state 为 Expired 的事件, 此时就算环境波动解除了, 再连 server 也会被拒绝, 当下的 session 已经过期, 什么操作都做不了了, 此刻唯一能做的就剩重建 zkclient 了;
curator 提供了重建 zkclient 的逻辑封装, 并使用 RetryPolicy
接口以定制重建的策略; 这个接口有很多实现, 包括:
其中, 使用最普遍的便是第五个 ExponentialBackoffRetry 了, 因为如果真得走到了需要重建 zkclient 的地步, 可能已经发生了比较严重的问题了 (比如网络故障), 一时半会儿恢复不了, 如果使用其他策略频繁重试, 非但无用, 当重试的机器很多的时候反而还会加重负担; 指数退避算法在局域网网路冲突处理中也有着广泛的应用;
采用zookeeper的EPHEMERAL节点机制实现服务集群的陷阱 这篇文章认为, ExponentialBackoffRetry 有一个坑, 它设置了最大允许重试次数为 29, 当发生机房长时间断网时, 有可能重试次数不够导致 zkclient 永久失效; 他提出的问题在于以下代码:1
2
3
4
5
6
7
8private static final int MAX_RETRIES_LIMIT = 29;
private static int validateMaxRetries(int maxRetries) {
if ( maxRetries > MAX_RETRIES_LIMIT ) {
log.warn(String.format("maxRetries too large (%d). Pinning to %d", maxRetries, MAX_RETRIES_LIMIT));
maxRetries = MAX_RETRIES_LIMIT;
}
return maxRetries;
}
那么为什么不是 28, 不是 30, 却偏偏是 29, 我猜可能与下面这段代码有关:1
2
3
4
5
6
7
8
9
protected long getSleepTimeMs(int retryCount, long elapsedTimeMs) {
long sleepMs = baseSleepTimeMs * Math.max(1, random.nextInt(1 << (retryCount + 1)));
if ( sleepMs > maxSleepMs ) {
log.warn(String.format("Sleep extension too large (%d). Pinning to %d", sleepMs, maxSleepMs));
sleepMs = maxSleepMs;
}
return sleepMs;
}
当 retryCount 到了 29, 那么 sleepMs 的取值范围会变成 [1, 1 << 30], 而有符号整数的取值范围是 [-2^31 + 1, 2^31 - 1], 如果 retryCount 再加到 30, 就要发生数据溢出了! 所以这可能就是 netflix 的工程师将 MAX_RETRIES_LIMIT 设置为 29 的原因吧;
从代码逻辑来看, 确实有可能连续 29 次重试的时间间隔都比较短, 导致还没撑到网络故障恢复就彻底死了;
在我看来, 这个指数退避算法可能还有一个坑: 默认的 maxSleepMs 被设置为了 Integer.MAX_VALUE
;
试想一下, 假定 baseSleepTimeMs == 1000, 如果第 N 次重试 sleepMs 的取值正好取到了上限 1 << N, 当 N 等于 12 时, 对应的 sleepMs 已经超过了 1 小时, 当 N 等于 16 时, 对应的 sleepMs 就快要达到 1 天了! 一天才重试一次, 那和死了也没什么区别了; 我认为, 当发生网络故障久久无法恢复时, 理想的重试时间应该控制在 [30min, 1h] 区间内, 我们其实可以设计一个组合策略: 当刚发生故障时, 前几次重试使用指数退避算法, 当连续数十次重试都无效, 时间区间已经增长到 1h 时, 锁定时间区间, 在 [30min, 1h] 的范围内随机产生下一次重试时间, 无限次重试, 直到故障恢复为止, 这样做的好处如下:
下面是一个简单的示例:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17private static final int POLICY_SWITCH_THRESHOLD = 12;
private static final int BASE_SLEEP_RANGE = 30 * 60;
private static final int baseSleepTimeMs = 1000;
private final Random random = new Random(System.currentTimeMillis());
protected long getSleepTimeMs(int retryCount, long elapsedTimeMs) {
long sleepMs;
if (retryCount < POLICY_SWITCH_THRESHOLD) {
// exponentialBackoff retry
sleepMs = baseSleepTimeMs * Math.max(retryCount, random.nextInt(1 << (retryCount + 1)));
} else {
// random retry
sleepMs = baseSleepTimeMs * (BASE_SLEEP_RANGE + random.nextInt(BASE_SLEEP_RANGE));
}
return sleepMs;
}
采用zookeeper的EPHEMERAL节点机制实现服务集群的陷阱 这篇文章最后说自己因为这个诡异的 29 最终放弃了 curator 回到了原生 zk 客户端, 我个人认为这个决定的机会成本实在是太高, 自己实现一个改进策略也就是分分钟的事, 怎能舍得放弃 curator 那么多好处回到原始时代?
每颗心上某一个地方,总有个记忆挥不散;每个深夜某一处微亮,总藏着最切的思量。
小时候,家是一叶温暖的襁褓,我在里面,父母在外面,包裹着它,我浸润在爱的海洋;长大后,家是一根思念的脐带,父母在上头,我在下头,剪断了它,我变成了独立的模样;而现在,家是一层薄薄的纸儿,我在左侧,她在右侧,揭开了它,我踏上了幸福的远航。
“我想问一下,这户口簿上,你的出生地是你的老家吧?” “不是的哦,我的籍贯才是我的老家呀!”空气似乎凝固在了下一刻,还好对面的人儿反应足够 “机敏”,才不至于酿成进一步的尴尬:“哦哦,我了解了。”
这似乎只是一段微不足道的对话,本应该迅速被双方遗忘。可我敏锐得捕捉到了一个微妙的信号,令我不由自主心生感慨!这是我三年来一直囿于内心的一段情愫,以往它仅仅于幽微之处暗自左右我的言行,不算深刻,只如文火慢炖。它期待与我对话的人能够体察到某些措辞的细节,但绝不会展示更进一步的暗示。它潜藏在其他诸如哀伤、思念、惆怅等情感之下,几乎没有存在感,却又为这些纷繁复杂的内心诉求提供了一片良田沃土,令其生生不息,滚滚东逝。
这种情愫,将千言万语化作了一个字:家。
我仔细打量着对面的他,岁月在其脸上留下的手笔,透出了一股沧桑,并散发着阅世经年的气息。一下子,我意识到了刚才对话的 “分歧” 所在:我发现他在提及 “家” 这个字眼的时候,是在说自己的家,他早已成家立业,家中妻儿,与他共同围绕在天伦之乐下,那是他的幸福之家,而当他提及 “老家” 这个词,其实指的是他父母的家。那我呢?我有家吗?有,但这三年来,不常住,恐怕一共也不过几十个日夜。想必谁都能猜的到,无非就是春节、国庆之类的法定节假日。那是我父母的家,也是我的家,我是他们的孩子,这是一个三口之家。所以当他问我户口簿上的出生地是不是我的老家时,我不假思索得说了不:我的出生地当然就是我的家啊,我的老家?那是我爷爷奶奶的家呀。
可是,不常回家的我,离家远去的我,究竟又住在何处呢?于我而言,无论是在北京还是杭州,我的栖息之处不过是从房东那儿租来的十尺地儿。故而独自站在异乡的土地上,我总于内心深处,释放出对口舌的羁绊: 我难以将 “家” 这个词随意得说出口。下班之后,我只是说:“我回去了。” 和别人聊到某件商品,我仅会道:“之前买过,放在我的房间里了。”我小心翼翼得在意着这件小事,生怕越 “雷池” 一步。倘若某天我不小心开错了口,我心里面会感到莫名的失落与难受。同我说话的人或许不会察觉到这般细微的情绪波动,而我也无意惊扰他们,只是在自己心中,尝一把它的酸辛,抿一口它的苦涩,并再一次告诉自己,我是这座城市里的一名漂泊之客。是啊,那只是我租的房子,甚至只能说是我租的房间,安能以家自居?
不过,家与否的问题,真的只和房子的产权有关系吗?若我亲自买下一套房子而不去租住别人的,就可以找到家的感觉吗?曾经有一个故事:一名警察送一位喝得酩酊大醉的富翁回家。警察说:“先生,我送您回家吧。”富翁却摇了摇头:“家?我没有家。” “那前面这栋别墅是怎么回事呢?”“那只是我的房子而已。”所以我心里明白,问题的本质并不在于房子,而在于内心的认同感。说到底这是个幸福与否的问题,找到幸福的归属,才是真正找到了回家的路。离家的人儿,踏出家门的第一步,便也是迈出回家的第一步。然回家之路千般荒凉,吾将何以为梦?回家之路万里蹀躞,吾又何以为归?
你站在马路边等车,我站在窗前望着你。这昏暗的房间里,静谧得只能容纳下我指尖落在键盘的嗒嗒声响。平淡的内心下暗流涌动,我迫切得想证明这世上除了我之外还有其他人的存在。于是凭栏伫立,我贪婪得吮吸着窗外的一切。低头玩手机的你,为司机师傅的两声喇叭所提醒,悠然得登上了车。车影远去,载着乘车的女孩,也载着我的梦,去了你那未知的地方。我虽不曾认识你,但你,却装饰了我的梦。
这梦悬在空中,不愿意轻易降临在我的房间,我的身边。几个月来,我一直在努力寻找着那个 “她”,怎奈命运为我准备的回家之路,却是坐落在一场迷宫之内。路漫漫其修远兮,吾将上下而求索,只望不道别离,惟愿不问归期。
每个周末,我在同父母打电话时,我爸爸总是会问我:“在‘宿舍’干什么呐?”宿舍?愣神之余,我竟发觉这个词使用得着实恰当。何谓宿舍?宿,睡觉也,舍,房屋也,宿舍,一个提供睡觉场所的房屋。定义相当之精准,我现在住的地方的确有柔软的席梦思,以及温暖舒适的被褥。这不就是我租住这个房子最主要的用途吗?
但我还是想问,为什么是 “宿舍” 呢?看来,“家” 这个词带着它与生俱来的敏感与多愁,若心中有个结,不管有意还是无意,料都令人难以启齿。“宿舍” 这个词,让我终于听懂了:我所租住的这个房子,不光我自己不愿意承认它是家,我的父母亦不愿意承认它是家。
七年之前我刚上大学的时候,我和爸妈拎着大包小包,走向了我的寝室。进门的那一刻,正是宿舍的模样。我们一起整理好了我的书桌床铺,在这有限的空间里,收拾出了一个五脏俱全的小天地。那一天,我即将告别父母,开启我的大学生活;那一天,我也告别了我的家,告别曾今那个装满了童年幻想的乐园。不得不承认,从 “家” 的角度看,我的身份角色已然发生了转换:我从一个以家为 “根据地” 的稚嫩学生,变成了一个需要在外地闯荡、独立自主的热血青年。在此之后的每一天,我都可能遇上一个什么人,并在更加之后的某一天,与她组成新的家庭。回想二十多年前,我的父母不也经历过这样的事情吗?这是一个轮回,而轮回的起点,正是上大学的那一天。
三年之前我毕业了,父母再一次来帮忙收拾行李:“我们也和你一起毕业!”告别母校,走出寝室大门,回眸的那一刻,还是宿舍的模样。这番印象,早已镌刻在我心上,如今也同样刻在了我父母的心上:“这就是我的孩子曾住过四年的宿舍!”
毕业后的这三年,我从学校走向社会,从一名学生变成了职场人士。纵然社会身份已经完成转变,但我的住所不过是从校内搬到了校外,除了价格翻了几番、环境稍许改善之外,似也没有其他什么区别了。于我父母而言,宿舍的轮廓似已无法在心中磨灭,或许孩子在身边就是家,只要在外,即是宿舍。上学的时候,心中总有个刻度,一年就是一座年级,四年就是一场大学。我渴望升级,父母亦盼望我成长;而现在上班的时候,刻度仍在,却变了味,变成了多久跳槽一次,变成了何时才能找到女朋友。预期在被不断拉长,变成了被多普勒擀出的时间旋涡,惶惶不知尽头何在。不在他们身边的日子里,父母会否盼望我早日回家,“离开” 社会,重新 “变回” 他们的孩子?不在他们身边的日子里,我立于床前,假装俯首揩去地上的白霜。我,有些想家了······
家是用来回的吗?是,也不是,到不了的都叫做远方,回不去的名字叫家乡;家是用来想的吗?是,也不是,雕栏玉砌应犹在,只是朱颜改。告别父母,置身异乡,现实生活的一大意义,不正是将我们从梦的幻想中拖拽出来吗?逼着我们告别任性的自己,告别彷徨的自己,告别懦弱的自己。回不去的不过曾经的傲娇与玩闹,想不起的只是过去的稚嫩与无知。对未来的憧憬与希望,将灵魂中那片柔弱的芳草地,化作了烈日下舞动的长鞭,令汗水掺和着肿胀,撕裂交织着疼痛,敦促着自己向前迈步,一步一步,找到归宿,找到她,找到心中真正的 “家”!
在这念想之间,我突然明白了,我所身处的,不仅是一场轮回,更是一场接力赛。从大学第一天开始,父母就向我们伸出了手,手中握着交接棒。整个交接过程,短则数十月,长则数十载。我们会犹豫,会徘徊,会在父母的家与自己的家之间抉择不定。生活与爱情的挫折,令我们颤抖了双手而接棒不稳,但父母只会在背后耐心得追随着我们,永不放手。我们还是回去吧?可我们还是回来了!一次又一次退缩的企图,却让我们一次又一次,明晰了自己的位置与处境。时不我待,只争朝夕,下一次与她的相遇,或许就将记录下我们成功接棒的瞬间,记录下我们蜕变的瞬间!
······
我常将我住的房间打扫的井井有条,但我从未有意去装点它。它那么朴实无华得存在着,像柔和的日出日落,像稳健转动的钟表,像磨白无光的铜镜。而一天夜里我真的做了一个梦:一个女孩走进了我的生活,降临在了我的房间里。“家徒四壁” 的外表下,我激动得对她说:“之前我一个人的时候,这里仅仅是我租的房子。而现在你的出现,意味着这里已经是一个家了。我觉得,我们有必要好好装点一下我们的家呢!”
梦醒时分,我微笑着抹去了眼角幸福的眼泪。
]]>开年第一天,我想写篇文章纪念一下我与我的博客之间的故事。
这个博客,它为何而来?它正在表达什么?它将向何处去?这些问题的答案,于我而言,是认真而虔诚的。
社交,并非总是在固定时间维度下的空间活动。常言道,读一本书便是在与作者对话,无论是如雨果这样的文豪大家,还是如我一般渺小的普通作者,阅读他们的文字,即是倾听对方的声音,即是探入对方的心灵。这何尝不是另一种形式的社交呢?
这样的社交,虽不曾面对面把酒当歌,却能剥离所有的外在,坦诚相待;这样的社交,即使对方的肉身早已灰飞烟灭,却仍然能够化解时间的束缚,在精神层面上,实现纯粹的碰撞与交融。
其实,在本博客的 about me 栏目中,我已经详细介绍过了我创建博客的初心以及愿景,那么这篇文章又寓意何在?
我想这大概是理性与感性的关系吧。在那篇 about me 中,我所表达的是一种理性的思考:确认社会角色,探索行为模式,以及树立人生信念,是主观意识的体现;而这篇文章中,我所表达的是一种感性的认知:重现审美倾向,释放内心渴望,以及追寻浪漫的共鸣,是潜意识暗流的涌动。
两篇文章,相辅相成,共同构成了希尔完整的 “博客人格”。
今天是 2019 年的第一天。站在此刻,往前看是曲折而难以忘怀的 2018,往后看是充满抉择、机遇与挑战的 2019。昨晚在我的微信朋友圈里,各式各样的人儿都晒出了自己的 2019 年度目标,也有少部分人自豪得展示着 2018 年的目标完成情况。我作为一名特殊的人类成员,当然也会有自己特殊的表达方式,比如现在正写着的这篇文章。
新加我微信的人总是好奇地问我:“你好像不发朋友圈动态,一条都没有哇?”其实这个问题我已经问过自己千百遍了,可以说这个博客的存在本身就是这个问题的答案。或许,我六年前的遭遇也在其中掺杂了一些似有似无的影响,但时至今日,经过不断的思考与经历,我已经完全明白自己到底在做什么,而不是被一些悲伤的情绪所左右。
朋友圈是人们社交的出入口,人们通过展示自己的生活片段与别人交流,互相了解,增进感情。同微博类似,当我们点开朋友圈下拉刷新,我们的视线将被各式各样的信息占有,有想看到的,也有不想看到的,统统进入我们的眼帘。这是一个被动的过程,我们事先并不能预测将获取谁的信息,这些全靠系统投喂给我们。
而我所做的,只是找回快被人们遗忘在从前的另一种表达方式:博客。与微博,朋友圈不同,访问博客是一个主动的过程,我们很确定我们在接受谁传达出来的信息,既然我们愿意进入他的博客,这本身就意味着对他的期待,意味着对他往日博文的肯定。而每次刷朋友圈,有时的确会收获一些惊喜与价值,但它来自于谁,来自于哪个时刻,都是不确定的事情。“朋友”们的动态此消彼长,只要信息的总量保持稳定,没有人会刻意关心谁的动态少了,而谁的动态又多了。久而久之,刷朋友圈变成了一种猎奇,一种感官刺激,真正的人际关系反而模糊了。
微博、朋友圈是为移动端设计的表达渠道,其特点是短小,简单,字数限制,换句话说可能便是浮于浅层,难以深入。这固然降低了使用者的门槛,圈揽了大量用户,但也导致信息生产的质量问题。于是,我们经常看到各种转发的公众号文章,这些公众号在一定程度上弥补了朋友圈信息的质量问题,但是公众号,本质上讲不就是另一种形式的博客吗?在微信的流量生态下,知识分子为了事业上取得更好得发展,不得不转换战场,以跟上目标读者的脚步。于我而言,博客并非我谋生的渠道,所以我并不需要适应移动互联网时代下的新环境。
像我这样另辟蹊径使用传统模式表达自我,其实还有另外一点好处。在没有屏蔽拉黑的假设前提下,我们发表的朋友圈动态会推送到每一位联系人的手机上。我无法预知自己即将发布的内容会不会受到大家的欢迎,我只知道如果有人不喜欢看到我的信息,我的动态也会被推送到他的手机上,接着他很有可能就会把我拉黑。反观写博客,我就不需要承受如此的心理压力,没有人知道我在思考什么,没有人知道我在感慨什么,除非你主动访问我的博客。另外,互联网是连接世界的,访问我的博客根本就不需要认识我,你喜欢便会驻留,不喜欢便会离开,于我而言没有任何区别。
当然,我的博客是我内心深处的秘密,我从不轻易公开博客地址,这里是我肆意想象,恣情意淫的天地。而这博客维护了这么长时间,我能察觉到,唯一一名对我保持长期关注的访客,是 google 的爬虫引擎:在 google 上检索相关关键词便会出现我的博客文章。与之相对应的是,百度从来都没有 “感知” 到我的存在,当然这也在情理之中,我使用 “敏感网站” github page 发布博客,使用境外的顶级域名,没做过 SEO,也不作 ICP 备案,政治正确的百度对此一定讳莫如深。
一般而言,人们总是倾向于表达并推广自我,以获得别人的接纳,满足被认同的心理需求,实现内心的价值归属。而我却不希望将我的博客曝光于世界之下,更不打算在微信朋友圈展现自己的面貌,这可能是属于我个人的审美倾向。在我的其它文章中,也会表达出类似的观念:我一直在追求的,是一种缺憾,一种惋叹,心花怒放,而孤芳自赏,红杏含苞,却伏于墙隅,只待世人“蓦然回首,那人却在,灯火阑珊处。”
既然我已决定不轻易公开博客地址,百度也不打算帮我推广介绍,我自然就不用指望会有人关注我了。如此一说,我这个博客的意义,怕是离“社交”的定位有点远了,我对世界唯一打开的一扇门,在实质上却等效于是关闭的。在现代社交网络的世界里,离群索居的我,谁怜一片影,相失万重云?
皎皎明月,皓然当空,她的美令众人侧目,她的美引嘉赞无数;一颗寒星,孤悬天际,遗世乎凡宇间的热闹,独立于红尘中的来往。众人赏月之际,会不会有一位走心的少年,裹挟着探索与好奇之心,向深邃的夜空一瞥,令那株隐隐若现,那抹微乎缥缈,在其双眸之内留下属于自己的一粒像素?
对于可能存在的 “黯淡结局”,我其实自打建站之初就已做好了心理准备。“兰之猗猗,扬扬其香。不采而佩,于兰何伤?”我节选韩愈《猗兰操》中的片段作为自己博客的副标题,影射自己的志向:就算没有人欣赏我,于我而言又有何可悲?只需做自己便好,哪怕世间万事万物都背对着我,我和我自己也永远面对着面!
好在,生活中有一样东西在万物中最为珍贵:希望。希望是一种可能性,就算发生的概率再低,只要不是零,它就配得上叫做希望。墨菲定律说,只要事情存在可能性,那么给足了时间,就总有它发生的一天。我在这里写博客不正是这样的一件事吗?看起来似乎已没什么通达之径指向我的世界,可实际上这个网站一直在与互联网保持着连接!谁又敢说将来的某一天,一定不会有人误打误撞在浏览器里输入了我的博客地址,踏进我为 ta 打开的这扇门呢?
华夏民族的历史是一部苦难之作。落第、贬谪、离亲、亡国,令愤懑、忧郁、悲恸、疾首在文人骚客的心中冲撞回荡。而转身之念,挥斥方遒,激扬文字,脍炙人口的篇章顷然奔泄。文人本也萎靡柔弱,但在自己的文字面前,却显万丈长情。晋出陶渊明“问君何能尔?心远地自偏”,南现李煜“一江春水向东流”;唐成刘禹锡“斯是陋室,惟吾德馨”,宋就文天祥“留取丹心照汗青”。即便命运再残败不堪,即使世道再腐朽不治,一腔情潮,只要找着了文字,便拥有了寄托之处。何谓寄托?于古人言,那是将自己人生的价值追求全数倾注,以不朽的文字为载体,向世人宣告:“零落成泥碾作尘,只有香如故。”他们深信不疑,就算“众人皆醉我独醒”,冥冥之中,也定有真命知己,在未来注定的某一刻,为自己的文字潸然落泪。幸运的是,前辈们的菁华之作已悉数为后世所珍,并至此处为吾感念,遂谨以告慰各在天之灵。
于是从小学,中学,到大学,我们品读诗书,向灵魂深处注入了中华文化的基因。在文学大家的感召下,潜移默化之中,我亦对文字产生了一种深深的依恋,一种模仿前辈的冲动本能般在心中酝酿。一个人如果有千言万语无处诉说,文字将成为其最好的发泄口。就在这一瞬间,前辈的指引,内心的悸动,诉说的渴望,各种复杂的情愫糅杂在一块,一个属于我自己的博客诞生了。
今时不比往日,黯淡了刀光剑影,远去了鼓角争鸣。和平时代的我,在互联网的熏陶之下,没有了壮士断腕的悲烈,多出来的则是宁静的思考,婉转的歌啼,以及,细腻的想象。我想象着,一位读者,于不经意之间闯进了这片田地,柔和的屏幕前,ta 规律得眨着眼儿,一行一行在文字间扫动、跳跃,时而停留在某一段落反复咀嚼,时而又蹙眉思考,有着共鸣之音,亦有着质疑之声;我想象着,在时间的某个角落,历史的必然性教我在这里结识朋友,遇见知己,有没有可能收获爱情呢?
我愿意等着那个 ta。在那一天到来之前,每一篇文章,都将是庄严的仪式,都将是虔诚的呼唤。一个人能做的事情实在有限,然而一个人能做的事情有时却可以超越时间的界限。“一个人”的社交,其实从来都不是一个人,而是你,我,以及历史星空中的点点繁星,共同筑起的,珍贵的情感纽带,深切的情感寄托。
]]>当我试图注销重新登陆时, 惊悚的一幕发生了: 黑屏! 近乎绝望般得按下电源键强制重启, 等来的居然是字符登陆界面, 讽刺的是我输入账号密码后, 还登陆成功了……
字符界面下, 所有的命令都能正常执行, 可是进不了 tty7, 这系统实质上就是废的呀! 我猜测 gnome 的核心组件大概是被我误删了…… 无奈, 思忖着崩溃前我到底做了什么, 也只能重头再来;
2018 年 10 月 30 日, fedora 发布了新版本: feodra 29, 令无数 linux 忠实拥趸跃跃欲试;
历经各种曲折, 耗费几近半年时间, 我终于赶在 2019 年前折腾出了一个有着勉强模样的 fedora, 装在了我的笔记本上, 甚为欣慰; 这半年间, 我试过了 fedora 26, 27, 28, 以及最新的 29, 一次又一次得蹂躏着我入手没多久的 ssd, 遭遇了各种有厘头, 无厘头的 bug, 缺陷, 宕机, 在 “几乎打算放弃” 的边缘上游走了数月; 可是我心里面总是咽不下这口气, 不把 fedora 搞定我浑身不自在, 就是特想做成这件事, 说是为了装逼也好, 学习也罢, 反正我整个人都豁出去了, 不达目的坚决不罢休, 死不瞑目!
于是, 今天终于有了这篇文章, 好好总结一下, 也好好纪念一下;
添加 rpm fusion 与 FZUG 源 (以 fedora 29 为例):1
sudo dnf install https://mirrors.tuna.tsinghua.edu.cn/fzug/free/29/x86_64/fzug-release-29-0.1.noarch.rpm
如果是其他 fedora 版本, 直接参考 官方文档 即可;
输入法是万物之源, 没有中文输入法, 一个中国人如何正常使用 fedora?
搜狗公司也算是个有情怀的公司, 为我们广大 linux 用户开发了 linux 版本的 sogoupinyin; 但是美中不足的是, 它只提供了 debian 系列才能使用的 deb 包, 而没有提供 redhat 系列的 rpm 包; 为了将其移植到 fedora, 我作了一些尝试与努力, 并专门总结了一篇文章: fedora 安装 sogoupinyin 输入法;
fedora 自带的 vim 是功能简化的 vim, 或者说是功能增强型的 vi:1
2> sudo rpm -qa | grep vim
vim-minimal-8.1.450-1.fc29.x86_64
而在日常脚本编写中, 一个 minimal 的 vim 是不够用的, 我们需要完整版的 vim:1
sudo dnf install vim-enhanced
一般装 shadowsocks client 不会使用 yum / dnf / apt-get 之类的工具, python-pip 直接上:1
sudo pip install shadowsocks
其运行命令的选项也是十分的简洁:1
2
3
4
5
6
7
8
9
10# -c CONFIG path to config file
# -s SERVER_ADDR server address
# -p SERVER_PORT server port, default: 8388
# -b LOCAL_ADDR local binding address, default: 127.0.0.1
# -l LOCAL_PORT local port, default: 1080
# -k PASSWORD password
# -m METHOD encryption method, default: aes-256-cfb
# -t TIMEOUT timeout in seconds, default: 300
sslocal -s xxx.xxx.xxx.xxx -p 8388 -k "*************" -b 127.0.0.1 -l 1080 & 1>/var/log/shadowsocks.log 2>&1
重定向一下命令的标准输出流与标准错误流, 方便故障时排查问题;
不过, 还是不建议直接在命令的选项里配置参数, 更好的方式是加载配置文件, 方便管理也不容易遗忘:1
2
3
4
5
6
7
8
9
10
11# /usr/local/etc/shadowsocks.json
{
"server": "xxx.xxx.xxx.xxx",
"server_port": 8388,
"local_address": "127.0.0.1",
"local_port": 1080,
"password": "*************",
"timeout": 3000,
"method": "aes-256-cfb",
"fast_open": false
}
1 | sslocal -c /usr/local/etc/shadowsocks.json 1>/var/log/shadowsocks.log 2>&1 |
另外, 这里只是 shadowsocks client, 要真正翻出去还需要 server 端的配合, 我在另一篇文章里有具体介绍: 在裸镜像上搭建 shadowsocks server;
网易云音乐客户端虽然谈不上是必装的关键软件, 但是毕竟人家是个有情怀的公司, 专门为 linux 用户出了客户端, 而我之前也拿到过云音乐部门的 offer, 无论如何我对 netease-cloud-music 都是有感情的;
同 sogoupinyin 输入法一样, netease-cloud-music 客户端美中不足的是它只提供了 deb 包, 故而我就需要做一些移植工作了, 总结文章链接如下: fedora 安装 netease-cloud-music;
无论是 fedora, 还是如今高版本的 ubuntu (18.04 及以上), 默认使用的桌面环境都是 gnome 这一通用主流的标准了, 而管理 gnome 的最佳工具是 gnome-tweak-tool:1
sudo dnf install gnome-tweak-tool
桌面主题个性化的精髓就在于 shell 拓展, 各种方便的工具可以帮助我们展示个性, 优化交互等;
之前折腾 fedora 28 时, 我下载收集了一些实用的拓展工具, 并统一整理到百度网盘上; 而现在 fedora 29 将 gnome 的版本升级到了 3.30, 这些插件对于 gnome 的版本要求很高, 连中版本都要对上号, 3.28 的插件在 3.30 的 gnome 环境下竟然不能兼容;1
2> gnome-shell --version
GNOME Shell 3.30.1
现在我打算放弃这种思路, 毕竟以后 fedora 还会继续升级, 就算我现在将百度网盘上的插件都更新为最新的, 也难保以后能兼容更高版本的 fedora; 所以更好的思路是寻找一个稳定的代理, 去帮助自己实时获取最适配的各种插件;
这个理想的代理就是 chrome, 让浏览器帮忙下载, 这需要两样东西:
1 | sudo dnf install chrome-gnome-shell |
这两样东西准备好后, 就可以去 gnome-shell-extension 官方网站 下载插件了; 除了默认带有的, 目前我又安装了如下几个插件:
fedora 默认情况下的窗口只展示关闭按钮, 而我们需要同时将 关闭, 最小化, 最大化 三个按钮都展示出来才符合使用习惯; 这个设置十分简单, gnome 的一条命令搞定:1
gsettings set org.gnome.desktop.wm.preferences button-layout 'close,minimize,maximize:appmenu'
以上配置会让 关闭, 最小化, 最大 三个按钮从前到后分别出现在窗口的左上角, 十分符合 linux 用户 (以及 Mac 用户) 的使用习惯;
(1) fcitx
输入法守护进程肯定是要在开机时就启动的, 毕竟打字的场景无处不在; 为了让 fcitx 顺利开机启动, 我竟然费了好些波折:
fcitx 是一个 XWindow 程序, 使用 dbus 通信; 而 dbus 是一个仅限于普通用户 session 的进程; 我们配置开机启动, 传统的思路都是使用 systemd 生成对应的 service (早期的系统使用 system V init), 但这种方式仅适用于使用 root 用户启动的非 XWindow 程序, 如果碰到一个带图形界面的程序, 例如 fcitx, 会报类似如下的错误:1
2(WARN-31472 dbusstuff.c:197) Connection Error (/usr/bin/dbus-launch terminated abnormally with the following error: No protocol specified
Autolaunch error: X11 initialization failed.
没法启动 X 进程, 和 dbus 无法通信, connection error;
查了一下, 对于这种用户级别的 XWindow 程序, fedora 有非常友好的解决方案: 将需要开机启动的应用的 .desktop 启动配置文件复制到如下目录中:1
cp -a /usr/share/applications/fcitx.desktop ~/.config/autostart/
fedora 会在开机后某一个合适的时间点, 回调 ~/.config/autostart/ 下面的所有应用, 从而做到开机启动;
(2) shadowsocks client
fedora 终端的命令行提示符默认是和标准输出一样的普通白色, 没有任何区分, 这会导致一个问题: 当屏幕上有上一条命令的输出时, 无法明显得区分本条命令输出的起始位置, 看起来都是白花花的一片, 很费眼睛, 所以我们需要个性化, 酷炫而显眼的命令行提示符;
linux 命令行提示符的样式是通过一个叫 PS1
的环境变量控制的, 默认情况下, 它在 /etc/bashrc
中被初始化; linux 不建议使用者直接修改 /etc/bashrc
, 而是建议将定制逻辑放在 /etc/profile.d
目录下, /etc/bashrc
会回调该目录下的脚本;
所以这里需要创建一个类似于 /etc/profile.d/PS1_reset.sh
:1
2# 重新定义命令行提示符的展示样式
export PS1="[\e[m\e[1;32m\u\e[m\e[1;33m@\e[m\e[1;35m\h\e[m \e[1;36m\w\e[m\e[1;36m\e[m] \$"
重新定义 PS1
即可;
我上面给出了一个具体的样式案例, 关于它的详细含义就不多说了 (这个相比正则表达式有过之而无不及之处), 我就上一个效果图吧:
搜狗公司也是一个有情怀的公司, 不过除了情怀之外, 我觉得还有责任在里面; 试想: 如果 sogoupinyin 不推出 linux 版本, 那 linux workstation 在中国的发展会增添多少阻力? 连输入法这一最频繁使用的工具都搞不定, 纵使我们这些拥趸再忠诚, 也只能算是痛苦郁闷的拥趸, 而不是真心诚意, 心甘情愿得使用 linux, 享受 linux;
所以, 我觉得搜狗公司的程序员一定会认为, 开发 linux 版本的 sogoupinyin 是一项神圣而伟大的光荣事迹!
与之前在 fedora 上安装 netease-cloud-music 类似, sogoupinyin 输入法官方也是只提供了 ubuntu 版本, 而没有 fedora 版本; 民间一些 fedora 爱好者打包了 fedora 环境下的 sogoupinyin rpm 版本, 但是存在严重的 bug (怀疑内存泄露), 当输入字符达到一定量时, 便卡死无法继续输入, 只能重启 sogoupinyin 进程;
很明显使用民间的版本是无法高效而专注得工作的, 所以我只能模仿之前 netease-cloud-music 的路数, 下载 ubuntu 下的 deb 包, 解压提取里面的关键内容自己安装了;
(1) 停止 ibus 守护进程
ibus 与 fcitx 这两个 linux 输入法架构同时只能有一个运行, 而 sogoupinyin 使用的是 fcitx 架构, 所以必须停止 fedora 默认的 ibus-daemon 进程;1
ibus exit
(2) 安装 fcitx1
sudo yum install fcitx
同时配置 fcitx 的启动环境:1
2
3
4# .bashrc 添加如下变量
export GTK_IM_MODULE=fcitx
export QT_IM_MODULE=fcitx
export XMODIFIERS="@im=fcitx"
(3) 下载 sogoupinyin 软件包:
我这里已经收集了搜狗最新发布的版本 (2018.4.24): 下载地址, 可以选择 2.2 或者 2.1 版本, 都不会有内存泄露的 bug 存在;
然后就是和 netease-cloud-music 差不多的步骤了:1
2
3
4# 解压 deb 包
ar vx sogoupinyin_2.2.0.0108_amd64.deb
# 将最核心的 data.tar.xz 复制到系统目录中
sudo tar -Jxvf data.tar.xz -C /
下一步比较重要: 将 sogoupinyin 库导入 fcitx 中, 以使 fcitx 识别并统一管理;1
2
3sudo cp /usr/lib/x86_64-linux-gnu/fcitx/fcitx-sogoupinyin.so /usr/lib64/fcitx/fcitx-sogoupinyin.so
# 检查一下是否具有执行权限
sudo chmod +x /usr/lib64/fcitx/fcitx-sogoupinyin.so
(4) 安装 fcitx-configtool
(5) 启动输入法1
2
3
4# 启动输入法核心驱动
fcitx
# 启动 sogoupinyin 面板
sogou-qimpanel
在以上安装过程中, 可能会遇到一些依赖问题需要解决 (主要是启动 sogou-qimpanel 时), 我已经将这些依赖都收集起来了: 下载地址;
依次安装即可:1
2sudo yum localinstall lib64qtwebkit4-4.8.2-2-mdv2012.0.x86_64.rpm
sudo yum localinstall libidn1.34-1.34-1.fc29.x86_64.rpm
在本次安装过程的探索中, 还遇到了一些比较深的坑, 这里也一并总结一下:
yum erase ibus
而不是 yum remove ibus
便可以避免, 我之前在 fedora 27/28 上测试好像是没问题的, 但是在最新的 fedora 29 上 erase 和 remove 没有区别, 命令执行完桌面系统就崩了; 我查了一下 manual 文档, fedora 29 直接将 yum 重定向到 dnf, 并在其中说明 erase 被 deprecate 了, 请使用 remove;生日,纪念着生命的诞生,度量着生命的年岁。
生日是快乐的,蛋糕前许愿的人儿又长大了一岁;生日是严肃的,静坐闭目冥想中反思着又一年的收获;生日是痛苦的,产房中撕裂的叫喊化作了人类文明延续的沉重代价。
从早到晚,我今日收到生日祝福的顺序如下:妈妈,招商银行,中国联通,东吴证券,爸爸,支付宝,google。
我已经有十五年没有为一年中这“特殊”的日子在心中产生丝毫的波澜了,上一次高兴得过生日还是 2003 年我十岁的时候。后继的时日里我如一尊入定的石佛,不喜不悲,平和而淡定得踏过了十五座春秋,似一本书将十五页纸轻轻翻过。这几年在步入社会之后,我稍稍令其变得与其他日子有些不同:每当这一天来临,我便以闭目冥想的方式反思自己走过的又一年,而今天,下班后回到家,我正在写着这篇文章。
早几年前 QQ 还没有销声匿迹的时候,我记得当一个人的生日临近了,QQ 便会向这个人的所有联系人推送,告诉大家这位朋友的生日快到了,快去送上祝福吧。可惜了一旦填写了生日,QQ 就不允许取消设置,只能修改。为了避免 QQ 引导别人向我送祝福,我只好在我生日前差不多一个月的时候,把我填写的出生日期给改到半年后去,等到我生日过了差不多一个月,再给它改回来,这样我就成功令大家“遗忘”了我。此中乐趣,不足为外人道也。
我曾仔细得思考过我这种奇怪心理的动机:说白了,我之所以会如此平静,是因为我不觉得,在这一天之前,与这一天之后,我的生活会有什么变化。该付出的努力一丝不能松懈,该承受的压力一毫不会减少,每一天都在迎接新的生活挑战。如果这一天的心情激昂而上,第二天难免又回落至常态,到头来只是徒添怅惘。
我发现,与我这种心境类似,却又形成鲜明对比的是处于耄耋之年的老人:他们也不需要在乎生日了,来自晚辈长寿云云的祝福,于当事人不过虚言罢了。年岁至此,于夕阳之下拄杖前行,每一天都可能是最后的绝唱,只有当清晨沐浴在新的日光之下,才是从心底生发出的由衷感谢。
在我的微信朋友圈里,时不时得有人晒出自己生日聚会上的美照,幸福的表情洋溢在脸上。对于她们来说,那应该是发自内心的快乐吧。想必大部分人都不会如我一般,竭尽全力得让自己消失于众人的视野之下,竭尽全力得将自己的心境保持为长久的低谷,深涧的细流。每个人的认识,期待,追求都不一样,我们需要尊重自己的感受,更要尊重他人的感受,每个人都有属于自己的仪式,去迎接自己生命中的特殊之刻。所以,如果今后有一个人可以和我一起生活,在她的生日那天,我一定会用心策划,给她惊喜,满足她的期待,而在我生日的那天,我只希望能和她如平日里一样,自然而充实得度过这一天。
是的,纪念生日,每个人的方式千姿百态。而在这万态之间,有一种最为特殊,自古以来穿越了整个人类文明:我们向世界问候的第一声啼哭。生命在其中酝酿,母亲在倚头微笑,怀中的婴儿可知这微笑背后的辛酸泪?
2018 年 11 月 9 日,刘强东的妹妹刘强茹,在临床生产时因羊水栓塞不幸去世。她是一个高龄产妇,43 岁怀孕,这或许是其发生意外最致命的因素之一,不过在此之上,还意味着另一个事实:即便再有钱,在生育这件事上,也和普通人面临着同样的危险,同样也有医生回天乏术的时候。
我们每个人活着来到这个世界,并非理所当然,这是我们的母亲冒着生命危险换来的。在几百年前,黑暗的中世纪,当女孩得知自己怀孕,要做的第一件事情是:立遗嘱。即便医学技术高度发达的今天,我们的分娩死亡率也没有低到令大家觉得可以忽略不计。就算母子平安无恙,但分娩的痛苦,现代的女性同几百年前的女性也没有什么差异。我的一位同事,其夫人几乎被折磨了两天后才终于分娩成功,他激动得说道:要爱妻子一辈子,恨这个小男人一万年!
故曰:孩之生日,母之难日。如果我们正在为自己庆生,又或者我们正在闭目回顾,我们都不能够忘记,很久之前的今天,我们伟大的母亲,冒着生命的危险,熬过了巨大的痛苦,诞下我们,并赐予了我们生命。而妈妈又总是第一个为我们送上生日祝福的人,不是因为她们忘不了当年的痛苦,想让我们也一同记住,而是因为她们看到自己的孩子又长大了一岁而真心感到高兴,这就是母爱!
感谢妈妈!
2018.10.27 ~ 2018.11.05,一周多一点儿的时间,却如同历经了一岁枯荣。
在爱情这场人生游戏里,我刚迈出第一步,却又将脚缩了回来。技能的缺失,经验的空白,让我这么一位初来乍到者感到了前所未有的迷茫。
但我没有选择,逝者如斯夫,犹豫不前将导向不可逆转的败局。我只能不断摸索,凭借着本能的反应,以及单身这么多年来建立的固有认知,以自己 “独特” 的方式闯入 “这场游戏的主战场”,开启 “战斗 - 挫败 - 总结 - 提升” 的玩家晋级之路。
感谢这一周的时间,有你从我的生活中走过。
这一周仿佛很漫长,有紧张和期待,有兴奋和快乐,也有焦虑,以及,最后的惆怅,如同上帝赠予我的一本小小恋爱指导手册。酸甜苦辣,别样滋味,悉数为我品尝,不啻一段动人的爱情故事。
说实话,我竟真有种恋爱的感觉,这感觉在我身体里如小鹿乱撞,令我心神不宁,令我寤寐无眠。现在结束了,一切终归平静,多情应笑我,早生华发。
人海茫茫,一缘难求。相遇如同分手埋下的伏笔,甜蜜似是哀伤垂下的鱼钩。生活无常,聚散流沙,前路漫漫,蹀躞万里。但我相信,只要真诚不减,挫折不过谈笑间的樯橹;只要信念不折,困难只是霓虹中的蜃楼。
生活暂告一段落,而来路方长。愿你我都能易装换马,再度出发!
失去女朋友是什么感受?大概就是现在傍晚时分,秋雨微停,空气清爽,男男女女走出办公楼,享受下班后的惬意,而我却再也不能联系她,与她共同分享这等美妙的时光。
我为何会有失恋般的惆怅?六年前,我的第一段感情持续了两个月。之后的哀思如一江春水滚滚东逝,尽管岁月的洗刷冲淡了愁情,但时至我来到杭州,依旧细若游丝,挥斩不断。而这一次,仅仅是一周之缘,她并不算我的女朋友,我们甚至也只有过一面之谋,当是萍水相逢而已,可固执的我还是没能摆脱多愁善感的毛病,身陷过往的覆辙之中。
在我的理解里,一段关系中的男女,只有双方都尽心尽力,才能将关系维系下去。倘若一方走了心,很快便能被另一方所察觉。有人曾对我说,初次见面是互换一个初始印象,并决定是否可以尝试交往;第二次见面应该察言观色,考察互相的修养与品味;第三次见面可以深入的交流,探究对方的理想与追求。三次见面后如果互相都感觉不错,双方就可以专注得投入到一段感情中了。可是,我认为感情并不是个有章可循的手艺活,每个人的性格迥异,所能接受的交往方式也千差万别,固化的内容未免教条。比如我就倾向于在第一次见面交流中就去了解最关键的基本面,如果我觉得没有问题,值得交往,我就会全力投入其中。琐碎的细节与瑕疵,只要没有让我感到足以影响我对她大方向上的判断,我都不会介意,求同存异是我一向的态度。
作为一个一旦点燃,便热情如火的我,所对面的交往对象可能是一个腼腆内向的女孩,但这不是什么问题,我并不期望对方一定要主动,我只希望我的主动能换来对方真诚的回应,我明白尽心尽力并不是具体到行为上的刻意要求,而是每个不同的人,在自身的性格与特质下从内心不由自主生发出的情感与反应,其是否尽心尽力,关系中的另一方是可以清晰感受到的。我会与她分享乐趣,分担烦恼,探讨真善美,感悟人生,她快乐我便快乐,她难过我也难过。总之,她会完全融入到我的生活之中,我的生活从此不可能存在没考虑到她的地方。
既然每个人所能接受的交往方式不可一概而论,那么必然会有人无法接受我这种火焰般的热情。保守的女孩将本能得排斥我,扑灭我刚刚对她升腾起的热情。没关系,感情本就是双向的,轻轻得退出就好。不过,我绝不会因为女孩的保守性格而改变我追求她的方式,事实上,我也改变不了,天性使然。能自然而然走到一起才算是缘分,没有缘分而强求缘分,何苦这般逼迫自己,扭曲了个性,到头来还不是画虎类犬,落得一个分手的狼狈结局。
固然,第一次的见面无法面面俱到,就算开始互相吸引,也总可能有那么一天,我们发现对方其实不是自己想找的那个人。失望过后,就要面对如何退出的问题。我其实直到现在也不确定,从上周五之后,她几乎就是突然地,不再认真得回复我的微信消息,这是否就是她寻求结束关系的方式,只是她不肯由她那一边主动把话挑明,她希望我能明白她的意思并好自为之?反正从那之后,我便陷入了莫名的焦虑之中,寝食难安。我的耐心被一点一点消磨,直到我主动提出结束关系前的那一刹,彻底化为乌有。
折好的纸飞机刚一掷出便栽倒在地;睁开眼的小鹿刚一迈步就失去重心;醉人的烟花刚一绽放就殒身天地;剔透的雪花刚一落地就遁于无形。我不安地发现,一对恋人的未来之路,竟是荆棘遍布:生活爱好,生活习惯,消费观,房子,车子,彩礼,婚礼,生育,教育,养儿,养老······每一关背后都安置着致命的陷阱。两个人之间的分歧就如同枝枝桠桠的树木,每一处分叉,都是一次考验,每一处分叉,都可以是分手的理由。我曾经历过的两段关系,无一不是早早夭折,上帝似乎在看我笑话,看我这存在先天缺陷的性格,如何演绎一段段滑稽的情感闹剧。要知道,每一关考验都艰难险阻,然在任何一关失足,都意味着前面闯过的关卡都前功尽弃,只能从头再来。
我现今的状态,大抵还徘徊在第一关:沟通,因为我的前两段经历,应该都没有通过这一关。第一段感情,在对方提出分手之前,我甚至都没有意识到问题出在哪里,最后还是对方隐约为我指出了一些方向。第二段经历,也就是这一次,我有了沟通的意识,但是由于自己的性格原因,最终还是酿成了遗憾。在发现微信渠道受阻的情况下,我尝试着给她打电话,没有料到电话对面传来的声音却是别样的动人,别样的真诚,让我不敢相信这和微信里的她是同一个人。果然,挂掉电话,再次回到微信中,她还是和原来一样冷落我,这大概就是理想与现实的距离吧,我最终选择了放弃。女孩的心思难以揣度,像孩童吹起的泡沫,远观无法窥见其细腻的纹理,近看又在亲昵的触碰下乍然破裂。当遭遇无法解释的行为,不着边际的猜疑开始在我心中作祟:就像上面小节中所描述的那样,我至今都无从得知她在微信里疏远我的原因,莫非是她真的很忙?莫非是信号不好?莫非她正经历每个月必定来拜访的烦恼?不猜了不猜了,恐怕误会本身都已经被我误会了。
只能说,这第一关沟通,是后续所有关卡的基础,没有沟通就无法化解矛盾,没有沟通两个人就很容易走向不同的分叉,越走越远,最后形同陌路。这段关系过后,我突然变得能接受打电话这种沟通方式了,我大概想通了,微信与电话,无非是形式的区别,不是战略与战术的关系。只要电话里能谈的来,终归是有机会拉近彼此的距离的,我必须得接受这种方式,两段关系的失败结局已经给了我足够的警示。下一段缘分的到来,我希望能真正把握好,闯过第一关,并有机会接受后续更多关卡的挑战。
但我内心还是有有所不解,一段关系中,男孩作为相对主动的一方,女孩作为相对被动的一方,我去追女孩子,我尽量使用女孩乐于接受的方式去接触她,这无可厚非。但是,如果女孩对男孩真的有好感,并渴望与他交往,难道不应该考虑一下男孩的感受?你说你对我的印象还不错,可是你如此冰冷得回应我发给你的微信消息,就算我对你爱的深沉,也无可避免被你的冷漠所伤害。我不相信一个还对我有好感的女孩子会作出这般无情的事情,所以我只能选择退出,否则每一天对我来说都是煎熬。
再继续往更深处回味,我发现了更多之前未曾察觉的事情。我把自己置身于一个假设性的场景之中:如果她没有如此良好的家境,如果她在杭州没有房子,然后把我们这一周的经历回放一遍,我能坚持到第几天?这真是一个毒辣的问题,令我突然意识到我内心的潜意识究竟在渴望什么。结论很明显:我大概在上周三,上周四的时候,就会修正我对她大方向上的判断,然后主动提出结束交往。甚至还可能更糟糕,我们第一次见面交流后,我就不会向她要来联系方式,这段关系根本没机会开始。
人是如此好吃懒做的生物。若不提及女孩的家世,如果她在杭州没房,我大概是非常笃定,自己将通过事业上的提升,通过自身的努力,自力更生,安居乐业。但是你突然告诉我有这么一条捷径可以走,我就这么着了魔,就这么满怀期待得与她开始交往。我自己在心中描绘出了一个美好的她,仿佛我们志趣相投,我们互相欣赏。但事实终归是事实,我们没法欣赏各自的兴趣爱好,我们交流话题的范围变得越来越狭窄,可我还在这里苦苦支撑,不甘放弃,是什么原因在背后指使我愿不放手,我现在算是恍然大悟了。而对于她,就没有这些无端的干扰,因为优越的条件本就是属于她自己的,我不过是一个普通的男孩,如果已经没有了共同语言,对我的兴趣自然就结束了。只是她没那么决绝,留下我一个人又挣扎了几天,最终无力地熄灭。
所以说,物质条件总是以一种不经意的方式,扰乱人们正常的感情交往,它看不见却很强大,强大到令人身不由己,甚至无法自拔。我很庆幸,我及时抽身出来了,或许是因为我不够 “坚定”,没有那种不撞南墙不回头的 “精神”?我觉得,大概是我还相信自己的经济实力,即便没有捷径可走,脚踏实地的努力也不算遥远。而想想那些嫁入豪门的女孩们,其中不乏终日以泪洗面者,但她们没有能力,也没有勇气对生活说不。
当我发现我难于放手的根本原因后,我感觉浑身轻松了许多。是的,终究我还是要靠自己的本事吃饭,其他的幻想都不要有。
秋雨绵延,辗转难眠。虽然之前已悟出些许道理,但是,我感觉这一周的伤心遭遇,还是有些细节没有被正视,前因后果,不明不白。我怕以后重蹈今日的覆辙,故挑灯伏案,借着夜的透彻与深邃,欲将其和盘托出,重新审视一番。
星期六
下午我们第一次见面,相谈甚欢,我们交流了各自的一些状况,聊到了各自的生活乐趣,比如喜欢的电影之类。我向她简述了我对艺术的追求,以及我对于跨领域跨学科综合涉猎的推崇,等等。末了,我询问了她对于我这些生活爱好,人生观念的看法,她笑着告诉我,她觉得我很优秀。这是向我抛出了橄榄枝,我有些兴奋,感觉新生活就要来临了!
可是我被兴奋冲昏了头脑,竟然忘了反过来问一问她的生活爱好,以及一些追求,态度之类的问题。至此,我对她性格爱好的了解仅限于见面前提前了解到的:她属于文静型的女孩,喜欢旅游。
这个草率的开头,似乎为一周后的结局埋下了伏笔。
星期日
早晨九点醒来,我赶忙在微信上向其问好,并配上可爱的表情。心里想着以后要早点起床,不能等到她都起床好半天了我才和她打招呼。她以同样可爱的表情回应我的问好,这真是一个美好的开端。
白天她要参加一个专业培训,我不再打扰她。晚上等她下课了,我给她打了一个电话,我们聊了一个半小时,谈天说地,非常愉快。在电话中,我不加掩饰得表达了昨日与她见面的喜悦之情,以及对能够与她交往的期待。她似乎是半开玩笑得回复我:“我感觉你说的好像有一点点······ 哈哈!” 我知道这当然不是什么表白,所以没多想,自然也就没从她的话中读出其他的信号:她其实是一个十分保守的女孩,直到经历了后续的事情,我才回味了过来。
星期一
开始要上班了,我早早地起床向其问好,并试探性得问她,应该不会对我每天早上的问好感到厌烦吧?她笑着回答说不会。同时,我和她协商,工作的时候我们就专注得工作,等晚上下班了再尽情聊天,这些我们都非常愉快得达成共识了。
等晚上我们都下班后,再次进入了二人的聊天世界中。我们聊到了李咏的去世,聊到了他最后的遗言:“没有遗憾,只有不舍”。就着这些话题,我们探讨了爱情观,家庭观,事业观等等。我感觉我说的比较多,真的是有感而发,她也跟随着我的节奏,就其中的一些内容表达自己的看法,这样的聊天节奏与聊天氛围真的让人满意。
这个晚上,我突然在思考一件事:我是一个文字爱好者,有时甚至无悔于花费我全部的节假日时间,只为倾吐内心的感慨。不久之前的国庆节,我就几乎使用了七天之间的全部空隙,完成了我最近六年来心路历程的总结,只求探究一个真实的自我。我在想,我要不要把这篇文章给她看一下,毕竟这是我最真诚的情感抒发,尽管文章里提及了前几年我生活上的挫败,但通过整篇文章,的确可以更加深入得了解我这个人的价值观与人生观。
犹豫了一下,我决定暂时还是不要让她知道,我不敢保证她读完以后的感受,所以还是先度过一些美好轻松的时光吧。
星期二
白天照旧,与昨日类似,一切都显得十分顺利与和谐。
我这两天会有意识地搜集一些有趣而值得探讨的事情,这样我们彼此就能通过交流观点而加深了解。这一天我搜集到的是一个五岁小朋友的入学简历,小朋友非常优秀,简历很棒,我正好可以借机与她讨论讨论子女教育的问题。
晚上和她交流相关话题时,也聊的挺精彩的,可以说面对这些问题我们的基本理念是一致的。可就在这一晚我突然隐约发现了一个细节:她从未唤过我的名字,更准确的说,是我的姓名。我每天从早上第一句问候,到后面每一句对其生活的关心,我一定会呼唤她的名字,以表示亲切。诚然,她的姓名包含了三个字,所以当我截断姓氏后只取其名,两个字读起来显得朗朗上口。可是我的姓名就只包含两个字,如果全读出来,会显得过于正式,似有 “直呼其名” 之嫌。只取我的名字那一个字,又显得过于亲昵,倘若把这一个字念作叠音,更是不适合刚交往的对象。我不知道她是不是也这么想的,不过这里确实有难处,我能理解。
从这里其实也能引申出一个问题:三个字的姓名比两个字的姓名在社会交往中更加容易被自然得称呼,更容易从心里上被接受。所以在给自己孩子起名字的时候,尤其要注意这一点。
星期三
这一天开始有一点小波折了。我如往日一样,早早得送上清晨的问候,但是她并没有及时回复我,等我已经到公司了,她终于发消息告诉我她睡过头了。刚才还隐隐有些不安的我看到她的回复之后长舒了一口气,否则我还真担心出了什么事呢。
这天晚上,我们聊的话题比较发散,没有一个明确的主题,有时候谈到一些社会现象,有时候又聊到各自的爱好。于是,我终于了解到了,平日里,她很喜欢看韩剧,日剧,以及一些其他的综艺节目。我有一些发愣,因为娱乐领域我是真的不了解,也真的不感兴趣。一时间有种奇怪的感觉爬上了我的身体,我不知道这是一种什么感觉,但我又知道这是一种什么感觉。
后来没多想了,正如我在 滋味 那一篇中所描述的,我不愿意想那么多,有种力量在我身后不允许我想那么多。
星期四
保持节奏,清晨准时同她问好。然而这一天开始成为整个交往过程的转折点。
首先是,她早上回复我的问好不再带上表情了,只是三个字:早上好。这也无妨,我心里一直告诉自己不要特别在意一些细节。接着是,我中午向她分享了我的午餐内容,她没有回复我。关于这一点,我其实也能够理解,这几天中午我都时不时得分享一些琐碎的东西给她,而中午大家吃饭午休的时间都很短,若天天中午都抽空与我互动,次数多了是会有些不耐烦,只要换位思考,设身处地得想象一下便能体察那种感觉,所以说只要晚上我们下班后能愉快得交流我就心满意足了,其他的不必强求。
果然,晚上我们如期开始了微信上的交流。这天晚上和星期三类似,也是没有什么突出的主题,你一句我一句的,从天上聊到地下。不知道节奏是被谁带偏了,没一会儿我们竟然又聊到了兴趣爱好上面。说真的,经历了昨晚的交流,我现在不是特别想与她聊电视剧这个话题,但是这一天她似乎有点主动,主动追着问我会看一些什么样的电视剧。这让我很尴尬,我是真的很少看电视剧。可想而知,这一轮对话后,气氛瞬时凝固起来。短时间内她没有再发送消息,我隔着屏幕似乎感觉到了她淡淡的失望。“你有没有觉得我这个人有点无趣?”我以一种自责的口吻打破了临时的僵局。“没有啦,每个人的兴趣爱好不同是很正常的事情嘛。”
是的,我们每个人出生于全然不同的环境,自然而然得塑成了不同的生活习惯与兴趣爱好。我一直追求的是一种求同存异的关系,但是何谓同,何谓异?究竟该如何拿捏同与异的分寸,我之前并未深入细致得思考过,我内心一直持有的是这样的观点:态度,态度是关键。生活中有些事情是我们的心之所向,我们愿素履以往;有些事情虽谈不上喜欢,但却理解喜欢这些事的人,并认同追求这些事给 ta 带来的价值,或许在长期的交往过程中,我们也会在潜移默化之中同样爱上这些事情;还有些是我们不太理解,也不感兴趣的事情;当然必不可少的,总也会有些事,令我们十分反感,完全不能接受。
所以,有一个非常简洁而高效的筛选方法,就是逆向筛选。比如说,我非常反感抽烟,反感饭局与酒桌文化,反感各种网络游戏,反感类似直播、抖音这种极其浪费时间的娱乐工具。讨厌与反感是一种十分强烈的态度,是每个人不可妥协的底线,底线相互冲突,泾渭分明,也就没什么好谈的了。在逆向筛选之后,剩下的选择就不可能通过如此粗暴的方法实现了,但有些还是比较好判断的。比如共同的爱好,这将为我们对其印象加分不少,所以相当一部分有缘人皆源于同行。即便没有共同的爱好,但若能互相认可各自追求的价值,也是一件十分幸运的事,我想说,这就是我所理解的 “同” 的含义。君子和而不同,在价值追求的态度与原则上保持相同,而追求的具体手段与方式,可以不尽相同,这就是 “异”。当然,其他与态度及原则无关的生活习惯,那些经过交流后很容易作出妥协与改变的东西,也都属于 “异” 的范畴之内。
我似乎是在以一种理想化的方式解释我的择偶观,是定格某个静止的时刻,然后从这一截面横切进去作观察与判断。而然人是会变的,一旦考虑加入时间这一参量,整个求解过程便瞬间复杂起来,这将引出另一个艰深的领域,那是关于如何经营感情,如何处理好两性关系的深奥学问,已经超出了我此刻正在面对的问题。
这天晚上,我执拗地想理清楚一件事情:看电视剧对我来说究竟意味着什么?首先喜欢肯定是算不上。那么它应该被归类到剩下三个中的哪一类呢?这似乎不是一个可以被独立归类的事情,而是需要将电视剧的具体内容以及观看时长一并考虑进去才能综合给出的结论。就像电影一样,我喜欢电影,但不可能全局通吃,所有都看,我也只是看我感兴趣的,觉得有价值的电影。首先要肯定,电视剧是一种影视艺术,同电影在本质上是一致的,只是它的叙事不用像电影那么紧凑,它可以使用很长的篇幅来细致得表现主题,细腻得刻画人物。我喜欢高质量的经典电影,是因为其以精炼的篇幅表现深刻的主题,或者说,我可以利用相对短暂的时间完成对一部完整作品的欣赏。但电视剧过长的篇幅,容易花费大量的时间,让人陷入其中。我很少看电视剧的一个重要原因,就是我不想陷入对某一部电视剧的无尽追逐之中,我能体会追剧的痛苦。但如果遇到了质量优秀,主题深刻的电视剧,追逐一下也无妨,比如去年的作品《人民的名义》,这是我这两年看过的唯一一部电视剧,感觉相当满意。说白了,我反感的并不是电视剧本身,而是无止尽地耗费大量时间追逐低质量的肥皂剧,这其实与把时间花费在抖音、直播等上面没有本质的区别。
可惜当天晚上我并没有将这件事像上述文字那般理得如此清楚,我只是想象了一下如果以后我们真的在一起了,在同一个房间里,她在看韩剧,我在读书或者作文的场景,用一句话形如就是:各做各的事,没有共同语言。我是如此得冲动以至于提前亮出了我的底牌:我告诉了她我国庆节写过一篇探究真实自我的文章,我希望她能看一下。是的,如果已经察觉出什么了,最直白的做法就是大声对她说出来。但我选择了如此含蓄的方式来表达自己,含蓄到其实我自己都需要好好揣摩一下我这么做的用意何在:我正在被物质条件的优越所魅惑,我做不到由我这边主动把话挑明,我希望她能给个结论,在看完这篇文章之后。周一的晚上我之所以犹豫要不要给她看这篇文章,是因为我那时还拿不准她对这篇文章的态度。而这天晚上我亮出了文章,是因为我好像猜到了她可能的表态了。
现在看来,我那天这么做的确是一个无厘头的决定,我既不知道她在看什么具体内容的日剧韩剧,也不知道她一周花费多少时间在追剧上面,我好像是直接假想出了一个天天花费大量时间追逐各种无意义肥皂剧的她,但是又不忍心向她提出结束交往,最后以一篇文章的形式把这个决定权交给了她。
“今天不早了,明天我把这篇文章的链接发给你吧。” “好的。”
星期五
既然现在还没有得知最终的结论,所以我一如往常,向她问好。
中午的时候,我不再打扰她了。但是她倒是反过来主动给我发了一个新闻,讲的是前两天重庆公交车坠江案的责任认定结果,并附带了一句话:“午安”。简洁之余透着一股匆忙,似乎证明了我昨天的想法。但是她主动向我发信息,不知是不是觉得没有被我如期骚扰好像有一点反常?
晚上下班了,我给她发了一些消息,并琢磨着在什么时间点以什么方式发给她那篇文章比较合适。想着想着,我发现好长时间了她还没回我消息,见鬼了,不会是和同事聚餐了吧?我手里把玩着手机,揣摩着她现在的状态。我还没发给她文章链接呢,难道她已经察觉出什么了?中午那句午安到底向我发出了怎样的信号?
“我不小心把手机设置成免打扰模式了,没看到你的消息。你该不会生我的气吧?”终于,我看到她的回复,又是长舒了一口气。我怎么可能会对和我交往中的女孩生气?我只会感到难过,失望,以及惆怅,我是不容易被激怒的。没有犹豫,我发送了一个愉悦而期待的表情给她。
这就是微信,充满了各种假装与粉饰,你永远无法窥探到消息背后的人究竟是以一种怎样的心情在发送,唯一不能掩饰的就是回复消息的时间,这大概是微信交流中最诚实的信号了。
如果你正和你的对象交往,在下班后本应该愉快聊天的时间,收不到对方的消息了,你接下来本能得会怎么做?我就在这里时不时得看一下她有没有回复,时不时得刷出了最新的失望。她就在那里做着自己的事情,两个小时过去了,她突然发现不小心把手机设成了免打扰模式。
我和她大概都不是善于与对方真诚交流的人。当她中午对我频繁的消息感到厌烦时,她选择了使用沉默而不是沟通来向我传递她的不满;当我对她长时间不关心对方的消息而感到心寒时,我选择了使用一个愉悦的表情来表达我的 “不在意”。看来我比她更狠,她好歹还是间接得让我知道了她的想法,而我用这样的 “一腔热情” 将我的失望封藏得滴水不漏。
可该聊的天还是得聊,她至少问了我有没有生她的气,无论如何她都关心了一下我对她的态度。我半开玩笑得问她是不是在看什么引人入胜的节目,她笑着回答我说,她现在只是在和我聊天,顺手捣鼓捣鼓其他一些零碎的事情而已。这听上去多么像是对自己刚才的走心作出的补偿,而这种方法对于我这样的人又是如此奏效,似乎一瞬间又填补上了我内心失望的空白,让我顿扫所有的阴霾,重新拾起了热情。
但与往日不同的是,在尽兴聊天之余,我还保持了一份敏锐的心思,我在观察时机,在寻找一个恰当的时间点向她展示那篇文章的链接。不管今晚聊得多么欢畅,我都不会忘记我昨天和她说过的事,她忘记了不要紧,但我不能忘,哪怕我知道促使她回忆起昨日的对话可能并不是一件让她感到高兴的事。现在我们的关系正处在一个微妙的时刻,昨日与今日的反常似已令互相心生嫌隙,而我已决定摊出底牌,执意要交由她给我一个判决结果。
“这是我的那篇文章,如果有空的话,可以看一下嘛?”我总算是迈出这一步了。这只是早或晚的问题,而不是可以回避的问题,口头语言的交流不是万能的,要进入一个人内心最深处,ta 的文字是比 ta 的语言更加深刻的入口,尤其是像我这样敏感的人。我的文字承载了我的深情,我的文字承载了我不可言说的秘密。
她不再回话,毕竟阅读需要时间,审判亦需要时间。当明日第一道阳光射入我的瞳孔,不管她的结论如何,在这段关系中,我都将成为一个全新的自己了。
星期六
或许这是我最后一次向她问好,或许来日方长,那我便只管做好当下的自己,从一句清晨的问候开始。
其实我也能够猜的到,她没有回复。接下来会发生什么,我也很熟悉了,六年前的往事,历历在目,言犹在耳。我正襟危坐,只轻轻地问了一句:“可以告诉我你的决定了吗?”
无论她告诉我能理解还是不能理解,能接受还是不能接受,我都想好了该怎么回答。但是临近中午时分,她的回复竟让我哑口无言:“我还没来得及看你的文章呢。”愣神了半晌,我才吐出一句:“不着急不着急”。难道她是真的没有看吗?我有点不敢相信。如果这是真的话,这个结局简直比我预想的两种情况都要糟糕:她不愿意接受我的文字语言沟通,不愿意走进我为她打开的我内心世界的大门。但愿只是我想多了,希望她是真的没来得及看。
前几天当我还没有像现在这番纠结时,我一直在计划着周末我们两人的约会事宜。但是从周四晚上开始持续到现在的这个糟糕样子,我是怎么也说不出口了。我们的关系就像一盏明火殆尽,余烟袅袅的蜡炬,在等待着双方谁出来表个态,吹灭最后一丝火星。可我真的不忍心就这么不明不白得了结这段关系,我真想和她把事情说清楚。我习惯于使用文字表达,但文字不是万能的,就像此时此刻,如果她不愿意接受这种沟通方式,纵有千词万句,也只剩苍白无力,还不如注视着她的眼睛,用几句话传达我想表达的一切!
“我们明天可以出来一起见一见吗?”但是消息刚一发送,我就笑了:我其实第一天就知道的,她最近每周日都有一个专业培训要参加,从早到晚。所以星期日她指定是没时间出来约会的,这是上帝不给我机会。
“我原本以为我们会周六见面的,因为周日我要参加培训。但是你一直没跟我约,我以为你周六要加班呢。”事到如今已经活脱脱演成了一部悲剧,原来她心里面一直是这么认为的。我现在算是明白了,我们两个人的思路根本就没搭在一根弦上,我发现她并没有理解我这个问题的意图:“可以告诉我你的决定了吗?”当然这也怪我,我从来就没有把我的担心,我的内心想法详细得透露给她,我只是给了她一篇文章链接,只是委婉得对她说:“这篇文章中描述的我,可能会令你感到失望,但他确实是真实的我。”也许她没意识到,这篇文章对我来说有多么重要,所以否定这篇文章就是否定我,肯定了这篇文章,我才能构筑起我们共同交往的基础。可是在她眼里,这篇文章的链接和我之前给她分享过的微信公众号文章链接,似乎没有太大的区别。
当意识形态发生了冲突,悲剧就不可避免。如果和我交往中的女孩发给我一篇她写过的文章,这对她意味着什么,对我又意味着什么,简直再清楚不过了。能感受到的,一定会牢牢把握,感受不到的,只能任凭其错过。
星期日
我之所以还在向她问好,是因为并没有谁站出来给这段摇摇欲坠的关系定一个结论,而我答应过每天早上都要和她问好。但我已经力不从心了,我只发送了文字,而不再带上任何表情。照旧,她也不会回复我的。我觉得文章她肯定是看了,只不过接近七千字的篇幅,她未必有耐心将其看完。
我来到理发店,准备了却伴随我两个月的长发,手起刀落,一地断首,好生痛快!但这也止不住我肆意增长的惆怅,剪不断、理还乱的,是我的自作多情。
我决心再给我自己最后一次机会:既然无法当面交流,那我就给她打一个电话吧。
我突然想起了美国那位心理学家的著名公式:“信息的总效果 = 7% 的语言 + 38% 的音调 + 55% 的面部表情”。按照这个结论,如果使用微信作纯文字交流,93% 的信息将会流失耗散,只有不到十分之一的信息能够传递给对方。姑且不论这三个百分比的准确性,透过数字本身,公式的提出者其实想要强调的是沟通中情感反馈的重要性。
“你好呀, 在忙嘛?”
收到礼物的惊喜,遭遇挑衅的愤怒,触景而生的愁哀,与人分享的快乐······我们一定会承认,林林总总的感觉,心情,反应,很多时候没法用准确的文字来形容,正所谓只可意会,不可言传。语言与表情,总是相辅相成,相生相伴,所以无论是曾经的 QQ,还是当下的微信,文字沟通之余,我们都离不开表情包的使用。“一图胜千言”,一旦没有了表情,我们将在光秃秃的文字之下,陷入揣摩对方情感的无尽挣扎之中。
“哦没有,我刚吃完晚饭,正在回去的路上。你在家吗?”
心灰意冷,过去的我曾一度避免在通信软件中使用表情,因为表情的滥用,也是一种奢侈,一种放纵。过度依赖表情的人,最终将不得不以表情才能证明自己的情感,文字反而成为了表情的附庸。六年前,在我告别了初恋之后,这漫长的岁月里我孑然一身,再未觅得一人足以让我放下束缚,去发送那个曾经令我陶醉,如今却令我心碎的可爱笑脸,直到上一周,我与她相见。
“嗯嗯我在家呢。这两天我在微信里似乎感觉到你有些不高兴,我想打个电话关心一下你的情况。” “没有啦,昨天你没有约我出来见面,下午我就找我闺蜜逛街去了。只是我不喜欢三心二意,所以和闺蜜专心逛街时我从来不看手机的,不然逛街也逛不好,和你聊天也聊不好,你说是吧。”
久违的声音,如一口刚哈出的暖气抚慰了寒窗上的霜花。我好像没认真听清楚她说的具体内容,我只是听出来了她的语气里并没有丝毫的冷漠。一对情侣之间的甜言蜜谈,内容本身已不是重点,只要两个人持续得让彼此感受到自己正在意 ta,这样的交谈就能持续下去,这个过程就是感情的培养。微信的表情包可以满足这样的需求,电话里语音的交流更能够抵达对方的内心。
“实在是不好意思,我原本在周二周三时就计划着周末和你一起出来玩的,但是这两天的事情可能引起了一些小误会,说实话我看到你一直没回话,真的心里面很焦虑很担心。” “抱歉呀,我好像觉得你的声音和之前不太一样,我感觉到你受委屈了。”
卸下了微信的马甲, 我的真情实感再也无处遁藏。之前被滤去的重要信息,此刻正毫无保留,毫无阻挡得向她传递:如果你愿意一层一层得剥开我的心,你将会感受到我对你的在意,感受到我的压抑和不能自已。当此时我若站在你面前,必是 “执手相看泪眼,竟无语凝噎!”
“如果我有哪里做的不够好,你一定要帮我指出来呀,我怕你对我的不满意,我却全然不知道。”
六年前失恋的教训我怎敢忘记,曾经多少次,伸出手想抓住熟悉的背影,睁眼竟是梦醒。放下所有的面子,我只想挽回一段濒临结束的关系,哪怕无法改变最终的结局,至少我也要比六年前的自己表现得更好。付出实际的行动,不仅仅是为了她,也是为了我自己。
“不知道那天我冲动得发给你那篇文章,是否引起了你的不适?” “哦哦,那篇文章,我只看了前面几节,说实话你的语言确实让我感觉有一点点不舒服,我觉得你文中描写你的生理反应太过真实了,你可以描写你的感受,但是这个实在是有点······”
就是说,我猜中了开头,却没有猜中结尾。她确实去看那篇文章了,也确实没有看完,我猜对了。但她所吐槽的内容点与角度,我着实没有预料到。我本是如实描写,并未有夸大事实以博眼球,流露的是我的真情实感。她这般反应,也许意味着她在某一方面的拘谨态度,相关的话题讨论可能触碰到了她敏感的神经。在这一 “禁忌” 领域,她还无法接受 “激进” 的理念,还需要慢慢引导,打开心结。我相信,这是生命中最神秘且最容易引起好奇心的领域,保守的人儿,只是不经意间为自己的心坎设下了屏障,只是还未发觉屏障背后的精彩与奥妙。
“所以说,你是觉得我可能在某些方面,太过于流露自己的情感?或者说,说话的分寸把握得不是很谨慎?” “嗯嗯,差不多是这个意思吧。”
如果我在意她,又有什么是无法改变的呢?一段关系,原本就是在不断的冲突与磨砺下铸成越来越契合的彼此,世间本不存在自然成珏的一对玉佩,亦未有天生合璧的两柄宝剑。双玉成珏,必先经历千凿万刻的雕琢;双剑合璧,定要承受千锤百锻的冶炼。但只要缘分足够,无论趟过多少泥沼,无论穿越多少荆棘,天下有情人终成眷属。
······
“嘟嘟” 几声忙音,她的手机没电了。
星期一
清晨阳光灿烂,似乎没有任何征兆这天傍晚将会落下久违的甘霖。
周日晚挂断电话之后直到早上我最后一次向她问好,我始终也没能在微信联系上她,我是否需要相信她的手机一直没充上电而无法开机?
感慨是只有我一个人的感慨,而没有别人什么事。现实与梦境的距离大抵如此,就算像昨天一样,像一周前初次见面一样,每天都能同她打一个如梦一般甜蜜的电话,但梦醒的时候我是否能够抵挡冷漠,战胜孤独与绝望?在该进入梦乡的时候我是否又能够安心合上双眼?该来的终究还是要来,至少以我的方式,这一周我没有怠慢过,我努力过,珍惜过。
恋爱毕竟是一种高级的人类活动,是一场难度极高的人生游戏,要通往幸福的彼岸,有太多的学问值得研究,有太多的曲折等待历经。作为一名程序员,我当然不会忘记排查 bug 时一次一次地调试失败,又从头开始追踪;作为一名作者,我再清楚不过当前正伏案作文的自己,是怎样将写好的词句,乃至段落,一遍一遍地删去,更重新构思酝酿。没有一次编译,完美运行的程序,没有一气呵成,字字珠玉的文章,那么也不会有:一帆风顺,波澜不惊的爱情。
百感交集,万情纵融,我将此时此刻心中之所言,汇成为一篇:《释然》。
正襟危坐,我把手指停放在微信消息发送键上方约莫半寸的位置,缓缓得转过屏幕背对着脸,撇开头,闭上眼,深吸一口气,猛然将手指贴在了屏幕上。心里蓦然一颤,一周之内的往事与回忆快速得在大脑中闪现,像一管不断抽出的胶卷,被时间曝光,被记忆塑封,被灵魂收藏。看呐,它被盛放在一隅静谧的角落,一格质朴的抽屉内,抽屉的外侧贴上了标识:《一位爱情初级玩家的偏见式独白》。
睁开眼,待我再次面对屏幕,确认早已度过了足够长的时间,这条消息已经成功发送,再也,无法撤回了。
]]>教堂内沧桑墙壁上深深刻着的 “命运” 一词,令雨果读出了人性的真善美与假恶丑;实验室中模拟地球演化的系统,一个是否包含生命的参数选项,令刘慈欣窥探到了整个宇宙的游戏规则;这一次轮到了我,当一只流浪猫与南师邂逅,当它的目光与我的目光交织在一块,我将会悟出什么?
这本是一封辞职信,初写于 2012 年底。在我的百度网盘私密空间尘封六年之后,我决定让其重新曝光于这个世界。
我记得 2006 年我上初中,第一节语文课,学到的第一篇课文,是赵丽宏老师的《为你打开一扇门》。开学伊始,我定然是认真得学习,可惜我天性愚钝,又或是阅历尚且,在诵读与笔记之余,并未理解赵老师想要表达的真义,无法窥见那扇大门背后的瑰丽与宏伟。那时的我像个 “标准” 的理科生, 字迹潦草,性情执拗,无法欣赏文学的美,只知道埋头解数学题。
六年过去后,我上了大学,以一名理科生的角色来到一所偏文科的院校。这看似剑走偏锋的选择,却为我呈上了一份珍贵的人生之礼:发觉艺术与审美的能力。而这篇文章所描述的,正是母校赠与我这份礼物的全过程。从事理工相关的方向,求学与职业之路无不充满了复杂的设计与缜密的逻辑,思维的高负荷运转难免为生活带来干涩与磨损,而这份精致的礼物,可相伴我一生,舒缓不顺心之事所带来的烦恼,慰藉不如意之情所添增的苦闷。
六年之后又六年,今天的我将这篇文章搬上了我的博客。错愕于触目惊心的文字,我方才惊忆起过去的我长着如何的一张面孔。尽管文中我的行为令现在的自己感到不齿,但那的的确确是过去的我,是真实的曾经。人总是在不断的改变,我们不会因为一个人糟糕的童年而否定其往后的努力人生,尤其当这个人是自己时,甚至需要反过来感到欣慰,欣慰自己的成长与蜕变:我,已经从一个懵懂无知的少年,变成了一个渴望思考、好奇世界、憧憬美好与高尚的青年人。回想起十二年前我坐在初一 (11) 班整洁明亮的教室里,朗诵着《为你打开一扇门》,我不禁热泪盈眶。纸上得来终觉浅,绝知此事要躬行,无论如何,我已经真正悟了,相比于那些还在门外徘徊不得而入的人来说。
种一棵树的最佳时间是十年前,其次是现在。感谢扬州市梅岭中学,于十二年前在我心中播下了一粒种子;感谢南京师范大学,于六年前为种子周围的土壤浇水施肥,令新生的嫩苗破土而出;感谢现在的我,继续悉心呵护着小树,令其深深扎根,茁壮成长。我相信:终了,它定会在我心中成长为一棵枝繁叶茂的参天巨木。
以下为正文:
我特别喜爱南师阳光明媚的午后,尤其是在这幅精致的图景之上添置一只可爱的小猫。
猫本是有灵性的动物,随遇而安,依境而存。于是,在我们宿舍楼,周遭总是窜着一只又一只的。它们日日生活在我们周围,睦邻相伴。每天都有阿姨给它们喂食,也有男生女生与它们嬉戏。就像青春的精灵,不管来自何方,它们都可以忘却曾经,尽情享受如今的温馨美好。
猫有记忆,它们能记住人。宿舍楼里的同学可以分辨出不同的猫,为它们起一个个可人的名字。猫也能辨别不同的人,尤其会记得某某对自己特别的好,于是静静往墙角一趴,专心地等待那位同学下课归来,几个小碎步迎上前去,懒懒的喵一声,像是在对自己的好朋友道声问候。
不过,猫也有自己的烦恼,因为它们遇上了我。我是一个理工科男生,也是这里所有安详与和睦的搅局者。在我这位理工男的眼中,动物与人是不一样的。人迹出没的地方,容不得动物的插足。但凡在我回宿舍的路上,看到猫的踪影,我的第一反应便是狠狠跺上一脚,看它们被吓得四散逃跑,仿佛在我心中,那是光明的火种驱散黑暗的阴霾,是人类为自己的权利而不懈斗争的胜利,我以为自己获得了一种不足为外人道的满足感,甚是好笑。自此,没有哪只猫会在看到我之后不慌不乱,甚至是隔得远远的时候,当感受到那一袭黑衣,斜跨黑包的身影急促地逼近时,所有在路口等待自己好朋友归来的猫们就仓皇而撤,连正面与我对视一眼的机会都不曾有过。我冷冷地微笑着。
我曾经就是这样以搅局者的身份自居:思维奇异,与众不同,追求极端,冷酷无情。仿佛一个理工科的男生,来到女儿王国的南师大,势必要挑起一场价值观的冲突。
虽是彻头彻尾的冷酷,但我依旧在外表上活成了一个 “有声有色” 的人:和其他同学一样,面试校学生会,面试赛扶,参与各种社团,过关斩将,悉数被录用。面对动物,我睁着邪恶的双眼;而面对人,我又展示着理性的思维与缜密的语言组织能力。道貌与岸然,将我的人格诠释的刚好。
于是,我加入了南师赛扶 “虎韵福鞋” 项目。在这里,我依旧走着我的风格:颠覆式的否定与创新。我无视了那些女生苦心孤诣地挖掘与丰富虎头鞋内涵的举动,我不懂她们也无心去了解。我一意孤行地设计着自己的战略:让传统的文化与现代的时尚去激情碰撞,我试图打开电商推广的渠道,我只相信现代科技的力量。而虎头鞋本身究竟是什么,我并不懂。虎头鞋的追根溯源与精神文化,我亦不屑。像我这么浮躁的人,怎会有耐心打磨绣花针?所以,分歧在所难免,隔阂日渐凸显。但是,我行我素,因为没有一种力量能打破僵局。
但是却有一种无形的力量可以做到。
一个阳光明媚的午后,我独自一人往宿舍走。静谧无声,周遭的一切与往昔似没有任何不同之处,唯独别致的是,当我路过一个转角,经过开水机的一刹那,我瞥见了一只小猫。它是如此的大胆以至于竟敢在我面前装作不动声色,甚至还作出 “越界” 的举动:趴在人类的开水机上惬意地舔舐着上面的积水。我从来都没有机会近距离地观察一只猫的形态,于是突然就来了好奇。弯下腰,以一种不怀好意的目光打量着它:一身毛发黄白两色,渐变一般从头到脚柔和地过渡,尾巴翘着老高像是在保持平衡,两只前爪刚好抠住不锈钢的平台,尽情地享受着饮水的乐趣。它的肚腩如波浪般的一抖,显然是一口清澈的陶醉。或许是忘我的沉浸让它忽略了身边的危险,我一口呼气掠过它的发须,它漫不经心的一瞥,眼神正好在那刻与我对撞。
原来这只猫的眼珠竟是蓝色的,特别纯洁的那种蓝!惊讶之余,一股被挑衅的感觉莫名涌上心头,我竟不能让这只猫屈服?莫非它不畏惧我邪恶的眼神?难道相比于它我这庞大的身躯都不能对其构成一点压力?我在考虑要不要保持我在这栋楼里对猫群的 “尊严”,总不至于过往的惯例在今天就此打破?
有一点我敢笃定,这只猫是这栋楼的新客,它没有见识过我的凶残,在它记忆里恐怕只有南师大整体的博爱情怀!
在它蔚蓝的眼神里,似乎包涵了整个南师大的灵魂:清澈不失厚重雄浑,淡定却又锋芒敏行!没想到,从一只猫的眼里,我竟读出一种强大的气场,邪恶与正气的交锋中,我越发觉得自己已处于守势!
如果我此刻突然爆发,使用暴力手段相威胁,它一定会在瞬间仓皇而逃。但是如果剥离所有肉体与物质层面上的东西,在两个生命的对峙中,我却无论如何也无法战胜它。曾经有个寓言:在质量的天平上,蚂蚁和大象没有可比性,然而在生命的天平上,两者却是等价的!
我觉得我没有资格在这只猫面前滥用武力,因为我面对的不仅仅是一只猫,也是南师大,她是我尊敬的母校!
或许这只猫曾浪迹天涯,只是最终与南师邂逅,并在生命里注入了南师的基因,从此行走在博雅与爱的路上。南师的魅力在于她不但能感召人,同时也能将物点化为自己的精魂。而很大程度上,南师感召莘莘学子,正是靠身边不计其数的师大精灵,才让混沌而迷误的灵魂走出泥沼,获得醍醐灌顶的觉悟。
我庆幸自己最终没有对那个纯洁的生命做出卑鄙残忍的行为,从而让它保持了那份对南师无暇的爱。我也后悔自己让其它无辜的生命这些天饱受恐惧与惊吓的折磨,打碎了它们对南师这个伊甸园美好纯真的映像。我担心,这些天,我从未让这些青春的精灵正面看我一眼,它们可能会误判而刻意躲避与我衣着类似的同学,这是间接剥夺了其他同学对自然生命亲密接触的权力!这一切的后果都是我造成的!我宁愿让我暴露在光天化日之下,让所有的精灵都看清我的本貌,让所有的惩罚都冲着我一个人来吧!
就让恶贯满盈的少年发自内心地忏悔吧!就让一身污迹的浪子接受电闪雷鸣的洗礼吧!
正是这种无形的力量。
对物残忍,对人傲慢。揭开我道貌与岸然的画皮,剩下的大概就是这两句话了。曾经我被赛扶吸引,是因为 “商业” 二字。然而我却忘了全句:用商业的力量改变世界,造福社会。重点在后面,而我本末倒置。我丝毫没有忖度自己的实际素养便妄图加入,还用自己的无知拖累整个团队,用赤裸的利益动机玷污了虎头鞋博大精深的文化底蕴。曾经有团队的同事告诉我,她选择虎韵福鞋是因为自己真的非常喜欢中国传统文化,因此想在这个领域做出自己的贡献。而我怎么告诉她?我选择这个项目是因为其中有诱人的利益链条,因此想大挣一把?情何以堪?
如今我再也不敢说我参悟了赛扶的真谛。真谛无情地照在我身上,反射出我惨白而嶙峋的瘦骨。我活了将近二十年,可能从小就会写 “公益”、“可持续” 这些个汉字。可悲的是,到如今我仍然还停留在小学的水平,我会写这几个字, 看得懂它们的字面意思,但是就是无法领悟它们的内涵!初中高中,学了六年的理工科,接触了大量的公式与定理,我每天忙碌地在试卷与作业上搬运成批成量的它们,我自以为能灵活运用,驾轻就熟。可是,我发现,学理工科最大的致命伤,就是我们过分强调了运用,却忽略了这些公式背后的探索验证,其实是一把饱含血泪的辛酸史。理性,正在扼封人性!我们踩着前人的肩膀向上攀登,却忘记了对先辈、对知识的敬畏与仰望!那一串串字符,是有血有肉的生灵;那一句句推理假设,当是为我们的明天,为我们的后代奠定了幸福的基础!
这些, 我曾经想过吗?
一个伟大的科学家,绝不仅仅停留在对人类在科学技术上的突出贡献。他一定也是一个悲天悯人的慈善家,他热爱生命,关心人类的疾苦,这些都是促进他在科学领域开创新时代的源动力;他一定也是一个对人类,对社会负责任的公民,他参悟了公益与可持续发展的内涵与精髓,他从骨子里焕发着对社会的使命感;他一定,也是一个心思细腻,柔情似水的诗人,在一只小猫面前,他甘心拜倒,放下他所有的身段与荣耀,只为接受自然予他的馈赠。
而一个伟大的科学家,从广义上讲更是一个执着思考的文学家。文学是这个世界上最容易被忽视而又最不容忽视的巨大力量,文学,总是与世界上最感性的事物相关联。她代表的是一种信念,一种信仰。她可以在人类面临危机时爆发出不可抗拒的能量,也可以在日常中左右人们细微的行为,并以一种滴水穿石的恒念,积聚起改变未来的倾彻。
历史上很多著名的科技巨头公司,曾经都是光芒万丈的恒星,他们也秉持着改变世界,造福社会的理念,只可惜,不可一世的辉煌麻痹了他们的双眼,资本市场的催化却加速了他们的腐化,很多人变得腰缠万贯,也变得偏执傲慢,于是离自己的初衷渐行渐远,最终堕落为天际边划过的一颗流星,燃烧殆尽。
怎样才能让自己始终不变初衷,坚定不移地前进?唯有信念与信仰!我越来越相信文学的力量,相信她对人的一生所起的指导作用。信念,只有一个终点,教人始终清醒,洞悉自己,不为眼前所迷惑,善待众生。如果一个跨国公司,在一只猫面前敢于屈膝,那么它就具有了生生不息的动力。一个能扛得起惊天伟业的人或组织,一定也能在自然面前认清自己卑微的本质。一个人当认识到自己的卑微时,他的行为便开始伟大!
所有贪图商业利益的人都是在走向终结的深渊。时间会淘尽一切,露出真金的本色。对于虎韵福鞋而言,文化即是最好的保值品。将来所有的东西都会消失,唯有文化得以生生不息。此刻,我刚好理解了为什么女生们会在加入虎韵福鞋后醉心于虎头鞋的文化内涵了。一个肤浅的人,搅浑了一捧清澈的甘露。
这些天,我突然想起来两个多月前我与杨经理第一次的电话交流。“如果将来学生会与赛扶都很忙,你必须放弃一个,你怎么选择?” “我会选择赛扶。因为我在暑假就已经规划好了我大学的目标,培养我在商业方面的职业素养。校学生会方面,我在宣传中心工作,可以理解为媒体与公关,它确实也是商业组织不可或缺的一部分,但那本质上触及不到商业的核心。”一段貌似有理的论述,现今看来,恐怕都没有一层纸的厚度。这段话暴露了我早期贪婪的本质,如果按照我 “精心设计” 的路线走下去,前方不远一定是万丈深渊。
我如今,没有能力,没有资格再在赛扶继续下去了。我向我曾经因为自己的无知而亵渎的虎韵福鞋文化,公益的精神,社会可持续的使命表示深深的歉意。在此,我也正式呈上我的辞职申请。
杨经理,请原谅我的食言。面对一个歧误的承诺,我已无力再恪守。
曾经我还抱着美好的希望,希望能在赛扶找到值得相伴一生的人。现在我只能苦笑,我的卑鄙龌龊根本就配不上那些天使般的女生,我的凑近只会反衬她们的纯洁,我不配!
又是一个宁静的午后,我站在宿舍阳台的一隅。远处层叠的山峦,如同编织起温暖的摇篮,枕着南师所有的生命,孕育着未来。
我之于那只可爱的小猫,不就如同猫之于南师大吗?感化与洗礼,脱胎与换骨,一只猫被注入了南师的精魂,青春的精灵得以在世间飘洒。而我,又何尝不是一只猫呢?造化选择我与南师邂逅,选择在一个阳光明媚的午后,选择在一个静谧无声的楼道,选择与一只青春精灵的生命碰撞,来完成对我整个灵魂的重塑!
我爱上了文学,爱上了读书,爱上了写作,爱上了思考。我作为一个理工科的男生,有必要去经历一个从未体验过的生活。一人静静走在随园校区的林荫小路上,凝神尽力吮吸着那古色古香的建筑所焕发出来的韵味。人生需要放空自己,解开束缚,让所有的欲念自由飞翔,能沉淀下来的才是真正的菁华。
如果说南师代表的是女性,那么我自甘情愿接受女性化的洗礼,我爱我的母校!如果有需要,我也可以成为南师万物中被点化的精魂,去拯救更多混沌而迷误的心!
我也愿意等候下一个安详而静谧的午后,站在宿舍开水机旁,只求与那只可爱的小猫再一次邂逅······
]]>写出这篇文章,本意味着我的幼稚,但若不写这篇文章,也只不过是将幼稚藏于心底罢了。敏感脆弱的品性,使我固执了六年,而不敢拾起真实的自己,蓦然回首,却发现我苦苦寻找的光明,其实就是自己的影子。
伤痛,迷惘,绝望,逃避, 再次面对,幡然醒悟······值得欣慰的是,无论这个过程多么坎坷而曲折,这么多年来,我心灵最深处却始终保留着通往真实自我的火种,在历经无数的风吹雨打后重新被我拾起,再一次燃燎起我内心的草原。
时间回退至 2012 年,阳光明媚的正午,我收到了你的消息:“我们俩的关系可能真的需要考虑一下了!”
开学以来的这两个月我很幸福,可惜在你眼里我最终还是令你感到失望了。这是一个无法挽回的结局,我不会赖着强留你。这是我第一次失恋,只是这终点距离起点,只有两个月的时光。我的内心一边对伤痛的洪流打开了闸门,一边又在无力地安慰自己:这只是两个月,而不是两年,但愿没有陷得太深。
可惜一语成谶,我高估了自己的情感自愈能力,接下来的一段时间内,我已经不是在开闸泄洪,而是在坠入黑洞:拂晓时分,睁开眼的一瞬,我一次又一次得被无情的现实拽开梦中我们紧拉着的手;之前极少梦遗的我,每当掀开被子,时不时会感到一片湿漉漉的下身;我在同学朋友面前故作镇定,每当控制不住时,不得不闭上眼睛,好让眼泪被瞳孔快速得吸收,末了,再假装睁开惺忪的睡眼,生怕别人看穿我内心的波澜。
在接近一个月的炼狱之旅后,我熬过了失恋的第一阶段,这一阶段属于时间短而冲击猛烈,如果我属于天生乐观的那类人,挺过这一阶段或许便能恢复到正常的生活。很遗憾我没有做到,在此期间,我的生理、心理、潜意识都不亚于经历了一场器质性的改变,造成了难以愈合的创伤,以致于我被迫进入了失恋的第二阶段。
这一阶段属于文火慢炖,学习、作息等日常活动都与此前几无二致,但性情却发生了大变,仿佛我内心的某一扇门被合上了:我关闭了 QQ 空间,排斥在任何群里说话,与单个人发送信息时也不会再轻易使用任何表情,尤其是那个可爱的笑脸,它像极了一个讽刺,曾经它令我感到多么幸福,现在就令我感到多么残酷。后来普及了微信,但于我这并不意味着一个崭新的开始,却倒像是对 QQ 的一个延续,我从未在朋友圈中分享过任何内容,也未主动加过别人好友,不认识的人一律拒绝,临时有事需要联系的,当事情结束后我便清空一切痕迹······我的学业虽不曾耽误,但把这种孤立自我的生活称作一蹶不振也不失得当吧。
喜怒哀乐乃人之常情,哀多乐少,也勉强可以当是一种生活之态,随着时间推移,此消彼长,或许于不经意之间又可以找回当初的模样。可惜我的心境没有就此打住,而是继续向更深处跌落,向着扭曲与极端方向发展,我本以为时间会为我酿制解药,慢慢稀释我的悲痛,但恰恰相反,我其实每天都在服下这剂慢性毒药,日复一日,直至病入膏肓,落下无可弥补的后遗症:我不敢再谈恋爱,不再有信心处理好两性关系,不敢再次尝试陷入别人的世界中,因为上一次的经历警示我,哪怕只有两个月,我都难保可以承受其失恋之痛。我冷漠得回应异性对自己的表白,尽管对方的真心也会让我内心悸动,但我总以 “绝对的理性” 强制扑灭心中的火苗。有时候别人的表白也会令我感到 “喜悦”,不过这份喜悦只是拒绝别人时变态而畸形的虚荣心与优越感罢了。
恰逢失恋之时,又遭遇了父母离婚,在这样的双重打击之下,我的三观彻底淹没在了汹涌的洪流之中,我无可避免得陷入了第三阶段:凡事有始必有终,既然承受不了终点的痛苦,那干脆就别给它开始的机会。这句话传递给了我一个危险的信号,我难以想象任我现在的状态发展下去会面临一个怎样的结局?庆幸的是,在这样一个节骨眼上,我遇到了我的高中同桌,他在隔壁南邮修习计算机专业,我仔细聆听他讲述他们专业的内容及特点,并感受到了强烈的召唤:计算机可能是助我摆脱当前危险处境的救命稻草,我必须竭尽全力得投入其怀抱,这是避免我生命终结的不二选择!经过半年的努力,我成功转到计算机学院,真的好希望能以此为契机,彻底告别过去!
计算机给了我一个活下去的理由,它是挽救我生命的第一张多米诺骨牌,但要真正推倒它,我得付出实际行动:我需要疯狂得爱上计算机。如若不能永恒,那就注定要毁灭,假设我对待计算机的态度只是为了逃避情感,那这种三分钟热度断不可存续太久,命运将会无可抗拒,所以我需要打造一个被信念赐予力量,抛开一切杂念的自己。
很快我就给自己找到了一个力量之源:还是我那位南邮同学。是他在我即将坠入万劫不复之际为我打开了一扇门。当我踏进门内,面对广阔无垠的新世界,他再一次成为我撷取知识的参考系。他是从大一开始学习计算机的,而我属于大二开学才半路杀入,我和他之间至少存在一年的差距,于是我的奋斗目标被冰冷得定义为:竭尽所能赶上他并超越他!我开启了自己波澜壮阔的求学之旅,每日每夜,每时每刻。图书馆、教室、宿舍、食堂窗口、路上······如果我处于一种思考的状态,那我一定在思考与计算机相关的问题,我进入了一种不可思议的魔怔。
每个周末我都要找我这位同学交流最近的学习心得,这种事情从开始的只是令我感到愉悦,发展到后来的令我极度渴望,我大概意识到了我学习计算机的动机已经发生了微妙的变化,我不是直接在为我自己学习,我是在为我这位同学而学习,我从同他的每周交流分享中获取快感,更甚的说,我从每周在他面前的装逼中体验高潮,这似乎才是我学习的原始动力。当然,我察觉到我在这方面的疯狂后,其实也在有意无意地利用这一点:如果装逼的代价是需要持续学习,那我不介意把装逼进行到底,不管学习的动机与姿态多么难看,我都是在实打实得学,最后掌握知识的人还是我自己呀!
一旦有多巴胺在生化级别上对我的行为作出了奖励,我真的感觉要对过去说再见了。自杀?没有的事,计算机的世界还有太多太多等着我去发现,哪还有心思去考虑怎么死?
学生会、社团、团 (党) 支部,这些字眼都与我无缘了,当我不在其上倾注感情,也就无所谓得与失。我的追求就是计算机,我对其倾注了所有的情感,它也给足了令我满意的反馈,我绝不容许自己失去它,失去即是死亡,即是毁灭。
差不多这就构成了我大学后三年的生活基调了,我认为这三年来我已经摆脱了失恋后危险的第三阶段,进入了一个有点唯心主义,并带有极端色彩的第四阶段。第四阶段与第二阶段在很多方面很类似,我还是和以前一样社交恐惧,内心离群索居,与常人的生活浅行浅远。但是第二阶段我的行为受到了主观意志的干扰,而第四阶段的我却是顺其自然,仿佛与生俱来,水到渠成。另外,第四阶段的我有强力的追求目标,让我的学习生活充满 “活力与斗志”,这几乎是塑造了一个全新的自己,但我清楚这并非一种健康的状态,就像前苏联一样高歌猛进,最后变成了跛脚的巨人。可是我能停下来吗?我敢停下来吗?
大概每一个单纯而执着的程序员,背后都有着自己心酸的往事。茶余饭后的人们总是戏谑程序员的木讷与无趣,殊不知木讷可能并非我们天生的性格,而是后天无可奈何的选择。
毕业后我去了北京工作,而我这位同学则继续留在南京读研,地理位置的隔阂让原本的一周一碰面,变成了以年为单位的见面。当然,我这辆疾速奔跑了三年的战车,已经拥有了足够的惯性,使得我不再需要以大学时代的模式来维系我生活的运转。
但这终究还是有区别的,学习与工作,校园与社会。我真心怀念学校图书馆里无忧无虑看书的日子,相比之下,在社会上,毕竟拿着公司发的工资,干活与完成任务才是第一要义。我喜欢计算机,但这和在互联网公司上班是两回事,很多人倾向于用工作的忙碌程度来评估自己的价值,把自己交给繁忙,得到了心里的踏实,但在 IT 行业,这是个死亡陷阱,是一个让人陷入原地踏步无法快速提升自己的重要原因。我不喜欢上班的感觉,可是哪有不上班的道理,不上班哪有经济来源?
如此,自失恋以来我又陷入了另一种痛苦之中,工作使我不得开心颜。即便并非天天皆如此,但这种悲观的预期却很难摆脱,当面临一个晴朗的周末,如果想到下周一又得跟进一个很恶心坑很多的项目,这个周末还要怎么度过?一周有七天,五天很烦恼,剩下两天稍微缓一缓;一年有 365 天,358 天都是漫长的修行,最后七天回到家乡与亲人团聚见一面。假期总是一晃而过,新的修行早已在路上。一年中的时日就像结绳,绳子由痛苦构成,结点则是快乐的分身,短暂的快乐连结了一段段漫长的痛苦,使岁月得以延续,然明年复明年,明年何其多?
淳朴的民工,辛勤劳作了一年,返回家乡时是快乐的。天生焦虑的我,只能感叹,快乐到来之前才是真正的快乐,假期来临之际其实刚好按下了快乐准备结束的倒计时,一秒一秒头也不回得流逝着,宣誓着,下一段痛苦的降临。佛曰:受身无间者永远不死,寿长乃无间地狱中之大劫,身处人间界又何尝不是如此?
五年前我去过一次杭州,欲把西湖比西子,淡妆浓抹总相宜。这是个令人心驰神往的城市,同时也孕育着互联网的新时代。我尽力地安慰身处北京的自己,将来会有那么一天,我挥手作别北京,投入杭州的怀抱,览水光潋滟,观山色空蒙。痛苦将会消褪,幸福将会来敲门······
苦难中的人们,热衷于编织美好的未来传说。三大宗教长盛不衰,因为它们所倡导的救赎,依托于死亡。当无法眼见为实,我们便心甘情愿以耳听为虚来慰藉满目疮痍的生活。所以,一个善意却不太高明的谎言,会给被骗之人留下通往真相的尾巴,当执着者按图索骥触及终点时,便会意识到斐然如画的词句,不过想象里缥缈的幻光,乍现即逝,正如我此刻虽已置身杭州,只是城虽换,心未易,这两个多月来却没有一天开心的日子。当然在北京的两年我也不曾度过开心的时光,可我在北京总可以安慰自己去了杭州一切便焕然一新。那么现在身处杭州的我,该怎么继续安慰(欺骗)自己,难道说等再回了南京一切才能真正好起来了吗?我内心到底在渴望怎样的一种生活?
从我第一次接触编程到现在已经快六年了,我心无旁骛,一路披荆斩棘。我以为曾经的痛苦会被我指尖的代码所肢解,我以为程序调通的瞬间会从内心升腾起由衷的喜悦,我笃定计算机是这世界上值得我留恋的东西,却不曾想到它竟渐渐要成为我在这世上唯一值得留恋的东西。
在北京的我,无论工作压力多么大,生活多么重复单调,我都能忍受,对计算机的喜爱加上对杭州的憧憬,所有的烦恼都会烟消云散。同大学时我不顾一切得投入计算机类似,这种激励模式谈不上良性,但至少也为我在一段时间内保持了比较稳定的心态,直至我来到杭州为止。我以为换了一座城市,就能甩掉所有包袱,重新开始,如今这被证明不过是在以偏概全,换汤不换药。作为一个谎言,它的确很有魅惑力,起到了望梅止渴的效果,可惜曹孟德最后确实找到了水源,然对我来说只是延缓了精神崩溃的时间罢了。
在北京的时候,偶然间我会再次萌生出自杀的想法,只一闪而过,随即便会被理智所控制,我笑着告诉自己不值得,我走过了黑暗的岁月,在失恋后最绝望的第三阶段重新拾起了新的追求目标,我何苦再踏回原先的老路。而今在杭州的我,被自己欺骗了自己的我,美好憧憬破灭的我,当再次想起自杀这个词眼时,我竟然放下了警惕,开始认真而专注地考虑这件事情:
六年来,两千多天,我努力超度自己,一层又一层得剥离自己感性的一面,我不再购买帅气的衣裤,不再尝试原创音乐,不再和小动物玩耍,不再打网球,不再与别人进行深入灵魂的交流······我腾出大脑的所有容积,只为给计算机世界留下 “自由发展” 的空间。终于,我 “成功” 了,当我闭上眼,当我睁开眼,我在意的只是代码,其余的一切,都灰飞烟灭了。当我不再拥有它们,也就不会再失去它们。起点与终点,如果每个人都要选择用一条线来连接,那么我当下的选择则是一条直线,所以当美好的周末来临,除了继续研究另一个令我 “感兴趣” 的技术实现方案之外,我已没有任何其他的 “期待”。
如果其余都照旧,只是不再与小动物玩耍了,我的生活最多少了两声欢笑;同理,如果只是不再打网球了,我也可以很快找到另外一项消遣活动;但当所有的这些都消失了,就构成了现在的我,一个被执念牢牢束缚的自己。五彩的泡沫幻灭了,我终于要看清我的真实面目了:原来我这六年来所做的一切努力,并不是在为自己打造一个没有失去、没有痛苦的乌托邦,而是恰恰在无端地制造失去,硬生生得剥夺生活中的乐趣。我变成了一个自虐狂,扼杀人性于无形之中,最后只能依赖一个莫须有的谎言,苟延残喘,艰难度日。细细想来,谎言中描绘的图景所影射出的,不正是我一边在极尽渴望,一边却又撒手而弃的生活吗?
猛然回神过来,我正站在窗边,凝视着十三层楼下的马路,一辆又一辆的汽车川流不息,越开越远,直至消失在视野尽头······
这算是我失恋后历经的第五阶段吗?
苦海无边,回头是岸,我既已悟了此道,便是亡羊补牢,为时未晚。失恋的创伤即便谈不上痊愈,也至少已经结痂了。偶尔的时候,我的内心固执地不愿意放下这段往事,似乎我很酷嘛?欣赏自己痛苦的状态,以为这是一种艺术行为,或是在顾影自怜?可王小波很早就说过,自己的痛苦成全不了自己,但却会成为别人的艺术源泉。所以我真得不必与自己过意不去,如果没有这场恋爱,也许我会走出另一种完全不同的人生,可能迷上了网络游戏而不能自拔,也可能错过了我这位南邮同学,却在另外一段感情中陷入绝境。这些都不是真实的我,该来的总归会来,挫折与磨炼无可回避。
偶尔和其他一些初高中同学交流,我发现他们好些人现在都过得十分 “幸福”,在老家三四线小城市里做一个公务员或国企职工,每天朝九晚五,不知加班为何物。收入高的也有七八千,进展快的已经结婚生子,开启天伦之乐。这种生活从某些角度讲可能叫做一眼望到路的尽头,遭到了很多人的不屑,但说真的如果让我选择是否向往这种生活,我恐怕会犹豫:如果说我不喜欢这种生活,那是因为我不甘心如此平庸,不希望自己一辈子只是养家糊口而无所成就;如果说我向往这种生活,那是因为它不会占用我的个人时间,除了周末外,每个工作日的晚上也归自己自由支配,我可以无干扰地做我自己想做的事。
什么是我自己想做的事?现在的我已经不是大学时那个不顾一切,舍命追求计算机的自己了。我欣慰得发现,曾经的爱好,阅读、写作、运动,其实都没有彻底泯灭,野火烧不尽,春风吹又生,它们只是躲了起来,在暗中观察我,期待着我的苏醒。只要我愿意醒过来,它们会永远追随我。我想做的事,就是找回曾经的那个自己,真实而自然的自己!
“什么是真实?真实是你看到什么,听到什么,做什么,和谁在一起,有一种从心灵深处满溢出来的,不懊恼也不羞耻的平和与喜悦。” 真实是有所成就的前提条件,不满足这一前提的所谓成就,是违心与压迫的产物,它将不会饱含激情与心血,也就注定逃不出平庸的罗网。
而当前的我就像一个标准加工的产品,计算机科班出生,毕业后进入著名互联网公司工作,两年后跳槽,薪水大幅提升。如果继续朝这个路线走下去,再过两年可以考虑去阿里巴巴,争取一个 P7 的职位。这是一个 “理想” 的职业发展轨迹,很多人都在冥冥中被如此安排了命运。我可以说不吗?这不是我想要的生活,如果这种职业攀升路线需要耗费我过多的精力在业务内容上,我根本不觉得这实现了我的人生价值,我不想被计算机的世界绑票。
其实这并不妨碍我热爱计算机,只是我的爱好十分广泛,我不允许自己囿于狭窄的一隅,却错过盎然多姿的世界!古今中外,文学大家的风骨;沧海桑田,历史车轮的磅礴;造化诡谲,基因破译的秘密;算法精妙,人工智能的潜能;股海沉浮,商业战场的博弈;宏观调控,经济运转的定律······说白了,我今生今世,发自肺腑想要做的,想探索的,想感悟的,不依赖于某个企业的环境或某个个人的意志,而是在于综合的这个世界的本身,这是我个人的价值得以实现的唯一途径。
2009 年我上高中,任职班长,沉稳自信,深受同学好评;
2010 年负责学校广播台,被评为优秀学生会干事;
2010 年校园合唱比赛,任指挥,带领班级拿下年级第一名;
2011 年校元旦文艺汇演,任主持人,同时自编自谱原创音乐,穿插表演,获最佳创意奖;
2012 年我上大学,在学院迎新晚会上参与舞台剧《霸王别姬》,主演项羽,被评为晚会最佳节目;
2012 年 10 月 18 日中午 11:28,我失恋了,阳光遁入乌云深处,接着便发生了上述的一切!
你想取代我,我就成为你。六年一个轮回,一段修行,我从绝望中来,向光明中去。挫折曾令我沮丧,命运曾强大到令我生不出改变它的念头。可是过去的我如此优秀,凭什么叫我对不堪的当下低头?我的青春也不过只有这些日子,还得抓紧时间赶路,我已经听到了未来的呼唤。
入学军训时你的靓影从我左肩划过,我便知道我将邂逅一段只在文学作品中才描述过的浪漫故事。我想陪你看书,也想两年后带你去上海迪士尼变成白雪公主;我想为你写诗,也想为你亲自谱一曲专属的浪漫乐章。
图书馆有我们坐在一起的背影,我认真帮你准备学生会入职后的第一份 PPT;学校餐厅有我们面对面的甜蜜,无需多贵,两杯奶茶就足以营造温馨的氛围;创行中国的答辩现场,你流畅的思路与犀利的言辞,无论对手是谁,在我心中都不及你半分;我买了你最爱吃的柚子与火龙果,你爱吃的水果当然也是我爱吃的水果。
天色渐晚,秋风宜人,你挽着我的手臂坐在了柔软的草地上。月光朦胧,如轻薄的衣纱从天而降,徐徐落在我双掌之间,你那洁白如玉的手上。微风拂过,依偎在我肩上的脸蛋,被吹乱的秀发迷离了双眼,采月湖面上波光粼粼,我心亦泛起阵阵涟漪。远处旖旎的路灯下,南师的佳人们入对出双,而你和我,只愿在这起霞坡的芳草地上,同蟋蟀为友,与蜻蜓为伴,相与枕藉,宁可不觉东方之既白。
六年恍如隔世,漫天的我在空中肆意飘洒,落在火红的枫叶上,“你” 还在想我吗?
]]>新东家发的办公笔记本是 MacBook Pro, 来之前我还觉得挺高大上, 然而真正开始用的时候发现, OS X 对于 linux 用户来说实在是太难于上手了, 甚至感觉比 Windows 系统还不习惯, Windows 好歹从前还是使用过的, OS X 简直就和初学者使用 vim 一样不知所措;
关于一些常规而必备的软件 (如 chrome, thunderbird, atom/sublime, jetbrains 系列, jdk 等等), 本文就不再赘述了;
说来蛋疼的是, 即便已使用 visudo 命令开启了用户的 sudo 权限, OS X 依然不允许修改系统级的目录, 这是 OS X 在 10.11 中引入的 System Integrity Protection (SIP) 特性; 我观察了一下, 差不多除了 /usr/local 这一原本就该属于用户自己管理的目录下之外, 其余的都无法操纵, 切到 root 也不行, 可以说算是另一个阉割版的 admin;
所以拿到本子的第一件事就应该是关闭 SIP 特性, 否则后面的操作会显得束手束脚:1
2# 开机, command + r 进入 rescue 模式
csrutil disable
这样就可以关闭 SIP 特性, 后面就可以以 sudo 权限操纵系统级的目录了;
作为 Mac 生态下主流的包管理软件, 安装 Homebrew 是使用 Mac 的程序员必做的事情之一, 否则后面想在命令行装东西可就费劲了;1
curl -LsSf http://github.com/mxcl/homebrew/tarball/master | sudo tar xvz -C/usr/local –strip 1
如果遇到 Error: Unknown command: install
, 则需要更新 Homebrew:1
brew update
这时就体现了完整版 sudo 权限的重要性: brew update
命令需要更新 /usr/local/
下的文件, 如果开启了 SIP 特性, 这个操作就没权限执行了;
有了 brew 之后, 后面安装与管理各种软件就方便多了; Homebrew 的命令是比较简洁明了的:1
2
3
4
5
6
7
8# 安装与卸载
brew install $package
brew uninstall $package
# 查询
brew list
brew search $package
brew info $package
借助 brew 命令, Mac 下面部署梯子的操作倒还算方便:1
brew install shadowsocks-libev
自定义一个开机启动脚本, 让 mac 每次开机时自动运行 ss-local:1
2
3
/usr/local/opt/shadowsocks-libev/bin/ss-local -c /etc/shadowsocks.json &
目前我了解到的, 这种通过快捷键召唤出来并能够根据关键字定位资源的工具, 大致有三类主流的代表: spotlight, alfred 以及 devonthink;
苹果自带的中文输入法不是很好用, 中英文切换默认使用 ctrl + space 组合, 十分不方便, 具体在哪里修改设置我也懒得看了; 此时需要下载符合国人习惯的 sougoupinyin, 当然由于搜狗对 mac 的支持比较友好, 仅需一键安装即可, 此处就不用多说了;
在输入法方面, 不得不承认 mac 是比 linux (至少是 fedora) 要方便不少的: fedora 上的 sougoupinyin 一直停滞更新, 目前最新版本依然有严重 bug, 我不得不去移植 ubuntu 环境下的 deb 包才能满足我在 fedora 下的使用;
mac 自带的 terminal 也不是很好用, 不过有第三方强大的替代品可以选择, 我这里选择的一个终端环境的组合是 iTerm2 + oh-my-zsh, 以代替原有的 terminal + bash 的默认组合;
首先通过菜单栏更改 iTerm2 为 default terminal;
iTerm2 支持各种个性化的配置, 包括终端颜色, 快捷键等, 我这里选择的配色方案是 solarized 中的 Solarized Dark;
接下来是安装 zsh 的全能管家 oh-my-zsh:1
2
3
4# by curl
sh -c "$(curl -fsSL https://raw.githubusercontent.com/robbyrussell/oh-my-zsh/master/tools/install.sh)"
# by wget
sh -c "$(wget https://raw.githubusercontent.com/robbyrussell/oh-my-zsh/master/tools/install.sh -O -)"
oh-my-zsh 的配置文件默认是 ~/.zshrc, 这个文件里有几个关键配置项:1
2
3# 加载 oh-my-zsh 的核心内容
export ZSH="/Users/zshell/.oh-my-zsh"
source $ZSH/oh-my-zsh.sh
以下为个性化定制:1
2
3
4
5
6
7
8
9
10
11# 定制主题
ZSH_THEME="ys"
# 开启语法高亮插件
source /usr/local/share/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh
# 定制插件
plugins=(
git
osx
docker
zsh-autosuggestions
)
一般比较漂亮顺眼的两款主题是 ys 和 agnoster, 在 ZSH_THEME 中可以更换, 如果使用 agnoster, 需要另外安装 Meslo 字体并在 iTerm2 中启用它;
关于语法高亮插件, 可以使用 brew 安装:1
brew install zsh-syntax-highlighting
然后在 .zshrc 中 source 下载的 zsh-syntax-highlighting.zsh 脚本即可;
与 iTerm2 相关的软件资源我整理到了一个公共目录下, 以方便日后在新的 MacBook 上下载: software / iterm+;
这其实是个很扯淡的事情: mac 的按键体系与其他传统的笔记本不一致, 它多了一个 command 键, 更改了 delete 键的含义, 少了一些诸如 backspace, page up/down, home/end 等按键, 如此迥异以致很多传统的快捷键在 mac 下都有很大的不同, 有些功能需要依靠按键组合来实现, 让初次接触的人很不习惯;
另外 mac 的各个按键有着独特的图像标识, 在一些软件的快捷键设置面板上会频繁出现, 如果不稍作了解, 有很多标识是不太看得懂其象形含义的, 这里我对所有 MacBooK 基础按键的标识作一个整理:
按键标识 | 含义 |
---|---|
⌘ | Command |
⇧ | Shift |
⌥ | Option, Alt |
⌃ | Control |
↩ | Return/Enter |
⌫ | Delete |
⌦ | 向前删除键 (Fn + Delete) |
↑ / ↓ / ← / → | 上下左右 箭头 |
⇞ / ⇟ | Page Up/Down (Fn + ↑/↓) |
Home / End | Fn + ←/→ |
⇥ | 右制表符 (Tab键) |
⇤ | 左制表符 (Shift+Tab) |
⎋ | Escape (Esc) |
我相信从 Windows 迁移到 mac 环境是一件阻力不大的事情, 这也是大部分人的模式, 而且这部分人群的行业分布十分广泛, 软件工程师只是其中一个子集而已; 然而对于一个长期使用 linux PC 的程序员来说, 事情就没那么富有吸引力了: mac 所能给予的生产力与效率, linux 也不遑多让, 另外对于开源软件有信仰的人来说, 这事甚至没有任何商量的余地;
但其实我很清楚, 这本质上不过是一个人内心深处的偏见与执念, 长期使用 mac 的人, 让他们转投 linux 阵营也是不可能的事; 即便在 linux 业界之内, 关于 fedora, arch 与 ubuntu 的争论也是从未休止过; 关于 OS X 其实有大量的优点在本文中完全没有被提及, 可能是我觉得不值得花费时间去探索这些东西, 我在工作中所创造的价值完全依托于 linux 主机, 所以我亦使用 linux 作为我个人笔记本的操作系统, 借用这种方式以熟悉, 并更好得理解我的作品在生产环境下的工作原理: 兴许这就是我无可救药的执念……
我听说阿里巴巴的办公笔记本发放的是 MacBook Pro 15’, 并且强烈不建议使用自己的笔记本办公, 非要使用的话必须安装各种安全监视与审计软件, 毕竟信息安全是上市公司的头等大事; 这么说无论如何, 我都得慢慢得去适应 mac 环境下的办公模式了, 否则将来因为强烈排斥使用公司统一发放的 MacBook Pro 而拒绝了某公司的 offer, 就有点扯淡了;
之前写过一篇文章 apache benchmark 使用笔记, 介绍了 apache benchmark 的使用及注意事项, 当时我确实是使用 ab 作了一个系统的压力测试; 可惜不够重视, 我在博客里只作了关于 ab 的使用笔记, 却没有将当时压测的结果输出为一份详细报告;
这次被我逮到机会了: 最近我在调研一个 KV 数据库 oracle berkeley db
, 需要测试其新版本 (7.4.5) 引入堆外内存作为辅助缓存的实际性能; 我详细得记录了本次压力测试的各种细节 (已经对所有涉及公司内部的信息作了脱敏处理), 希望能以此为模板, 当以后有相关的压力测试需要时, 可以从中获得参考价值;
测试机器的物理配置如下:1
2
324C
64G
2.7T
为了构造大量的随机数据以模拟服务的真实场景, mock 了三个接口如下:1
2
3
4
5
6# 随机写, key 在 (0, xxx] 范围内随机生成, valueSize 指定 key 的大小
http://${remote_url}/random_set?keyRange=xxx&valueSize=xxx
# 随机读, key 在 (0, xxx] 范围内随机生成
http://${remote_url}/random_get?keyRange=xxx
# 随机批量读, key 在 (0, xxx] 范围内随机生成, keyNum 指定批量个数
http://${remote_url}/random_mget?keyRange=xxx&keyNum=yyy
在各接口中使用当前时间作为随机数发生的 seed, 确保真实随机, 然后使用 apache benchmark 作压力测试:1
2# 100 万次总请求, 250 并发, 5s timeout
ab -n 1000000 -c 250 -s 5 http://${remote_url}/random_get
使用 ab 收集基础数据:1
2
3
4
5
6# rt
min, mean, median, P90, P99, max
# 标准差/乖离率
stdev
# failure stat
error/exception/timeout
使用 jstat 采样 jvm gc 状态:1
2
3
4
5> sudo -u www jstat -gcutil -h 10 ${vmid} 1000 | tee /tmp/jstat_collect/xxx
# sample
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
5.67 0.00 48.74 70.68 98.24 - 15588 1199.695 20 2.865 1202.561
1 | # 收集关键 gc 状态指标 |
堆外内存无法使用 jmap / jstat 观察, 只能用 top 观察;1
top -b -n 100 -H -p ${vmid}
除了计划 E 是专门对比收集器效果的, 其余的测试计划内均使用 ParNew + CMS 的收集器组合, 配置如下:1
2
3
4
5
6
7
8
9-XX:ParallelGCThreads=${CPU_COUNT}
-XX:+UseConcMarkSweepGC
-XX:+UseCMSCompactAtFullCollection
-XX:CMSMaxAbortablePrecleanTime=5000
-XX:+CMSClassUnloadingEnabled
-XX:CMSInitiatingOccupancyFraction=80
-XX:+UseCMSInitiatingOccupancyOnly
-XX:+CMSScavengeBeforeRemark
测试环境:1
2
3
4
5-Xms=25g
-Xmx=25g
-Xmn=10g
-XX:MaxDirectMemorySize=10g
-Dje.maxOffHeapMemory=10g
A-1: 测试 set
测试命令:1
2
3# 100 万次请求, 250 个并发
ab -n 1000000 -c 250 -s 5 "http://${remote_url}/random_set?keyRange=9999999&valueSize=5000"
sudo -u www jstat -gcutil -h 10 ${vmid} 1000 | tee /tmp/jstat_collect/A-1_X
测试结果:
metrics \ je.maxMemoryPercent | 5% | 10% | 20% | 30% |
---|---|---|---|---|
RT (min/P90/P99/max) (ms) | 6/122/316/1461 | 6/128/352/1708 | 6/125/365/3354 | ab timeout |
RT (mean/median) (ms) | 85/73 | 87/75 | 88/75 | / |
error/timeout | 0 | 0 | 0 | / |
stdev/bias | 68.2 | 72.0 | 77.0 | / |
YGC/YGCT (s) | 14/2.998 | 14/4.397 | 16/6.278 | / |
FGC/FGCT (s) | 0/0 | 0/0 | 2/0.589 | / |
A-2: 测试 get
测试命令:1
2
3# 100 万次请求, 250 个并发
ab -n 1000000 -c 250 -s 5 "http://${remote_url}/random_get?keyRange=9999999"
sudo -u www jstat -gcutil -h 10 ${vmid} 1000 | tee /tmp/jstat_collect/A-2_X
测试结果:
metrics \ je.maxMemoryPercent | 5% | 10% | 20% | 30% |
---|---|---|---|---|
RT (min/P90/P99/max) (ms) | 9/32/29/1030 | 8/26/29/1038 | 8/27/29/1218 | / |
RT (mean/median) (ms) | 24/24 | 23/24 | 22/21 | / |
error/timeout | 2 | 2 | 5 | / |
stdev/bias | 15.8 | 17.8 | 27.8 | / |
YGC/YGCT (s) | 7/1.442 | 6/0.963 | 6/0.907 | / |
FGC/FGCT (s) | 0/0 | 0/0 | 0/0 | / |
A-3: 测试 mget
测试命令:1
2
3# 100 万次请求, 250 个并发
ab -n 1000000 -c 250 -s 5 "http://${remote_url}/random_mget?keyRange=9999999&keyNum=20"
sudo -u www jstat -gcutil -h 10 ${vmid} 1000 | tee /tmp/jstat_collect/A-3_X
测试结果:
metrics \ je.maxMemoryPercent | 5% | 10% | 20% | 30% |
---|---|---|---|---|
RT (min/P90/P99/max) (ms) | 8/28/34/4529 | 6/28/31/4804 | 6/27/33/1073 | / |
RT (mean/median) (ms) | 34/26 | 32/25 | 25/23 | / |
error/timeout | 2006 | 10907 | 14300 | / |
stdev/bias | 139.5 | 29.8 | 24.3 | / |
YGC/YGCT (s) | 26/31.157 | 23/22.947 | 24/4.687 | / |
FGC/FGCT (s) | 0/0 | 2/1.689 | 1/4.243 | / |
测试小结
在当前的测试机器上, 共有 30g 的存量数据; 根据现有的状况, 在计划 A 中选取的几个测试条件, 分别代表了:
当然, 根据不同机器上的不同数据分布情况, 相应的测试条件也需要调整;
从以上测试结果中可以得知: 当各分片 bdb 实例的 in-heap 大小控制在比较高的水平 (20%) 时, 由于数据的 overflow, 将会对整体请求的稳定性造成影响, 产生比较大的乖离率, timeout/error 概率也相应增大; 而当 in-heap 大小控制到更高水平 (30%) 时, 甚至在 250 并发强度下无法正常提供服务, 发生大量 timeout 以及 connection error;
综合来说, 这里建议比较充分得使用 jvm 堆内存, 对应测试中的第二项条件 10%;
测试环境:1
2
3
4-Xms=25g
-Xmx=25g
-Xmn=10g
-Dje.maxMemoryPercent=10
B-1: 测试 set
测试命令:1
2
3# 100 万次请求, 250 个并发
ab -n 1000000 -c 250 -s 5 "http://${remote_url}/random_set?keyRange=9999999&valueSize=5000"
sudo -u www jstat -gcutil -h 10 ${vmid} 1000 | tee /tmp/jstat_collect/B-1_X
测试结果:
metrics \ je.maxMemoryPercent | 5% | 10% | 20% | 30% |
---|---|---|---|---|
RT (min/P90/P99/max) (ms) | 6/122/339/3173 | 7/117/318/1714 | 6/128/352/1708 | 6/131/153/3074 |
RT (mean/median) (ms) | 85/73 | 83/72 | 87/75 | 89/76 |
error/timeout | 0 | 0 | 0 | 2 |
stdev/bias | 71.6 | 69.0 | 78.0 | 75.3 |
YGC/YGCT (s) | 14/4.398 | 15/5.051 | 14/4.397 | 15/4.954 |
FGC/FGCT (s) | 0/0 | 0/0 | 1/4.243 | 0/0 |
B-2: 测试 get
测试命令:1
2
3# 100 万次请求, 250 个并发
ab -n 1000000 -c 250 -s 5 "http://${remote_url}/random_get?keyRange=9999999"
sudo -u www jstat -gcutil -h 10 ${vmid} 1000 | tee /tmp/jstat_collect/B-2_X
测试结果:
metrics \ je.maxMemoryPercent | 5% | 10% | 20% | 30% |
---|---|---|---|---|
RT (min/P90/P99/max) (ms) | 9/25/29/1030 | 8/25/28/1225 | 8/26/29/1038 | 823/28/29/441 |
RT (mean/median) (ms) | 23/22 | 23/22 | 23/24 | 23/26 |
error/timeout | 11 | 5 | 2 | 371 |
stdev/bias | 22.5 | 25.8 | 17.8 | 11.6 |
YGC/YGCT (s) | 8/1.222 | 7/1.214 | 6/0.963 | 8/1.602 |
FGC/FGCT (s) | 0/0 | 0/0 | 0/0 | 0/0 |
B-3: 测试 mget
测试命令:1
2
3# 100 万次请求, 250 个并发
ab -n 1000000 -c 250 -s 5 "http://${remote_url}/random_mget?keyRange=9999999&keyNum=20"
sudo -u www jstat -gcutil -h 10 ${vmid} 1000 | tee /tmp/jstat_collect/B-3_X
测试结果:
metrics \ je.maxMemoryPercent | 5% | 10% | 20% | 30% |
---|---|---|---|---|
RT (min/P90/P99/max) (ms) | 6/28/162/1029 | 6/27/145/1342 | 6/28/31/4804 | 6/27/67/1431 |
RT (mean/median) (ms) | 26/24 | 26/24 | 32/25 | 26/23 |
error/timeout | 9011 | 9467 | 10907 | 10875 |
stdev/bias | 29.1 | 35.5 | 29.8 | 36.8 |
YGC/YGCT (s) | 27/3.758 | 26/3.535 | 23/22.947 | 23/3.05 |
FGC/FGCT (s) | 2/0.236 | 2/0.236 | 2/1.689 | 2/0.245 |
测试小结
berkeley db 使用堆外内存作为堆内存 overflow 后 spill to disk 之间的缓冲区; 计划 B 分别选取了四个差异较大的测试条件; 从测试结果中可以得知:
分配相对充分的 off-heap 比例作为 disk 缓冲区是有一定的效果的, 在 get 测试和 mget 测试中, 10g 与 20g 的测试组都在 gc 次数与 gc 时间上比 512m 和 1g 的测试组占有优势; 在乖离率方面, 10g 与 20g 的测试组也较 512m 和 1g 测试组较低, 稳定性更加;
综合来说, 这里建议分配相对充分的堆外内存 (10g ~ 20g) 作为 disk buffer;
一般控制 -Xmx 与 -Xms 相同, 同时这里设置 -XX:NewRatio=2;
需要注意的是, 在 64 位机器上, 当 jvm 内存超过 32g, 指针压缩 (CompressedOops) 功能将无法生效, 内存使用效率将会降低; 所以无论机器的物理内存有多大, 每个 jvm 实例的 Xmx 都不建议超过 31g;
测试环境:1
2
3-Dje.maxMemoryPercent=10
-XX:MaxDirectMemorySize=10g
-Dje.maxOffHeapMemory=10g
C-1: 测试 set
测试命令:1
2
3# 100 万次请求, 250 个并发
ab -n 1000000 -c 250 -s 5 "http://${remote_url}/random_set?keyRange=9999999&valueSize=5000"
sudo -u www jstat -gcutil -h 10 ${vmid} 1000 | tee /tmp/jstat_collect/C-1_X
测试结果:
metrics \ je.maxMemoryPercent | 5% | 10% | 20% | 30% |
---|---|---|---|---|
RT (min/P90/P99/max) (ms) | 6/123/324/3092 | 6/123/343/3140 | 6/128/352/1708 | 6/138/324/3101 |
RT (mean/median) (ms) | 83/70 | 86/74 | 87/75 | 94/80 |
error/timeout | 0 | 0 | 0 | 26 |
stdev/bias | 69.0 | 76.1 | 72.0 | 82.3 |
YGC/YGCT (s) | 38/4.035 | 19/11.268 | 6/0.963 | 12/6.99 |
FGC/FGCT (s) | 2/0.202 | 0/0 | 0/0 | 0/0 |
C-2: 测试 get
测试命令:1
2
3# 100 万次请求, 250 个并发
ab -n 1000000 -c 250 -s 5 "http://${remote_url}/random_get?keyRange=9999999"
sudo -u www jstat -gcutil -h 10 ${vmid} 1000 | tee /tmp/jstat_collect/C-2_X
测试结果:
metrics \ je.maxMemoryPercent | 5% | 10% | 20% | 30% |
---|---|---|---|---|
RT (min/P90/P99/max) (ms) | 9/25/28/2819 | 8/25/28/1077 | 8/26/29/1038 | 10/25/28/1429 |
RT (mean/median) (ms) | 22/22 | 22/22 | 23/24 | 22/22 |
error/timeout | 368 | 539 | 112 | 86 |
stdev/bias | 45.1 | 17.7 | 17.8 | 18.8 |
YGC/YGCT (s) | 17/1.912 | 12/1.154 | 14/4.397 | 5/1.206 |
FGC/FGCT (s) | 1/1.934 | 0/0 | 0/0 | 0/0 |
C-3: 测试 mget
测试命令:1
2
3# 100 万次请求, 250 个并发
ab -n 1000000 -c 250 -s 5 "http://${remote_url}/random_mget?keyRange=9999999&keyNum=20"
sudo -u www jstat -gcutil -h 10 ${vmid} 1000 | tee /tmp/jstat_collect/C-3_X
测试结果:
metrics \ je.maxMemoryPercent | 5% | 10% | 20% | 30% |
---|---|---|---|---|
RT (min/P90/P99/max) (ms) | 6/28/102/1195 | 6/29/140/1264 | 6/28/31/4804 | 6/28/159/1344 |
RT (mean/median) (ms) | 27/25 | 27/25 | 32/25 | 28/25 |
error/timeout | 6125 | 9410 | 1090 | 7670 |
stdev/bias | 28.8 | 28.7 | 29.8 | 38.1 |
YGC/YGCT (s) | 66/4.818 | 32/3.579 | 23/22.947 | 20/5.442 |
FGC/FGCT (s) | 4/0.197 | 2/0.202 | 2/1.689 | 2/0.469 |
测试小结
此次升级 bdb 版本的重要目的就是使用堆外内存, 降低堆内存, 从而降低 gc 的压力; 在计划 C 中选取了不同的 Xmx, 从测试结果中可以得知:
较高的堆内存 (30g) 虽然没有明显的 gc 压力, 但是在乖离率, max rt 等方面相比中等内存 (20g, 25g) 有增加; 另外, 较低的堆内存 (10g) 由于可用内存太少, 可以看出存在频繁的 gc, 无论是 young gc 还是 old gc, 都明显高于其他测试组;
综合来说, 这里建议分配适当的堆内存空间 (20g ~ 25g) 作为 Xmx;
选取对比的两个目标版本为: 6.4.25 vs 7.4.5;
测试环境:1
2
3
4
5
6-Xms=25g
-Xmx=25g
-Xmn=10g
-Dje.maxMemoryPercent=10
-XX:MaxDirectMemorySize=30g
-Dje.maxOffHeapMemory=10g
注意: 当 bdb 版本降为 6.4.25 时, 其性能已支撑不了前面三个测试计划中的 250 并发量, 频繁超时, 无法收集到有效数据; 经过多次调节, 确定将并发数降低到 50 方可收集到有效数据;
D-1: 测试 set
测试命令:1
2
3# 50 万次请求, 50 个并发
ab -n 1000000 -c 50 -s 5 "http://${remote_url}/random_set?keyRange=9999999&valueSize=5000"
sudo -u www jstat -gcutil -h 10 ${vmid} 1000 | tee /tmp/jstat_collect/D-1_X
测试结果:
metrics \ version | 6.4.25 | 7.4.5 |
---|---|---|
RT (min/P90/P99/max) (ms) | 6/24/40/755 | 6/24/40/1015 |
RT (mean/median) (ms) | 17/15 | 17/15 |
error/timeout | 0 | 0 |
stdev/bias | 13.5 | 13.4 |
YGC/YGCT (s) | 7/2.739 | 7/2.621 |
FGC/FGCT (s) | 0/0 | 0/0 |
D-2: 测试 get
测试命令:1
2
3# 50 万次请求, 50 个并发
ab -n 500000 -c 50 -s 5 "http://${remote_url}/random_get?keyRange=9999999"
sudo -u www jstat -gcutil -h 10 ${vmid} 1000 | tee /tmp/jstat_collect/D-2_X
测试结果:
metrics \ version | 6.4.25 | 7.4.5 |
---|---|---|
RT (min/P90/P99/max) (ms) | 6/8/8/1077 | 6/8/8/1130 |
RT (mean/median) (ms) | 7/7 | 7/7 |
error/timeout | 2 | 2 |
stdev/bias | 12.6 | 10.9 |
YGC/YGCT (s) | 3/0.604 | 4/0.849 |
FGC/FGCT (s) | 0/0 | 0/0 |
D-3: 测试 mget
测试命令:1
2
3# 50 万次请求, 50 个并发
ab -n 500000 -c 50 -s 5 "http://${remote_url}/random_mget?keyRange=9999999&keyNum=20"
sudo -u www jstat -gcutil -h 10 ${vmid} 1000 | tee /tmp/jstat_collect/D-3_X
测试结果:
metrics \ version | 6.4.25 | 7.4.5 |
---|---|---|
RT (min/P90/P99/max) (ms) | 6/8/9/510 | 6/8/9/1068 |
RT (mean/median) (ms) | 8/7 | 8/7 |
error/timeout | 8 | 2 |
stdev/bias | 9.2 | 12.0 |
YGC/YGCT (s) | 8/1.561 | 8/1.049 |
FGC/FGCT (s) | 0/0 | 0/0 |
测试小结
从测试结果来看, 50 并发量的请求压力下, 6.4.25 与 7.4.5 版本没有存在明显的差距; 但是在更高的并发量下, 6.4.25 版本的 berkeley db 根本扛不住;
所以这里毫无疑问, 7.4.5 版本的 berkeley db 是优于 6.4.25 版本的;
最后是关于收集器的对比; 考虑到 G1 对于大内存 (大于 16g) 的延时管理较其他收集器有优势, 这里也需要就收集器作一些对比测试;
两个收集器的选项对比如下:
ParNew + CMS:1
2
3
4
5
6
7
8-XX:ParallelGCThreads=${CPU_COUNT}
-XX:+UseConcMarkSweepGC
-XX:+UseCMSCompactAtFullCollection
-XX:+CMSClassUnloadingEnabled
-XX:CMSInitiatingOccupancyFraction=80
-XX:+UseCMSInitiatingOccupancyOnly
-XX:+CMSScavengeBeforeRemark
G1:1
2
3-XX:+UnlockDiagnosticVMOptions
-XX:+UseG1GC
-XX:+G1SummarizeConcMark
测试环境:1
2
3
4
5
6-Xms=25g
-Xmx=25g
-Xmn=10g
-Dje.maxMemoryPercent=10
-XX:MaxDirectMemorySize=30g
-Dje.maxOffHeapMemory=10g
E-1: 测试 set
测试命令:1
2
3# 100 万次请求, 250 个并发
ab -n 1000000 -c 250 -s 5 "http://${remote_url}/random_set?keyRange=9999999&valueSize=5000"
sudo -u www jstat -gcutil -h 10 ${vmid} 1000 | tee /tmp/jstat_collect/E-1_X
测试结果:
metrics \ collector | CMS | G1 |
---|---|---|
RT (min/P90/P99/max) (ms) | 6/128/352/1708 | ab timeout |
RT (mean/median) (ms) | 87/75 | / |
error/timeout | 0 | / |
stdev/bias | 72.0 | / |
YGC/YGCT (s) | 14/4.397 | / |
FGC/FGCT (s) | 0/0 | / |
E-2: 测试 get
测试命令:1
2
3# 100 万次请求, 250 个并发
ab -n 1000000 -c 250 -s 5 "http://${remote_url}/random_get?keyRange=9999999"
sudo -u www jstat -gcutil -h 10 ${vmid} 1000 | tee /tmp/jstat_collect/E-2_X
测试结果:
metrics \ collector | CMS | G1 |
---|---|---|
RT (min/P90/P99/max) (ms) | 8/26/29/1038 | / |
RT (mean/median) (ms) | 23/24 | / |
error/timeout | 2 | / |
stdev/bias | 17.8 | / |
YGC/YGCT (s) | 6/0.963 | / |
FGC/FGCT (s) | 0/0 | / |
E-3: 测试 mget
测试命令:1
2
3# 100 万次请求, 250 个并发
ab -n 1000000 -c 250 -s 5 "http://${remote_url}/random_mget?keyRange=9999999&keyNum=20"
sudo -u www jstat -gcutil -h 10 ${vmid} 1000 | tee /tmp/jstat_collect/E-3_X
测试结果:
metrics \ collector | CMS | G1 |
---|---|---|
RT (min/P90/P99/max) (ms) | 6/28/31/4804 | / |
RT (mean/median) (ms) | 32/25 | 8/7 |
error/timeout | 10907 | / |
stdev/bias | 29.8 | / |
YGC/YGCT (s) | 23/22.947 | / |
FGC/FGCT (s) | 2/1.689 | / |
测试小结
可惜了, 我 retry 了几次, 使用 G1 gc, ab 都在途中 timeout 了; jstat 显示 G1 的 young gc 经历了一个非常长的时间:1
2
3
4
5S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
0.00 100.00 94.87 47.44 98.45 96.54 12 6.923 0 0.000 6.923
0.00 100.00 94.87 47.44 98.45 96.54 12 6.923 0 0.000 6.923
0.00 100.00 2.89 50.92 98.46 96.54 12 14.315 0 0.000 14.315
0.00 100.00 2.97 50.92 98.46 96.54 12 14.315 0 0.000 14.315
1 | S0 S1 E O M CCS YGC YGCT FGC FGCT GCT |
我对 G1 的了解还是不够深入, 可能当前的场景比较特殊, 需要作定制化的调参, 之前使用 G1 都是只加 -XX:+UseG1GC
和 -XX:+G1SummarizeConcMark
两个参数, 其余的优化都交给 jvm 了, 然而对于今天的场景这可能不够用了, 这个需要另行研究了;
本次测试采用 ab + jstat 组合的方式同时采集测试数据, ab 用于反映系统表面的性能指标, jstat 用于反映系统的 gc 状态, 并进而反映隐藏在表面之下的系统性能问题或者服务潜力;
本次测试并没有作极限测量 (不断增大并发直至压挂为止), 而是根据当前的调用状况取了一个留有适当 buffer 的并发量, 从测试结果中可以间接得计算当前服务能承载的 TPS;
根据测试的结果, 7.4.5 版本的 berkeley db 优于 6.4.25 版本的 berkeley db, 其在并发承受能力上存在明显优势;
在收集器选择上, 暂时还是使用 CMS 比较稳妥, G1 可能遇到了特殊的情况, 需要后续研究调优的方法;
在使用 7.4.5 版本的 berkeley db 时, 建议作如下内存配置组合, 以达到较好的使用效果:1
2
3
4
5
6-Xms= 20g ~ 30g
-Xmx= 20g ~ 30g
-Dje.maxMemoryPercent= ${Xmx} * 80% / ${bdb_shard_number}
-XX:MaxDirectMemorySize= (${machine_total_memory} - ${Xmx}) * 80%
-Dje.maxOffHeapMemory= ${MaxDirectMemorySize} / ${bdb_shard_number}
两年前在老东家, 我于 InheritableThreadLocal 上踩过一次坑, 可惜当时坑不算深, 就没有把相关的知识点总结下来; 结果两年后的今天我在新东家又遇到了类似问题, 似曾相识却又记不太清楚具体的情况了; 所以这一次一定要认真总结一下 (本文代码基于 jdk 1.8);
ThreadLocal 并不是一个独立的存在, 它与 Thread 类是存在耦合的, java.lang.Thread 类针对 ThreadLocal 提供了如下支持:1
2
3/* ThreadLocal values pertaining to this thread. This map is maintained
* by the ThreadLocal class. */
ThreadLocal.ThreadLocalMap threadLocals = null;
每个线程都将自己维护一个 ThreadLocal.ThreadLocalMap
类在上下文中; 所以, ThreadLocal 的 set 方法其实是将 target value 放到当前线程的 ThreadLocalMap 中, 而 ThreadLocal 类自己仅仅作为该 target value 所对应的 key:1
2
3
4
5
6
7
8public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
1 | ThreadLocalMap getMap(Thread t) { |
1 | void createMap(Thread t, T firstValue) { |
get 方法也是类似的道理, 从线程的 ThreadLocalMap 中获取以当前 ThreadLocal 为 key 对应的 value:1
2
3
4
5
6
7
8
9
10
11
12
13public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
"unchecked") (
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}
需要注意的是, 如果没有 set 过 value, 此处 get() 将返回 null, 不过 initialValue() 方法是一个 protected 方法, 所以子类可以重写逻辑实现自定义的初始默认值;1
2
3
4
5
6
7
8
9
10private T setInitialValue() {
T value = initialValue();
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
return value;
}
1 | protected T initialValue() { |
综上所述: ThreadLocal 实现线程关联的原理是与 Thread 类绑定, 将数据存储在对应 Thread 的上下文中;
ThreadLocal 中主要有两个使用中需要注意的地方;
讨论这个问题之前, 需要先介绍一下 ThreadLocal.ThreadLocalMap 类中维护了的一个自定义数据结构 Entry, 其定义如下:1
2
3
4
5
6
7
8
9static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
这里要注意的是, Entry 类继承了弱引用 WeakReference
, 更具体的说, Entry 中的 key (ThreadLocal 类型) 使用弱引用, value 依旧使用强引用;
To help deal with very large and long-lived usages, the hash table entries use WeakReferences for
keys
.
这其实是一个令初学者感到困惑的设计:
假设 Entry 不继承 WeakReference, 令 key 也使用强引用, 那么结合上一节的内容, 只要该 thread 不退出, 通过 Thread -> ThreadLocal.ThreadLocalMap -> key 这条引用链, 该 key 就可以一直与 gc root 保持连通; 这时即便在外部这个 key 对应的 threadLocal 已经没有有效引用链了, 但只要该 thread 不退出, jvm 依旧会判定该 threadlocal 不可回收;
于是尴尬的事情发生了: 由于 ThreadLocal.ThreadLocalMap 这个内部类没有对外暴露 public 方法, 在 Thread 类里面 ThreadLocal.ThreadLocalMap 也是 package accessible 的, 这意味着我们已经没有任何方法访问到该 key 对应的 value 了, 可它就是无法被回收, 这便是一个典型的内存泄露;
而如果使用 WeakReference 这个问题就解决了: 当该 key 对应的 threadlocal 在外部已经失效后, 便仅存在 thread 里的 weak reference 指向它, 下次 gc 时这个 key 就会被回收掉;
针对这一特性, ThreadLocal.ThreadLocalMap 也配套了与之相适应的内部清理方法: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
31private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;
// expunge entry at staleSlot
tab[staleSlot].value = null;
tab[staleSlot] = null;
size--;
// Rehash until we encounter null
Entry e;
int i;
for (i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
if (k == null) {
e.value = null;
tab[i] = null;
size--;
} else {
int h = k.threadLocalHashCode & (len - 1);
if (h != i) {
tab[i] = null;
// Unlike Knuth 6.4 Algorithm R, we must scan until null because multiple entries could have been stale.
while (tab[h] != null)
h = nextIndex(h, len);
tab[h] = e;
}
}
}
return i;
}
在该方法里, 除了清理指定下标 staleSlot 的 entry 外, 还会遍历整个 entry table, 当发现有 key 为 null 时, 就会触发 rehash 压缩整个 table, 以达到清理的作用;
下面就要提到这里的一个隐藏的坑, ThreadLocal 并没有配合使用 ReferenceQueue 来监听已经回收的 key 以实现自动回调 expungeStaleEntry 方法清理空间的功能; 所以 threadlocal 实例是回收了, 但是引用本身还在, 其所对应的 value 也就还在:
However, since reference queues are not used, stale entries are guaranteed to be removed only when the table starts running out of space.
实际上, expungeStaleEntry 方法是被安插到了 ThreadLocal.ThreadLocalMap 中的 get, set, remove 等方法中, 并被 ThreadLocal 的 get, set, remove 方法间接调用, 必须显式得调用这些方法, 才能主动式地清理空间;
在某些极端场景下, 如果某些 threadlocal 设置的 value 是大对象, 而所涉及的 thread 却没来得及在 threadlocal 被 gc 前作 remove, 再加上之后也没有什么其他 threadlocal 去作 get / set 操作, 那这些大对象是没机会被回收的, 这将造成严重的内存泄露甚至是 OOM; 所以使用 ThreadLocal 要谨记一点: 用完主动 remove, 主动释放内存, 而且是放在 finally 块里面 remove, 以确保执行;
在很多系统中, 我们会定义一个 static final 的全局 ThreadLocal, 这样其实就不存在 threadlocal 被回收的情况了, 上面说的 WeakReference 机制也将效用有限, 这种环境下我们就更加需要用完后主动作 remove 了;
在下一节中我还会继续讲到 value 串位的问题; 这一节所讲的串位与下一节相比, 有相似之处也有不同的问题场景; 与此同时, 这一节的串位与上一小节的内容也有一丝关联;
通常而言, 我们的代码总是跑在应用容器里, 如 tomcat, jetty, 或者是 dubbo 这样的服务框架内; 这些基础组件都有一个共性: 线程池化复用; 在这种场景下, 线程被线程池托管, 在整个应用的生命周期中, 这些 worker 线程往往是不会轻易退出的;
试想一种极端场景: 在一个处理线程内, 我们条件性得 (并非每次都会) 使用 ThreadLocal.set 方法设置一个 value, 然后在后续逻辑中又使用 ThreadLocal.get 方法获取该值; 一个处理线程在上一个任务执行结束之前未作 ThreadLocal.remove 清理 value, 刚巧这个线程在接手下一个任务时未满足条件, 没有调用 ThreadLocal.set 方法设置 value, 此时它所绑定的是上一个任务的 value, 在后面调用 ThreadLocal.get 时, 拿到的就是串位的数据了;
这也再一次提醒我们: 使用 ThreadLocal, 在逻辑处理完后, 一定要作 remove;
首先要说的是, 上文所讲的 ThreadLocal 的问题与注意点, 对 InheritableThreadLocal 都是成立的, 这里便不再赘述;
与 ThreadLocal 类似, InheritableThreadLocal 类也不是独立存在的, Thread 类针对 InheritableThreadLocal 作了如下支持:1
2
3
4
5/*
* InheritableThreadLocal values pertaining to this thread. This map is
* maintained by the InheritableThreadLocal class.
*/
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
只是, InheritableThreadLocal 要额外实现子线程传递 threadlocal 的任务, 所以 Thread 类在构造方法中还提供了额外的支持以将父线程的 ThreadLocalMap 传递给子线程:1
2
3public Thread() {
init(null, null, "Thread-" + nextThreadNum(), 0);
}
1 | private void init(ThreadGroup g, Runnable target, String name, long stackSize) { |
下面要说的是 InheritableThreadLocal 在线程复用组件下的串位问题;
上一小节所讲的 ThreadLocal 的 value 串位问题, 对于 InheritableThreadLocal 来说也是存在的, 这点自不必说; 然对于 InheritableThreadLocal 所提供的额外功能 父子线程传递 value 来说, 还有一种线程复用场景, 会遇到类似的坑;
在 jdk 1.5 之前我们没有线程池的时候, 子线程的创建都是手工及时完成的, 那种场景下父子线程的关系是唯一绑定的, 绝对不会出现 value 串位的问题; 然而 Doug Lea 大神开发了 ThreadPoolExecutor, 这彻底改变了我们使用多线程的习惯, 它不仅仅在各种容器中出现, 我们的日常代码中凡涉及多线程的地方, 大多也会采用线程池的方式实现;
那么问题来了: 在线程池中, worker 线程是被复用的, worker 线程的父线程是谁并没有人关心, 反正 worker 线程的父线程大多数都比 worker 线程本身要短命许多; 而线程的初始化只发生在其创建的时候, 根据上面的内容, InheritableThreadLocal 传递 value 只发生在子线程初始化的时候, 也就是线程刚创建的时候; 所以, 往线程池中提交任务的时候, 除非是线程池刚好创建了一个新线程, 才能顺利得将 value 传递下去, 否则大多数时候都只是复用已经存在的线程, 那线程中的 value 早已不是当前线程想要传递的值;
InheritableThreadLocal value 串位问题的根本原因在于它依赖 Thread 类本身的机制传递 value, 而 Thread 类由于其于线程池内 “复用存在” 的形式而导致 InheritableThreadLocal 的机制失效; 所以针对 InheritableThreadLocal 的改进, 突破点就在于如何摆脱对 Thread 类的依赖;
现在业界内比较好的解决思路是将对 Thread 类的依赖转移为对 Runnable / Callable 的依赖, 因为提交任务时 Runnable / Callable 是实时构造出来的, 父线程可以在其构造之时将 value 植入其中;
下面以阿里为例, 介绍一种典型的实现; 阿里巴巴开源了其对 InheritableThreadLocal 的改进方案: alibaba/transmittable-thread-local;
纵观其源码, TransmittableThreadLocal 的核心设计之一在于其自己维护了一个静态全局的 holder, 存储了所有的 TransmittableThreadLocal 实例:1
2
3
4
5
6static ThreadLocal<Map<TransmittableThreadLocal<?>, ?>> holder = new ThreadLocal<Map<TransmittableThreadLocal<?>, ?>>() {
protected Map<TransmittableThreadLocal<?>, ?> initialValue() {
return new WeakHashMap<TransmittableThreadLocal<?>, Object>();
}
};
这里的一个设计细节是, 其使用 WeakHashMap 作为存储 TransmittableThreadLocal 实例的容器; 这里与上文所讲的 ThreadLocal.ThreadLocalMap.Entry 使用 WeakReference 作为 key 的原理是类似的, 可以便捷得发现已经无效的 threadlocal, 而且 WeakHashMap 使用了 ReferenceQueue 去监听 key 的 gc 情况, 不用像 ThreadLocal 那样每次需要遍历全表以寻找 stale entries;
同时, TransmittableThreadLocal 提供一个 copy() 方法实时复制所有 TransmittableThreadLocal 实例及其在当前线程的 value:1
2
3
4
5
6
7static Map<TransmittableThreadLocal<?>, Object> copy() {
Map<TransmittableThreadLocal<?>, Object> copy = new HashMap<TransmittableThreadLocal<?>, Object>();
for (TransmittableThreadLocal<?> threadLocal : holder.get().keySet()) {
copy.put(threadLocal, threadLocal.copyValue());
}
return copy;
}
TransmittableThreadLocal 的另一个核心设计是它封装了自己的 Runnable 和 Callable; 以其封装的 TtlRunnable 为例, 其提供了一个 private 类型的构造器:1
2
3
4
5private TtlRunnable(Runnable runnable, boolean releaseTtlValueReferenceAfterRun) {
this.copiedRef = new AtomicReference<Map<TransmittableThreadLocal<?>, Object>>(TransmittableThreadLocal.copy());
this.runnable = runnable;
this.releaseTtlValueReferenceAfterRun = releaseTtlValueReferenceAfterRun;
}
可以发现, 在 TtlRunnable 构造之初, 除了包装原始的 Runnable 之外, 其复制了当前线程下所有的 TransmittableThreadLocal 实例及其对应的 value, 放到了一个 AtomicReference 包装的 map 之中, 这样就完成了由父线程向 Runnable 的 value 传递;
下面是最关键的 run() 方法的处理:1
2
3
4
5
6
7
8
9
10
11public void run() {
Map<TransmittableThreadLocal<?>, Object> copied = copiedRef.get();
// 非核心逻辑已省略
......
Map<TransmittableThreadLocal<?>, Object> backup = TransmittableThreadLocal.backupAndSetToCopied(copied);
try {
runnable.run();
} finally {
TransmittableThreadLocal.restoreBackup(backup);
}
}
拿到父线程所有的 threadlocal -> value 键值对后, 需要将其一一设置到自己的 ThreadLocal 中:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21static Map<TransmittableThreadLocal<?>, Object> backupAndSetToCopied(Map<TransmittableThreadLocal<?>, Object> copied) {
Map<TransmittableThreadLocal<?>, Object> backup = new HashMap<TransmittableThreadLocal<?>, Object>();
for (Iterator<? extends Map.Entry<TransmittableThreadLocal<?>, ?>> iterator = holder.get().entrySet().iterator();
iterator.hasNext(); ) {
Map.Entry<TransmittableThreadLocal<?>, ?> next = iterator.next();
TransmittableThreadLocal<?> threadLocal = next.getKey();
backup.put(threadLocal, threadLocal.get());
if (!copied.containsKey(threadLocal)) {
iterator.remove();
threadLocal.superRemove();
}
}
// 将 runnable 携带的父线程 threadlocal -> value 键值对, 真正用 ThreadLocal.set 将 value 设置到子线程中去
for (Map.Entry<TransmittableThreadLocal<?>, Object> entry : copied.entrySet()) {
"unchecked") (
TransmittableThreadLocal<Object> threadLocal = (TransmittableThreadLocal<Object>) entry.getKey();
threadLocal.set(entry.getValue());
}
doExecuteCallback(true);
return backup;
}
接下来在调用原始 Runnable 的 run() 方法时, 便能够顺利 get 到父线程的 value 了;
什么废话都不用多说, 就一句话: 搭梯子, 程序员的基本功!
最近在折腾 fedora, 想着不能老是蹭公司的 “绿色通道”, 遂自己兑了点美刀, 自力更生干了起来, 顺手在这里总结一下;
要搭梯子, 得买个国外的云主机服务; 以 DigitalOcean 为例, 选择 centos 系统的 elastic compute service, 如果不使用定制的 cloud-init, DigitalOcean 创建的虚机将配置默认的 yum 源 (附带一个 digitalocean 自己的源):1
2
3
4
5
6
7
8
9CentOS-Base.repo
CentOS-CR.repo
CentOS-Debuginfo.repo
CentOS-fasttrack.repo
CentOS-Media.repo
CentOS-Sources.repo
CentOS-Vault.repo
# digitalocean 附带的自己的源
digitalocean-agent.repo
默认的源里面是没有 shadowsocks 相关的软件包的, 这意味着我们无法使用 yum 安装 shadowsocks server;
在 shadowsocks 的 官方 github 上, 有多种 shadowsocks 版本: python, go, rust, R, nodejs 等; 以 shadowsocks-go 为例, 其 release 页面 提供各种版本的二进制包供下载; 而 GFW 迫于中国 IT 界的压力暂不能封锁 github, 这样我们就可以从 shadowsocks github 官方页面上下载 shadowsocks 了;
在 github 有个好心人做了一个更加便捷的 shadowsocks 一站式安装工具 teddysun/shadowsocks_install 方便广大网民 “一键部署”; 以安装 shadowsocks-go 为例, 其提供了 shadowsocks-go.sh 脚本, 其中安装 shadowsocks 的主函数内容如下:1
2
3
4
5
6
7
8
9
10install_shadowsocks_go() {
disable_selinux
pre_install
download_files
config_shadowsocks
if check_sys packageManager yum; then
firewall_set
fi
install
}
其中:
/usr/bin/
目录;/etc/init.d/
目录;/etc/shadowsocks/config.json
;可以发现,
在 centos 裸镜像中, 使用以上工具部署 shadowsocks-server 的过程总结如下:
(1) 安装 wget1
yum -y install wget
(2) 下载安装脚本1
2wget --no-check-certificate -O shadowsocks-go.sh https://raw.githubusercontent.com/teddysun/shadowsocks_install/master/shadowsocks-go.sh
sudo chmod +x shadowsocks-go.sh
(3) 执行脚本1
bash shadowsocks-go.sh 2>&1 | tee shadowsocks-server-install.log
可以说是相当方便, 为国外云主机上安装 shadowsocks-server 的首选方案;
ssh 相关的命令是日常开发中基础中的基础, 乃是登陆机器操作必不可少的过程; 但是越是寻常, 可能越容易疏于整理总结; 本文就从 openssh-client 着手, 总结一下 .ssh 目录, ssh 相关命令, 以及相关配置文件的使用;
.ssh 目录对权限的要求是比较苛刻的, 毕竟涉及到了私密信息的安全问题; 一般来说, .ssh 下各目录的权限要求如下 (这里只考虑使用 rsa 算法而不考虑 dsa, ecdsa 等其他非主流的加密算法):
以下是一个直观的例子:1
2
3
4
5
6
7> ls -al .ssh/
drwx------ 2 zshell.zhang qunarops 76 Dec 25 13:27 .
drwx------ 4 zshell.zhang qunarops 94 Dec 25 14:50 ..
-rw------- 1 zshell.zhang qunarops 12997 Dec 25 15:38 authorized_keys
-rw------- 1 zshell.zhang qunarops 1679 Dec 25 11:55 id_rsa
-rw-r--r-- 1 zshell.zhang qunarops 407 Dec 25 11:55 id_rsa.pub
-rw-r--r-- 1 zshell.zhang qunarops 7931 Dec 25 14:02 known_hosts
一般来说, id_rsa 是私钥, id_rsa.pub 是公钥, 公钥与私钥的命名只是约定俗成, 没有强制规定, 可以自定义; 但自定义之后要使用特定的私钥登陆就需要在命令中使用参数指定, 具体请见下一小节;
还有一点需要说明的是, 这四类文件虽然都默认存在于用户家目录下的 .ssh/ 目录中, 但对于同一台主机上的同一个用户, 这四个文件并不都会同时出现, 如果真的同时出现了, authorized_keys 与 id_rsa, known_hosts 中的内容也不会有什么关联; 关于 authorized_keys 和 known_hosts 的具体说明, 请见下文;
authorized_keys 记录了允许以当前用户登陆该主机的所有公钥, 但凡一个登陆请求的私钥与 authorized_keys 中的公钥相匹配, 则此次登陆成功; 所以, authorized_keys 并非用于 openssh-client, 而是 server 端的 sshd, 这也是上文所说的: 即便 authorized_keys 与 id_rsa 共存于一个 .ssh 目录下, 两者在内容上也是独立的, 前者是校验别人登陆到本机器的, 而后者是用于从本机器登陆其他主机的;
在日常运维值班中, 有一个比较频繁的事情便是机器权限申请的审核与开通, 这里面的操作就涉及到 authorized_keys 的更新; 通常我们会使用自动化运维工具 (例如 saltstack, ansible) 在目标主机上执行相关的逻辑:1
2
3
4
5# 创建用户
useradd -g ${user_group} -d ${user_dir}/${user_name} $user_name
# 将公钥写入目标主机对应用户的 authorized_keys 文件
wget -O ${user_dir}/${user_name}/.ssh/authorized_keys http://user_query_service_url/${user_name}/id_rsa.pub
...
对于最后一个 known_hosts, 其主要用于 openssh-client 对每次登陆的主机的 host key 作校验; 主机 host key 的构成在 man sshd
中有如下介绍:
Each line in these files contains the following fields: hostnames, bits, exponent, modulus, comment. The fields are separated by spaces.
host key 中存储了 hostname, ip 等内容, 并作了哈希编码; 当 openssh-client 试图连接一个主机时:
1 | The authenticity of host '10.64.0.11 (10.64.0.11)' can't be established. |
中间人攻击
告警信息:1 | @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ |
其实, 在公司的内网环境中, 大可不必考虑中间人攻击的可能, 倒是日常运维操作致使主机 ip 地址改变的情况时有发生, 所以对于这种提示, 只需要更新 known_hosts 文件, 删除对应的 host key 即可:1
ssh-keygen -f "/home/zshell.zhang/.ssh/known_hosts" -R l-xx1.ops.cn1
重新 ssh 连接, 经过询问与确认之后, 新的 host key 便会写入 known_hosts 文件;
最后回过头来总结一下:
像 id_rsa 以及 authorized_keys 这类涉及到私人信息安全的文件一定是要对其余用户不可访问的: 如果私钥文件对其余用户可读, openssh-client 会直接拒绝并提示文件权限设置过宽, 如果 authorized_keys 对其余用户可读, 则用户无法登陆, 会提示需要输入密码; 而类似 id_rsa.pub 公钥这种原本设计就是要公开的信息, 设置成 644, 对其余用户只读即可;
ssh 命令常用的选项如下:1
2
3
4# -i: identity, 指定私钥文件, 适用于文件名自定义的私钥文件
# -p: port, 指定连接 openssh-server 的端口号
# -X: 开启 openssh 的 Forwarding X11 图形界面功能
ssh -p 22 -i .ssh/id_rsa_xxx zshell.zhang@l-xx1.ops.cn1
scp 命令常用的选项如下:1
2
3
4
5# -r: recursive, 传输整个目录下的子文件
# -l: limit, 限制传输带宽, 单位是 kb/s
# -i: identity, 指定私钥文件
# -P: port, 指定端口
scp -r zshell.zhang@l-xx1.ops.cn2:/tmp/xxx ~/Downloads
与 openssh-client 相关的命令, 还有一个 sftp, 在本文中不作详细讨论, 本站另一篇文章中单独讨论了 sftp 相关的内容: sftp 相关知识梳理;
openssh-client 的配置文件主要有两方面, 全局配置和个人家目录下的私有配置; 在可配置的内容选项上, 全局配置与私有配置其实没有差别, 只不过习惯上会将一些比较通用的配置放在全局配置里;
openssh 的全局配置文件的路径: /etc/ssh/ssh_config
;1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18Host * # 对所有的 host 适用的配置
ForwardAgent no
ForwardX11 no # 允许开启图形界面支持
RhostsAuthentication no
RhostsRSAAuthentication no
RSAAuthentication yes
PasswordAuthentication yes
FallBackToRsh no
UseRsh no
BatchMode no
CheckHostIP yes
StrictHostKeyChecking no
# 默认的私钥文件, 按先后顺序依次获取
IdentityFile ~/.ssh/identity
IdentityFile ~/.ssh/id_rsa
Port 22
Cipher 3des
EscapeChar ~
openssh 的私有配置文件的路径: $HOME/.ssh/config
;1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16Host * # 对所有的 host 适用的配置
ServerAliveInterval 30
ControlPersist yes
ControlMaster auto
ControlPath ~/tmp/ssh/master-%r@%h:%p
ConnectTimeout 30
TCPKeepAlive yes
StrictHostKeyChecking no
# 对所有匹配到 *.cn0 的主机, 均使用以下配置连接
Host *.cn0
Port 22
User zshell.zhang # 使用 zshell.zhang 用户登陆目标主机
IdentityFile ~/.ssh/id_rsa # 使用指定的私钥文件
ProxyCommand ssh zshell.zhang@l-rtools1. -W %h:%p # 具体的 ssh 命令
网易是个有情怀的公司, 云音乐客户端推出了 linux 版本, 虽然是个 deb 包, 那也值得尊敬!
我现在要做的, 就是把它移植到 fedora 环境中, 以造福更多的 linux 爱好者!
网上流传着某些 netease-cloud-music 的 rpm 包, 但是经测试发现这些 rpm 包无法正常使用;
所以现在一个经测试验证可行的方案是下载官方的 deb 包, 然后提取关键内容手动移到 fedora 上: 无论是 ubuntu 还是 fedora, 都以同样的本质运行 linux 进程, 软件包只是打包方式而已, 不影响程序的执行过程;
以 netease-cloud-music_1.1.0_amd64_ubuntu.deb 为例, 将其解压后得到如下文件:1
2
3control.tar.gz
data.tar.xz
debian-binary
其中, data.tar.xz 是核心的内容, 其余的都可以删除; data.tar.xz 是 xz 压缩包, 解压后得到如下目录结构:1
2
3
4
5
6
7
8> xz -d data.tar.xz
> tree -L 2 data
data
└── usr
├── bin
├── lib
└── share
它是对应到 /usr/ 目录的, 所以需要将其全部拷贝到对应目录:1
sudo cp -a usr /
至此, netease-cloud-music 的核心内容已经全部提取并放置到正确路径下了; 其余可能还有一些制作 desktop 图标放到 dock 启动器中等小动作, 本文不再详述;
fedora 安装 netease-cloud-music 所需要的依赖 (安装命令) 列举如下:1
2
3
4
5
6su -c 'dnf install http://download1.rpmfusion.org/free/fedora/rpmfusion-free-release-$(rpm -E %fedora).noarch.rpm http://download1.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm'
sudo dnf install gstreamer1-libav gstreamer1-plugins-ugly gstreamer1-plugins-bad-free gstreamer1-plugins-bad-freeworld gstreamer1-vaapi
sudo dnf install libmad
sudo dnf install vlc # fedora 26 上需要安装, 在我的笔记本上亲测
sudo dnf install qt5-qtx11extras
sudo dnf install qt5-qtmultimedia
以上便是 fedora 安装 netease-cloud-music 的过程记录, fedora 26 上亲测有效;
在处理与 ip 地址相关的 nginx 逻辑上, ngx_http_geo_module 往往能发挥一些有力的作用; 其封装了大量与 ip 地址相关的匹配逻辑, 使得处理问题更加便捷高效;
ngx_http_geo_module 最主要的事情是作了一个 ip 地址到其他变量的映射; 一说到映射, 我们便会想起另一个模块: ngx_http_map_module; 从抽象上讲, geo 模块确实像是 map 模块在 ip (geography) 细分领域内的针对性功能实现;
ngx_http_geo_module 编译默认安装, 无需额外操作;
geo 模块的配置只能在 nginx.conf 中的 http 指令下, 这与 ngx_http_map_module 模块是一致的:1
2
3
4
5
6
7
8
9
10
11static ngx_command_t ngx_http_geo_commands[] = {
{ ngx_string("geo"),
NGX_HTTP_MAIN_CONF|NGX_CONF_BLOCK|NGX_CONF_TAKE12,
ngx_http_geo_block,
NGX_HTTP_MAIN_CONF_OFFSET,
0,
NULL },
ngx_null_command
};
geo 模块的配置模式如下:1
2
3
4geo [$address] $variable {
default 0;
127.0.0.1 1;
}
其中, $address 可选, 默认从 $remote_addr
变量中获取目标 client ip address; 如果使用其他变量作为 ip 地址, 该变量须要是一个合法的 ip 地址, 否则将以 “255.255.255.255” 作为代替;
以下是一个典型的 geo 模块配置, $address 已缺省默认为 $remote_addr
:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18geo $flag {
# 以下是一些设置项
# 定义可信地址, 若 $remote_addr 匹配了其中之一, 将从 request header X-Forwarded-For 获得目标 client ip address
proxy 192.168.100.0/24;
delete 127.0.0.0/16;
# 默认兜底逻辑
default -1;
# 定义外部的映射内容
include conf/geo.conf;
# 以下是具体的映射内容
# 可以使用 CIDR 匹配
192.168.1.0/24 0;
# 精确匹配
10.64.0.5 1;
}
除了以上的典型用法之外, geo 模块还有一种地址段范围的匹配模式:1
2
3
4
5
6
7geo $flag {
# 需放在第一行
ranges;
192.168.1.0-192.168.1.100 0;
192.168.1.100-192.168.1.200 1;
192.168.1.201-192.168.1.255 2;
}