ConcurrentHashMap 的 size 方法原理分析

前言

JAVA 语言提供了大量丰富的集合, 比如 List, Set, Map 等。其中 Map 是一个常用的一个数据结构,HashMap 是基于 Hash 算法实现 Map 接口而被广泛使用的集类。HashMap 里面是一个数组,然后数组中每个元素是一个单向链表。但是 HashMap 并不是线程安全的, 在多线程场景下使用存在并发和死循环问题。HashMap 结构如图所示:

线程安全的解决方案

线程安全的 Map 的实现有 HashTable 和 ConcurrentHashMap 等。HashTable 对集合读写操作通过 Synchronized 同步保障线程安全, 整个集合只有一把锁, 对集合的操作只能串行执行,性能不高。ConcurrentHashMap 是另一个线程安全的 Map, 通常来说他的性能优于 HashTable。 ConcurrentHashMap 的实现在 JDK1.7 和 JDK 1.8 有所不同。

在 JDK1.7 版本中,ConcurrentHashMap 的数据结构是由一个 Segment 数组和多个 HashEntry 组成。简单理解就是ConcurrentHashMap 是一个 Segment 数组,Segment 通过继承 ReentrantLock 来进行加锁,所以每次需要加锁的操作锁住的是一个 Segment,这样只要保证每个 Segment 是线程安全的,也就实现了全局的线程安全。

JDK1.8 的实现已经摒弃了 Segment 的概念,而是直接用 Node 数组 + 链表 + 红黑树的数据结构来实现,并发控制使用 Synchronized 和 CAS 来操作,整个看起来就像是优化过且线程安全的 HashMap,虽然在 JDK1.8 中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本。 通过 HashMap 查找的时候,根据 hash 值能够快速定位到数组的具体下标,如果发生 Hash 碰撞,需要顺着链表一个个比较下去才能找到我们需要的,时间复杂度取决于链表的长度,为 O(n)。为了降低这部分的开销,在 Java8 中,当链表中的元素超过了 8 个以后,会将链表转换为红黑树,在这些位置进行查找的时候可以降低时间复杂度为 O(logN)。

如何计算 ConcurrentHashMap Size

由上面分析可知,ConcurrentHashMap 更适合作为线程安全的 Map。在实际的项目过程中,我们通常需要获取集合类的长度, 那么计算 ConcurrentHashMap 的元素大小就是一个有趣的问题,因为他是并发操作的,就是在你计算 size 的时候,它还在并发的插入数据,可能会导致你计算出来的 size 和你实际的 size 有差距。本文主要分析下 JDK1.8 的实现。 关于 JDK1.7 简单提一下。

在 JDK1.7 中,第一种方案他会使用不加锁的模式去尝试多次计算 ConcurrentHashMap 的 size,最多三次,比较前后两次计算的结果,结果一致就认为当前没有元素加入,计算的结果是准确的。 第二种方案是如果第一种方案不符合,他就会给每个 Segment 加上锁,然后计算 ConcurrentHashMap 的 size 返回。其源码实现:

public int size() {
  final Segment<K,V>[] segments = this.segments;
  int size;
  boolean overflow; // true if size overflows 32 bits
  long sum;         // sum of modCounts
  long last = 0L;   // previous sum
  int retries = -1; // first iteration isn't retry
  try {
    for (;;) {
      if (retries++ == RETRIES_BEFORE_LOCK) {
        for (int j = 0; j < segments.length; ++j)
          ensureSegment(j).lock(); // force creation
      }
      sum = 0L;
      size = 0;
      overflow = false;
      for (int j = 0; j < segments.length; ++j) {
        Segment<K,V> seg = segmentAt(segments, j);
        if (seg != null) {
          sum += seg.modCount;
          int c = seg.count;
          if (c < 0 || (size += c) < 0)
            overflow = true;
        }
      }
      if (sum == last)
        break;
      last = sum;
    }
  } finally {
    if (retries > RETRIES_BEFORE_LOCK) {
      for (int j = 0; j < segments.length; ++j)
        segmentAt(segments, j).unlock();
    }
  }
  return overflow ? Integer.MAX_VALUE : size;
}

JDK1.8 实现相比 JDK 1.7 简单很多,只有一种方案,我们直接看 size() 代码:

    public int size() {
        long n = sumCount();
        return ((n < 0L) ? 0 :
                (n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n);
    }

最大值是 Integer 类型的最大值,但是 Map 的 size 可能超过 MAX_VALUE, 所以还有一个方法 mappingCount(),JDK 的建议使用 mappingCount() 而不是 size()mappingCount() 的代码如下:

   public long mappingCount() {
        long n = sumCount();
        return (n < 0L) ? 0L : n; // ignore transient negative values
    }

以上可以看出,无论是 size() 还是 mappingCount(), 计算大小的核心方法都是 sumCount()sumCount() 的代码如下:

    final long sumCount() {
        CounterCell[] as = counterCells; CounterCell a;
        long sum = baseCount;
        if (as != null) {
            for (int i = 0; i < as.length; ++i) {
                if ((a = as[i]) != null)
                    sum += a.value;
            }
        }
        return sum;
    }

分析一下 sumCount() 代码。ConcurrentHashMap 提供了 baseCount、counterCells 两个辅助变量和一个 CounterCell 辅助内部类。sumCount() 就是迭代 counterCells 来统计 sum 的过程。 put 操作时,肯定会影响 size(),在 put() 方法最后会调用 addCount() 方法。

addCount() 代码如下:
- 如果 counterCells == null, 则对 baseCount 做 CAS 自增操作。

  • 如果并发导致 baseCount CAS 失败了使用 counterCells。

  • 如果counterCells CAS 失败了,在 fullAddCount 方法中,会继续死循环操作,直到成功。

然后,CounterCell 这个类到底是什么?我们会发现它使用了 @sun.misc.Contended 标记的类,内部包含一个 volatile 变量。@sun.misc.Contended 这个注解标识着这个类防止需要防止 "伪共享"。那么,什么又是伪共享呢?

缓存系统中是以缓存行(cache line)为单位存储的。缓存行是2的整数幂个连续字节,一般为32-256个字节。最常见的缓存行大小是64个字节。当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响彼此的性能,这就是伪共享。

CounterCell 代码如下:

    @sun.misc.Contended static final class CounterCell {
        volatile long value;
        CounterCell(long x) { value = x; }
    }

总结

  • JDK1.7 和 JDK1.8 对 size 的计算是不一样的。 1.7 中是先不加锁计算三次,如果三次结果不一样在加锁。
  • JDK1.8 size 是通过对 baseCount 和 counterCell 进行 CAS 计算,最终通过 baseCount 和 遍历 CounterCell 数组得出 size。
  • JDK 8 推荐使用mappingCount 方法,因为这个方法的返回值是 long 类型,不会因为 size 方法是 int 类型限制最大值。
0

从 ThreadLocal 的实现看散列算法

引子

最近在看 JDK 的 ThreadLocal 源码时,发现了一段有意思的代码,如下所示。

    private final int threadLocalHashCode = nextHashCode();
    /**
     * The difference between successively generated hash codes - turns
     * implicit sequential thread-local IDs into near-optimally spread
     * multiplicative hash values for power-of-two-sized tables.
     */
    private static final int HASH_INCREMENT = 0x61c88647;

    /**
     * Returns the next hash code.
     */
    private static int nextHashCode() {
        return nextHashCode.getAndAdd(HASH_INCREMENT);
    }

可以看到,其中定义了一个魔法值 HASH_INCREMENT = 0x61c88647, 对于实例变量 threadLocalHashCode, 每当创建 ThreadLocal 实例时这个值都会 getAndAdd(0x61c88647)

0x61c88647 转化成二进制即为 1640531527,它常用于在散列中增加哈希值。上面的代码注释中也解释到:HASH_INCREMENT 是为了让哈希码能均匀的分布在2的N次方的数组里。

那么 0x61c88647 是怎么起作用的呢?

什么是散列?

ThreadLocal 使用一个自定的的 Map —— ThreadLocalMap 来维护线程本地的值。首先我们先了解一下散列的概念。

散列(Hash)也称为哈希,就是把任意长度的输入,通过散列算法,变换成固定长度的输出,这个输出值就是散列值。

在实际使用中,不同的输入可能会散列成相同的输出,这时也就产生了冲突。通过上文提到的 HASH_INCREMENT 再借助一定的算法,就可以将哈希码能均匀的分布在 2 的 N 次方的数组里,保证了散列表的离散度,从而降低了冲突几率.

哈希表就是将数据根据散列函数 f(K) 映射到表中的特定位置进行存储。因此哈希表最大的特点就是可以根据 f(K) 函数得到其索引。

HashMap 就是使用哈希表来存储的,并且采用了链地址法解决冲突。

简单来说,哈希表的实现就是数组加链表的结合。在每个数组元素上都一个链表结构,当数据被 Hash 后,得到数组下标,把数据放在对应下标元素的链表上。

散列算法

先来说一下散列算法。散列算法的宗旨就是:构造冲突较低的散列地址,保证散列表中数据的离散度。常用的有以下几种散列算法:

除法散列法

散列长度 m, 对于一个小于 m 的数 p 取模,所得结果为散列地址。对 p 的选择很重要,一般取素数或 m

公式:f(k) = k % p (p<=m)

因为求模数其实是通过一个除法运算得到的,所以叫“除法散列法”

平方散列法(平方取中法)

先通过求关键字的平方值扩大相近数的差别,然后根据表长度取中间的几位数作为散列函数值。又因为一个乘积的中间几位数和乘数的每一位都相关,所以由此产生的散列地址较为均匀。

公式:f(k) = ((k * k) >> X) << Y。对于常见的32位整数而言,也就是 f(k) = (k * k) >> 28

斐波那契(Fibonacci)散列法

和平方散列法类似,此种方法使用斐波那契数列的值作为乘数而不是自己。

  1. 对于 16 位整数而言,这个乘数是 40503。
  2. 对于 32 位整数而言,这个乘数是 2654435769。
  3. 对于 64 位整数而言,这个乘数是 11400714819323198485。

具体数字是怎么计算得到的下文有介绍。

为什么使用斐波那契数列后散列更均匀,涉及到相关数学问题,此处不做更多解释。

公式:f(k) = ((k * 2654435769) >> X) << Y。对于常见的32位整数而言,也就是 f(k) = (k * 2654435769) >> 28

这时我们可以隐隐感觉到 0x61c88647 与斐波那契数列有些关系。

随机数法

选择一随机函数,取关键字的随机值作为散列地址,通常用于关键字长度不同的场合。

公式:f(k) = random(k)

链地址法(拉链法)

懂了散列算法,我们再来了解下拉链法。拉链法是为了 HashMap 中降低冲突,除了拉链法,还可以使用开放寻址法、再散列法、链地址法、公共溢出区等方法。这里就只简单介绍了拉链法。

把具有相同散列地址的关键字(同义词)值放在同一个单链表中,称为同义词链表。有 m 个散列地址就有 m 个链表,同时用指针数组 T[0..m-1] 存放各个链表的头指针,凡是散列地址为 i 的记录都以结点方式插入到以 T[i] 为指针的单链表中。T 中各分量的初值应为空指针。

对于HashMap:

HashMap

除法散列(k=16):

HashMap

斐波那契散列:

HashMap

可以看出用斐波那契散列法调整之后会比原来的除法散列离散度好很多。

ThreadLocalMap 的散列

认识完了散列,下面回归最初的问题:0x61c88647 是怎么起作用的呢?

先看一下 ThreadLocalMap 中的 set 方法

private void set(ThreadLocal<?> key, Object value) {
            Entry[] tab = table;
            int len = tab.length;
            int i = key.threadLocalHashCode & (len-1);
            ...
}

ThreadLocalMapEntry[] table 的大小必须是 2 的 N 次方(len = 2^N),那 len-1 的二进制表示就是低位连续的 N 个 1, 那 key.threadLocalHashCode & (len-1) 的值就是 threadLocalHashCode 的低 N 位。

然后我们通过代码测试一下,0x61c88647 是否能让哈希码能均匀的分布在 2 的 N 次方的数组里。

public class MagicHashCode {
    private static final int HASH_INCREMENT = 0x61c88647;

    public static void main(String[] args) {
        hashCode(16); //初始化16
        hashCode(32); //后续2倍扩容
        hashCode(64);
    }

    private static void hashCode(Integer length){
        int hashCode = 0;
        for(int i=0; i< length; i++){
            hashCode = i * HASH_INCREMENT+HASH_INCREMENT;//每次递增HASH_INCREMENT
            System.out.print(hashCode & (length-1));
            System.out.print(" ");
        }
        System.out.println();
    }
}

结果:

7 14 5 12 3 10 1 8 15 6 13 4 11 2 9 0 
7 14 21 28 3 10 17 24 31 6 13 20 27 2 9 16 23 30 5 12 19 26 1 8 15 22 29 4 11 18 25 0 
7 14 21 28 35 42 49 56 63 6 13 20 27 34 41 48 55 62 5 12 19 26 33 40 47 54 61 4 11 18 25 32 39 46 53 60 3 10 17 24 31 38 45 52 59 2 9 16 23 30 37 44 51 58 1 8 15 22 29 36 43 50 57 0 

产生的哈希码分布确实是很均匀,而且没有任何冲突。再看下面一段代码:

public class ThreadHashTest {
    public static void main(String[] args) {
        long l1 = (long) ((1L << 32) * (Math.sqrt(5) - 1)/2);
        System.out.println("as 32 bit unsigned: " + l1);
        int i1 = (int) l1;
        System.out.println("as 32 bit signed:   " + i1);
        System.out.println("MAGIC = " + 0x61c88647);
    }
}

结果:

as 32 bit unsigned: 2654435769
as 32 bit signed:   -1640531527
MAGIC = 1640531527

Process finished with exit code 0
16进制 10进制 2进制 补码
0x61c88647 1640531527 01100001110010001000011001000111 10011110001101110111100110111001

可以发现 0x61c88647 与一个神奇的数字产生了关系,它就是 (Math.sqrt(5) - 1)/2。也就是传说中的黄金比例 0.618(0.618 只是一个粗略值),即 0x61c88647 = 2^32 * 黄金分割比。同时也对应上了上文所提到的斐波那契散列法。

黄金比例与斐波那契数列

最后再简单介绍一下黄金比例,这个概念我们经常能听到,又称黄金分割点。

黄金分割具有严格的比例性、艺术性、和谐性,蕴藏着丰富的美学价值,而且呈现于不少动物和植物的外观。现今很多工业产品、电子产品、建筑物或艺术品均普遍应用黄金分割,展现其功能性与美观性。

对于斐波那契数列大家应该都很熟悉,也都写过递归实现的斐波那契数列。

斐波那契数列又称兔子数列:

  • 第一个月初有一对兔子
  • 第二个月之后(第三个月初),它们可以生育
  • 每月每对可生育的兔子会诞生下一对新兔子
  • 兔子永不死去

转化成数学公式即:

  • f(n) = f(n-1) + f(n-2) (n>1)
  • f(0) = 0
  • f(1) = 1

当n趋向于无穷大时,前一项与后一项的比值越来越逼近黄金比

最后总结下来看,ThreadLocal 中使用了斐波那契散列法,来保证哈希表的离散度。而它选用的乘数值即是2^32 * 黄金分割比

0

理清 Promise 的状态及使用

为什么会有 promise?

因为要解决回调函数的嵌套,也就是所谓的回调地狱,回调地狱长啥样大家应该有点数吧?

doSomethingA((res) =>{
  if (res.data) {
    doSomethingB(res.data, (resB) => {
      if (resB.data) {
        doSomethingC(resB.data)
      }
    })
  }
})

这样的代码不太美观,还得依靠缩进来分清层级。那解决这种回调地狱的方式有很多,最简单方式是定义好一堆具名函数直接调用。那进阶一点方式便是 promise。

promise 是什么?

通过 Promise 构造函数可以创建 promise 对象,promise 是一种通过链式调用的方式来解决回调函数传递的异步方案。
promise 对象具有状态,pending、fulfilled、rejected。状态改变之后就不会再变化。

promise 实例

通过 new 关键字初始化得到新的 promsie 对象。

const promise = new Promise(function(resolve, reject) {
    // ... do something
    if (/* 异步操作成功 */){
        resolve(value);
    } else {
        reject(error);
    }
})

promise 对象创建便会立即执行,但 promise 会保存状态。

基本用法

Promise 定义了一些原型方法来进行状态处理。最常用的有:
- Promise.prototype.then

const pm = new Promise(function(resolve) {
    setTimeout(function() {
        resolve(100)
    }, 2000)
})

pormise 对象通过内部 resolve 函数完成状态的 pending -> fulfilled。此后这个promise 将保留这个状态。可以通过 then 方法去处理状态。

pm.then((val) => {
    console.log(val); // 100
})

then 方法也可以用来处理 rejected 状态,then 方法可以接收 2 个函数参数。

new Promise(function( resolve, reject ) {
    setTimeout(() => {
        reject(new Error('err'))
    }, 2000)
}).then(null, (error) => {
    console.log(error); // error: err 
})

但用 then 的第二个参数去处理错误不是最好的选择。因为大多数情况下我们会用到链式调用。类似:promise.then().then().then()。所以在每个 then 方法去处理错误显得代码很多余,而且也真的没必要。

  • Promise.prototype.catch

catch 方法就是用来做错误统一处理。这样链式调用中我们只需要用 then 来处理 fulfilled 状态,在链的末尾加上 catch 来统一处理错误。

new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve(100);
    }, 2000)
}).then(result => {
    return result * num // 这里模拟一个错误,num 未定义
}).then(result => {
    return result / 2;
}).catch(err => {
    console.log(err); // num is not defined
})

这里举得例子比较简单,在 then 方法里面没有去 return 新的 promise。可以看到第一个 then 发生了错误,最后的 catch 会捕捉这个错误。catch 实际上是.then(null, rejection)的别名。

  • Promise.prototype.finally()

这个 api 一般用来放在结尾,因为它不管前面的 promise 变为什么,它都会执行里面的回调函数。

new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve(100);
    }, 2000)
}).then(result => {
    return result * num // 这里模拟一个错误,num 未定义
}).catch(err => {
    console.log(err); // num is not defined
}).finally(() => {
   console.log('complete'); // complete 
})

这便是一个完整的状态处理流程,then() 处理 fulfilled、catch() 处理 rejected、finally 两种都处理。但 finally 目前还只是提案阶段。

  • Promise.all

这个 api 在开发中也比较实用,如果 C 行为依赖于 A、B 行为,A、B 之间又没有依赖关系,而且只有当 A、B 的状态都为 fulfilled,或者有一个变为 rejected 时才会开始 C 行为。这时用 Promise.all() 就显得比较合适。

Promise.all([A, B]).then((resA, resB) => {
    ... // do something
}).catch(() => {
    ... // do something
})

如果不用 Promise.all 也能做到 A、B 完成之后再调用 C,一般会这么写:

promsiseA.then(() => {
  return promsiseB;
}).then(() => {
    ... // do something
}).catch(() => {
    // do something
})

这样就好比强行将 A、B 置为串行,所以对比 Promise.all() 显得效率不高。

  • Promise.race

用法和 Promise.all 一样,C 依赖于 A、B,但只要 A,B 的任一状态改变了,C 便开始执行。

Promise.race([A, B]).then((res) => {
    ... // do something
}).catch(() => {
    ... // do something
})

链式调用

之所以能在 promise 的使用中进行链式调用,是因为 then、catch、finally 方法都会返回新的 promise 对象。链式调用在 js 中也很常见。

[1, 2, 3].map(num => num*2).join('-')  // "2-4-6"

$('#box').find('.info').text('hello world') // jquery 链式

其实链式调用原理都一样,让方法始终返回新的对象就好了。
promise 中的 then、catch、finally 方法也是如此,总是返回新的 promise。这里都能理解,但有趣的是 promise 具有了状态。这让链式变得稍微复杂了些。

  • 状态变化

then、catch、finally 返回 promise 但这些 promise 的状态由谁决定呢?

答案是如果处理了状态那新得到的 promise 的状态由处理函数的具体内容决定,如果没处理状态那得到 promise 的状态直接继承前面 promise 的状态。

假设 promiseA 的状态为 resolved

const promiseB = promiseA.then(() => {
    return 1
}) // resolved 1

const promiseB = promiseA.then(() => {
    const num = 100 
}) // resolved undefined

const promiseB = promiseA.then(() => {
    throw 1
}) // rejected 1

const promiseB = promiseA.then(() => {
    return promiseC
}) // 状态由 promiseC 决定

假设 promiseA 的状态为 rejected

const promiseB = promiseA.then(() => {
    // do anything 
}) // 状态沿用 promiseA: rejected
  • 错误处理
const pm = new Promise((resolve, reject) => {
    reject('err') // 直接将 pm 对象的状态变为 rejected
})

var pm1 = pm.then(result => {
  console.log('hello, then 1');  // 不会执行
  return 100; // 不会执行
});

这里由于pm 的状态是 rejected, 所以 .then 继续将 rejected 状态向下传递,这样我们就能通过末尾的 catch 操作处理异常。

pm
.then(result => {
    return result * 2
}) // rejected
.then(result => {
    return result * 4
}) // rejected
.catch(err => {
    console.log(err)
})

配合 async await 使用

如果能比较熟练的使用 promsie 再过渡到 async、await 也没什么问题。

  • 怎么使用
async function f() {
    await 100;
}

f().then((num) => {
    console.log(num); // 100
})

简单的例子似乎看不出 async await 到底是干什么的,又有什么用。但可以知道 async 返回的是 promise, await 相当于改变状态的操作。

  • 对比 promise 优势在哪?

aysnc 、await 最大的好处是简化 promise 的 .then 操作,让代码更加同步。

pmA.then((dataA) => {
    ...
    return dataB
}).then((dataB) => {
    ...
    return dataC
}).then((dataC) => {
    ...
    console.log('end')
})

换做是 async、await 就可以写成

async f() {
    const dataA = await pmA;
    const dataB = await fb(dataA);
    const dataC = await fc(dataB);
    ...
}

所以不管 await 后面去接什么样的异步操作,整体上还是会保持同步的方式,这样看起来结构清晰。

总结

从 promsie 到 async、await,JS 提供的这些新的特性,都让 JS 的使用体验变得越来越灵活方便。从 ES6 的出现到现在的 ES7、ES8,也都能感受到 JS 在拥有更大的能力去做好更多的事情。关于 promise 的使用还是要多用多体会,不然用 promise 写出回调地狱一样的代码也不是没有可能。最后还是贴出经典的 promise 问答题!据说能回答正确这些写法区别的 promise 掌握的肯定没问题。

// 1
doSomething().then(function () {
  return doSomethingElse()
}).then(/* ... */)

// 2
doSomething().then(function () {
  doSomethingElse()
}).then(/* ... */)

// 3
doSomething().then(doSomethingElse()).then(/* ... */)

// 4
doSomething().then(doSomethingElse).then(/* ... */)
0

逻辑思维:理清思路,表达自己的技巧

为什么要讲逻辑思维

逻辑思维一直是职场的重要技能之一。当遇到某个问题时,你可以运用逻辑思维去梳理问题、分析问题,从而找到问题的本质与解决方案;当需要向他人陈述结论时,你可以运用逻辑思维去梳理即将要表达的内容,划分清晰沟通中的结论、背景、论点,从而将你的结论条理清晰的传达给对方。

几乎可以这么说,一个人的逻辑思维越强,他解决问题的能力就越强,沟通表达就越清晰。

接下来,文章将分别从逻辑思维如何运用在思考过程中、如何运用在沟通表达中去展开介绍。这两种情况的核心区别在于是已有结论对外陈述,还是未有结论对内分析挖掘。因为现在大多数逻辑思维的讲解都是更注重教给我们如何对外陈述,所以在这里我们也从沟通表达的场景开始介绍。

表达框架:金字塔图组织语言,快速表达

在问题被梳理清晰的前提下,运用逻辑思维去沟通表达,一般是遵循 论点/背景--->结论--->理由--->行动 的整体框架。接下来逐个介绍各个环节的关键点:

1. 论点/背景

论点,一般指接下来谈话的中心内容。论点阐述时经常包含背景介绍,它们往往不可分割。

  • 阐述论点,应尽量从对方了解的信息开始阐述;
  • 我们可以使用 5W2H 法来细化论点:When、Where、Who、What、Why、How、How much,通过 5W2H 方法将来各事件元素(时间、地点、人物、事件、原因、如何进展、进展如何等)梳理清楚。

举一个简单例子,你想找 Boss 聊聊员工加班的事情,你不能说 “Boss,关于加班我想找你聊一聊“ 。这样显然没有将事件元素陈述清晰,Boss 会无法判断你谈话的内容,他只能去猜测你将要表达内容是关于加班的哪个方面。正确的论点阐述应该是 “最近,年轻员工加班时间增加过多了,我们是不是应该做一些调整?”。这样论点就描述清楚了。

2. 结论

结论,指在接下来谈话中你最想陈述的内容,即便删除所有其他内容你也想保留的内容。结论的陈述需要注意以下三点:

  • 结论是解决问题,而不是阐述事实;
  • 结论和论点不能偏离,也就是说结论与论点间的联系要科学易被听众接受:尽可能做到是/否问题回答是/否;原因问题回答原因;怎么做问题回答怎么做。比如,Boss 问你销售为什么不输入商品的预估数据?你却回答,输入的方式应该设计的简单一些,就是有问题的。Boss 问的是原因,你回答的解决方案,跳跃太大,造成 Boss 难以理解。
  • 遵循金字塔原则,大多数时候结论先行,否则倾听者容易产生疲倦。当希望给倾听者以准备时间或者倾听者自行得出结论的时候,我们才将结论放后面。

3. 理由

理由的陈述关键点在于做到符合 MECE 分析法,筛选“合格”理由作为结论的支撑,避免将一个相似的理由分成3个理由来说。那么如何做到符合MECE原则?如何筛选“正确合格”理由?接下来我们详细介绍下:

MECE分析法(Mutually Exclusive Collectively Exhaustive),理由间相互独立,完全穷尽。

通俗地说,就是在没有重复没有遗漏的状态下进行思考或表达。当然,真实的情况往往是我们难以做到真正的穷尽,所以只是力求不遗漏,覆盖 8~9成 即可。

MECE 的分析方法,主要有以下三种:

  • 日常分类模式:投入 vs 产出、生产 vs 消费、人力 vs 物力 vs 财力、过去 vs 现在 vs 未来...
  • 通过公式推导思维解决遗漏:销售额 = 用户数 * 转化率 * 客单价,因子一目了然;
  • 套用经典商业/业务模型:
    • 战略模型:3C-Customer/Competior/Company、SWOT-Strength/Weakness/Opportunity/Threat
    • 营销模型:4Ps-Product/Price/Place/Promotion、4Cs-Customer/Cost/Communication/Convenient
    • 生产管理模型:QCD-Quantity/Cost/Delivery

常见分析方法为我们提供了 MECE 分析的切入点,那么我们要如何选择 MECE 的理由?一个是参考下面将介绍的筛选合适理由的原则,另一个是注意从反向思考去检查MECE分析所得理由的缺点。

选择“正确合格”的理由作为支撑,遵循以下几个原则:

  • 从对方角度设想,选择能让对方信服的理由,所以面对不同的听众,要有不同的侧重;
  • 对于认同感不强的理由,采用“理由的理由”去支持它。“理由的理由”可以是:数据证明、一般常识/规律、事例的累积、已决断的策略、公司规定/制度等;
  • 有大量事实,但没有结论的场景,我们可以先陈列大量理由,从理由中推导出结论;
  • 已经有结论,但理由支撑没有思路的时候,我们同样可以先陈列大量理由,再整理分类理由。

此外,在理由的陈述中还需注意理由与结论的关联。因为理由是结论的支撑,理由推导得到结论的逻辑被听众理解是你的结论被理解的重要前提。下面介绍两种主要的理由推导结论的逻辑:

  • 归纳并举型:列举理由,通过理由的共通点推测结论,这种推理方式的缺点是显得主观,所以要格外注意考虑例外的情况。
  • 演绎推理型:三段式,"大前提 + 小前提" -> 结论。如果大前提、小前提是错误的,那么演绎推理的结论也是无意义的,所以要注意确保大、小前提是正确的。

4. 行动

有结论还应该有行动,行动应该是明确自身和他人的具体任务,并加以Deadline限制。即人、事、时间都是划分清楚的,在此同样可以采用5W2H来确认行动计划是否清晰。

思考框架:梳理问题,逻辑推导解决问题

在还没有将梳理清晰问题时,我们运用逻辑思维去梳理问题,它一般遵循 Problem-->Why-->How 的框架,接下来分开阐述各环节(因为思考过程中运用的逻辑思维方法与沟通表达所运用的方法类似,所以这里不会再陈述的非常详细):

1. 明确问题

明确问题主要有两个方法:

  • 设定想要的状态,即设定目标或设定参照物。当问题很明确,那么设定“理所当然”的目标即可,如:公司连续两年赤字,那么设定目标为公司盈利即可;当问题不明确,那么需要设定理想目标,如:公司连续10年保持全国第四,那么设定目标可考虑5年内成为全国第一。
  • 把问题具体化到能够思考原因的大小,列举具体事例,从事例中归纳问题。如,年轻员工没有朝气,那么我们可以通过列举具体事例:打招呼有气无力、写资料错误率高、辞职率高等,通过这样的细化,我们就知道具体的问题是什么了。

2. 寻找原因

寻找原因的关键在于,不停地询问为什么,逐步深入挖掘。如何有逻辑地深入挖掘原因?这里仍旧建议 MECE 分析法,与表达框架中介绍的理由陈述相似,毕竟在表达中陈述的理由就是在思考中挖掘得到的原因。

挖掘原因的另外一个重要命题是,我们需要深挖到什么程度?各种行业书籍给出的答案是,追问5次,直到能找到具体的解决方案。对于没有可能性的部分中途就可以停止深挖,不必要刻意追问5次,只对有真正可能性的原因进行探究。

3. 检讨解决方案

深挖直到能想象具体怎么做为止,在探讨解决方案的过程中,先不要过多考虑方案的可行性。这里,对日常生活中常见的两类人给出总结与建议:

  • 对于不思考就行动的人,建议多思考在行动,采用根本原因分析、从零开始思考等方法;
  • 对于思考过多而不行动的人,建议一边行动一边思考,采用假设思考法。

总结

以上,为关于逻辑思维整理思路,表达自身技巧的总结与介绍。但仍有一点是需被注意的:逻辑思维虽然有用,但也并非所有的场合都可以用逻辑思维去解决问题。比如,女朋友大发雷霆和你大吵特吵,你如果试图用逻辑思维去解决,那估计是要凉凉了(手动微笑脸)。。。大多数人建议在需要寻找问题总结结论或者进行结论表达的时候才使用逻辑思维!

0