搞懂HashMap,这一篇就够了

弄懂HashMap,这一篇就够了

如果你点开了这篇博客,请一定要读完,可能会花费你20分钟,因为它真的可以帮助你了解到hashmap的底层实现以及使用hashmap的注意事项,声明:这篇博文是摘抄至国外的一个大牛的博客,地址在博文底端。

大多数JAVA开发人员都在使用Maps,尤其是HashMaps。 HashMap是一种简单而强大的数据存储和获取方法。 但是,有多少开发人员知道HashMap在内部如何工作? 在本文中,我将解释java.util.HashMap的实现,介绍JAVA 8实现中的新增功能,并讨论使用HashMaps时的性能,内存和已知问题。

内部存储

Java中HashMap类实现了接口Map <K,V>。该接口的主要方法是:

  • V put(K key, V value)
  • V get(Object key)
  • V remove(Object key)
  • Boolean containsKey(Object key)

HashMap使用了一个内部类来存储数据: Entry<K, V>。

Entry是一个简单的键值对,其中包含两个额外的数据:

  • 对另一个条目的引用,以便HashMap可以存储诸如单个链接列表之类的条目
  • 表示键的哈希值的哈希值。存储此哈希值是为了避免每次HashMap需要哈希时都进行哈希计算。

这是JAVA 7中Entry实现的一部分:

 static class Entry<K,V> implements Map.Entry<K,V> {
    final K key;
    V value;
    Entry<K,V> next;
   int hash;
 …
 }

HashMap将数据存储到多个单链接的条目列表(也称为存储桶或箱),所有列表都注册在Entry数组(Entry <K,V> []数组)中,并且此内部数组的默认容量为16。

这副图显示了带有可为空的条目的数组的HashMap实例的内部存储。每个条目可以链接到另一个条目以形成链接列表。

具有相同哈希值的所有键都放在相同的链表(存储桶)中。具有不同哈希值的键可以最终出现在同一存储桶中。

当我们调用put(K key,V value)或get(Object key)时,该函数将计算条目应在其中的存储区的索引。然后,该函数遍历列表以查找具有相同键的Entry(使用键的equals()函数)。

在get()的情况下,该函数返回与该条目关联的值(如果该条目存在)

对于put(K,V),如果该条目存在,则该函数将其替换为新值,否则它将在单链接列表的开头创建一个新条目(根据键和参数中的值。这也就是在HashMap中不能存储相同键值的原因,因为这回导致原有的数据被覆盖。

该存储桶的索引(链结列表)一般分3步生成:

  • 它首先获取密钥的哈希码。
  • 接着会重新哈希,以防止将所有数据放入内部数组的相同索引(存储桶)的键中,造成不均匀的散列值。
  • 它采用经过重整的哈希哈希码,并使用数组的长度(负1)对其进行掩码。此操作可确保索引不能大于数组的大小。您可以将其视为在计算上经过优化的模函数。

下面是Java 7 和Java 8 处理索引的源码:

 // Java7 哈希值的计算方法
static int hash(int h) {
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
  }
// Java8 哈希值的计算方法
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
//从重新哈希返回索引的函数
static int indexFor(int h, int length) {
return h & (length-1);
}

为了有效地工作,内部数组的大小必须为2的幂,让我们看看为什么。

假设数组大小为17,则掩码值将为16(大小为size-1)。 16的二进制表示为0…010000,因此,对于任何哈希值H,按位公式“ H AND 16”生成的索引将为16或0,这意味着大小为17的数组将仅用于2个存储桶:索引为0的一个存储桶和索引16的一个存储桶,效率不高…

但是,如果您现在使用的是2的幂(例如16),则按位索引公式为``H AND 15‘‘。15的二进制表示形式是0…001111,因此索引公式可以输出0到15的值,并且大小为16的数组已完全使用。看下面这个例子:

  • 如果H = 952,其二进制表示形式为0..01110111000,关联的索引为0…01000 = 8
  • 如果H = 1576,其二进制表示为0..011000101000,则关联的索引为0…01000 = 8
  • 如果H = 12356146,则其二进制表示形式为 0..0101111001000101000110010关联索引为0…00010 = 2
  • 如果H = 59843,则其二进制表示形式为0..01110100111000011,关联索引为0…00011 = 3

这就是为什么数组大小是2的幂的原因。这种机制对开发人员是透明的:如果他选择大小为37的HashMap,则Map将为其内部数组的大小自动选择37(64)之后的下一个2的幂。

自动扩容

获取索引后,函数(get, put or remove)访问/迭代关联的链表,以查看给定键是否存在现有的Entry。 如果不进行修改,则此机制可能会导致性能问题,因为该功能需要遍历整个列表以查看条目是否存在。想象一下,内部数组的大小是默认值(16),您需要存储2百万个值。在最佳情况下,每个链接列表的大小将为125000个条目(2/16百万),因此,每个get(),remove()和put()都会导致125000次迭代/操作。为了避免这种情况,HashMap可以自动增加其内部数组以保留非常短的链表。创建HashMap时,可以使用以下构造函数指定初始大小和loadFactor:

 public HashMap(int initialCapacity, float loadFactor)

如果未指定参数,则默认数组的初始大小为16,默认loadFactor(加载因子)为0.75。 initialCapacity表示链表内部数组的大小。

每次使用put(…)在映射中添加新的键/值时,该函数都会检查是否需要增加内部数组的容量。为此,map存储了2个数据:

  • map的size大小:它表示HashMap中的条目数。每次添加或删除条目时都会更新此值。
  • 一个阈值:等于(内部数组的容量)* loadFactor,并在每次调整内部数组的大小后刷新

在添加新条目之前,put(...)方法之前,如果size> threshold,它将重新创建一个大小加倍的新数组。由于新数组的大小已更改,因此索引函数(返回按位运算“ hash(key)AND(sizeOfArray-1)”)更改。因此,调整数组大小会再创建两个存储桶(即链表),并将所有现有条目重新分配到存储桶中(旧的和新创建的)。

调整大小操作的目的是减小链接列表的大小,以使put(),remove()和get()方法的时间成本保持较低。调整大小后,所有具有相同哈希值的键的条目将保留在同一存储桶中。但是,转换后,位于同一存储桶中的两个具有不同哈希键的条目可能不在同一存储桶中。

该图显示了调整内部数组大小之前和之后的表示。 在增加之前,为了获得条目E,地图必须迭代5个元素的列表。 调整大小后,相同的get()会遍历2个元素的链接列表,调整大小后,get()的速度提高了2倍。

注意:HashMap仅增加内部数组的大小,而没有提供减小内部数组的方法。

线程安全

如果您已经了解HashMaps,那么您知道这不是线程安全的,但是为什么呢?例如,假设您有一个Writer线程仅将新数据放入Map中,而一个Reader线程则从Map中读取数据,为什么它不起作用?

因为在自动调整大小机制期间,如果线程尝试放置或获取对象,则地图可能会使用旧的索引值,而不会找到条目所在的新存储桶。

最坏的情况是两个线程同时放置一个数据,而两个put()调用则同时调整Map的大小。 由于两个线程同时修改链表,因此Map可能在其链表之一中以一个内循环结束。 如果您尝试通过内部循环获取列表中的数据,则get()将永远不会结束。

HashTable实现是线程安全的实现,可以防止这种情况发生。但是,由于所有CRUD方法都是同步的,因此此实现非常慢。例如,如果线程1调用get(key1),线程2调用get(key2),线程3调用get(key3),且这三个线程可以同时访问数据,就只能有一个线程获得该值,其他两个线程只能等待。

自Java 5以来,存在一个更安全的线程安全HashMap实现:ConcurrentHashMap。只有存储桶是同步的,因此多个线程可以同时get(),remove()或put()数据(如果这并不意味着访问同一存储桶或调整内部数组的大小)。这是因为ConcurrentHashMap只给一个方法的一个片段加锁,所以在并发的效率下也能维持一个高的性能。下面是ConcurrentHashMap的部分源码。

   else {
            V oldVal = null;
            // 只对这个片段加锁
            synchronized (f) {
                if (tabAt(tab, i) == f) {
                    if (fh >= 0) {
                        binCount = 1;
                        for (Node<K,V> e = f;; ++binCount) {
                            K ek;
                            if (e.hash == hash &&
                                ((ek = e.key) == key ||
                                 (ek != null && key.equals(ek)))) {
                                oldVal = e.val;
                                if (!onlyIfAbsent)
                                    e.val = value;
                                break;
                            }
                            Node<K,V> pred = e;
                            if ((e = e.next) == null) {
                                pred.next = new Node<K,V>(hash, key,
                                                          value, null);
                                break;
                            }
                        }
                    }

Key的不变性

为什么Strings和Integers是HashMap的键的良好实现?主要是因为它们是一成不变的!如果您选择创建自己的Key类并且不使其保持不变,则可能会丢失HashMap中的数据。

看下面的示例:

  • 您有一个内部值为1的键,
  • 并使用此键将一个对象放入HashMap中.
  • HashMap根据该键的哈希码生成一个哈希值(因此从``1‘‘开始)
  • .Map将此哈希值存储在新创建的Entry中
  • 您如果将键的内部值修改为“ 2”。
  • 键的哈希值被修改,但HashMap不知道(因为存储了旧的哈希值)。您尝试使用修改后的键获取对象 密钥的新哈希值(因此从“ 2”开始)以查找条目在哪个链表(存储桶)中

这就会出现你存储的值丢失,具体原因如下:

  1. 情况1:由于您修改了密钥,因此Map会尝试在错误的存储桶中找到该条目,但找不到它
  2. 情况2:幸运的是,修改后的密钥生成与旧密钥相同的存储桶。 然后,映射将迭代链接列表,以查找具有相同键的条目。 但是要找到键,映射首先比较哈希值,然后调用equals()比较。 由于修改过键的哈希值与旧哈希值(存储在条目中)的哈希值不同,因此map不会在链接列表中找到该条目。

这是Java中的具体示例。我将2个键值对放入地图中,我修改了第一个键,然后尝试获取2个值。从地图仅返回第二个值,第一个值在HashMap中“丢失”:

 public class MapTest {
public  void test(){
    Map<String,String>  map  = new HashMap<>();
    map.put("1","hello");
    map.put("2","chen");
    for (Map.Entry<String,String> entry: map.entrySet()) {
        System.out.println(entry.getKey());
    }
}

public static void main(String[] args) {

    // 检验map的key值被改了以后的情况
    class Mykeys{
        private int i;

        public Mykeys(int i){
            this.i = i;
        }
        public int getI() {
            return i;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (!(o instanceof Mykeys)) return false;
            Mykeys mykeys = (Mykeys) o;
            return i == mykeys.i;
        }

        @Override
        public int hashCode() {
            return Objects.hash(i);
        }

        public void setI(int i) {
            this.i = i;
        }
    }
    new MapTest().test();

    Map<Mykeys,String> map = new HashMap<>();
    Mykeys key1 = new Mykeys(1);
    Mykeys key2 = new Mykeys(2);
    map.put(key1,"test"+1);
    map.put(key2,"test2"+2);

    // 改变key的值
    key1.setI(3);
    String val1 = map.get(key1);
    String val2 = map.get(key2);
    System.out.println("test1="+val1+" "+"test2="+val2);
}
}

输出:

1
2
test1=null test2=test2

如预期的那样,map无法使用已修改的键1检索字符串1,所以test1的值为null,造成了数据的丢失。

JAVA 8改进

HashMap的内部表示在JAVA 8中发生了很大变化。确实,JAVA 7中的实现需要1k行代码,而JAVA 8中的实现则需要2k行。在JAVA8中,您仍然有一个数组,但是它现在存储的Node包含与Entries完全相同的信息,因此也是链接列表:

这是JAVA 8中Node实现的一部分:

  static class Node<K,V> implements Map.Entry<K,V> {
  final int hash;
  final K key;
  V value;
  Node<K,V> next;

那么,JAVA 7的最大区别是什么?就是节点可以扩展到TreeNodes。 TreeNode是一个红黑树结构,可以存储更多的信息,因此它可以在O(log(n))中添加,删除或获取元素。

这是TreeNode内部存储的数据的详尽列表

   static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
   final int hash; // inherited from Node<K,V>
   final K key; // inherited from Node<K,V>
   V value; // inherited from Node<K,V>
   Node<K,V> next; // inherited from Node<K,V>
   Entry<K,V> before, after;// inherited from LinkedHashMap.Entry<K,V>
   TreeNode<K,V> parent;
   TreeNode<K,V> left;
   TreeNode<K,V> right;
   TreeNode<K,V> prev;
   boolean red;

红黑树是自平衡二进制搜索树。 它们的内部机制确保了尽管添加或删除了新的节点,它们的长度始终在log(n)中。 使用这些树的主要优点是在许多数据都位于内部表的同一索引(存储桶)中的情况下,在树中进行搜索将花费O(log(n)),而链接列表的成本为O(n)。如您所见,树比链接列表占用了更多的空间(我们将在下一部分中讨论)。通过继承,内部表可以同时包含Node(链表)和TreeNode(红黑树)。

Oracle决定使用以下规则使用这两个数据结构:

  • 如果内部表中给定索引(存储桶)的节点超过8个,则链表转换为一棵红黑树
  • 如果给定索引(存储桶) )内部表中的节点少于6个,树被转换为链表

该图显示了一个JAVA 8 HashMap的内部数组,其中包含树(在存储桶0处)和链表(在存储桶1,2和3处)。值区0是一棵树,因为它有8个以上的节点。

内存开销

Java7

HashMap的使用在内存方面要付出一定的代价。在JAVA 7中,HashMap将键值对包装在Entries中。条目具有:

  • 对下一个entry的引用
  • 预先计算的哈希(整数)
  • Key的参考
  • 对值的引用

此外,JAVA 7 HashMap使用Entry的内部数组。假设JAVA 7 HashMap包含N个元素并且其内部数组具有容量CAPACITY,则额外的内存成本约为:

 sizeOf(integer)* N + sizeOf(reference)* (3*N+C)

解释如下:

  • 整数的大小取决于4个字节
  • 引用的大小取决于JVM / OS / Processor,但通常为4个字节。

这意味着开销通常为16 * N + 4 * CAPACITY个字节

提醒:在自动调整Map大小之后,内部数组的CAPACITY等于N之后的下一个2的幂。注意:自JAVA 7起,HashMap类具有一个惰性的init。 这意味着即使您分配了HashMap,条目的内部数组(花费4 * CAPACITY字节)也不会在第一次使用put()方法之前在内存中分配。

Java8

使用JAVA 8实施方案时,获取内存使用情况变得有些复杂,因为Node可以包含与Entry相同的数据或相同的数据以及6个引用和一个布尔值(如果是TreeNode)。

如果所有节点均为Nodes,则JAVA 8 HashMap的内存消耗与JAVA 7 HashMap相同。

如果所有节点都是TreeNodes,则JAVA 8 HashMap的内存消耗为:

   N * sizeOf(integer) + N * sizeOf(boolean) + sizeOf(reference)* (9*N+CAPACITY )

在大多数标准JVM中,它等于44 * N + 4 *容量字节

性能问题

倾斜的HashMap与平衡良好的HashMap

在最佳情况下,get()和put()方法的时间复杂度为O(1)。 但是,如果您不注意密钥的哈希函数,则可能会以非常慢的put()和get()调用结束。 put()和get的良好性能取决于将数据重新分配到内部数组(存储桶)的不同索引中。 如果密钥的哈希函数设计不当,您将有一个倾斜的分区(无论内部数组的容量有多大)。 所有使用最大链接条目列表的put()和get()都会变慢,因为它们需要迭代整个列表。 在最坏的情况下(如果大多数数据都在同一个存储桶中),最终可能会导致O(n)时间复杂度。

这是一个视觉示例。第一张图片显示了倾斜的HashMap,第二张图片显示了均衡的图片。

在这种偏斜的HashMap的情况下,存储区0上的get()/ put()操作成本很高。获得条目K将花费6次迭代

在这种平衡良好的HashMap的情况下,获取条目K将花费3次迭代。 两个HashMap都存储相同数量的数据,并且具有相同的内部数组大小。 唯一的不同是(项的)哈希(hash)功能,该功能在存储桶中分配条目。

这是JAVA中的一个极端示例,其中我创建了一个哈希函数,该函数将所有数据放入同一存储桶中,然后添加200万个元素。

   public class Test {

public static void main(String[] args) {

    class MyKey {
        Integer i;
        public MyKey(Integer i){
            this.i =i;
        }

        @Override
        public int hashCode() {
            return 1;
        }

        @Override
        public boolean equals(Object obj) {
        …
        }

    }
    Date begin = new Date();
    Map <MyKey,String> myMap= new HashMap<>(2_500_000,1);
    for (int i=0;i<2_000_000;i++){
        myMap.put( new MyKey(i), "test "+i);
    }

    Date end = new Date();
    System.out.println("Duration (ms) "+ (end.getTime()-begin.getTime()));
}
}

在我的电脑上,i7第八代,使用Java 8u40花费的时间超过45分钟(我在45分钟后停止了该过程)。

如果我使用以下哈希函数运行相同的代码,则可以提供更好的哈希重新分区

  @Override
  public int hashCode() {
  return i;
  }

现在需要2秒

以上示例可以看到哈希函数的重要性。 如果在JAVA 7上进行了相同的测试,则在第一种情况和第二种情况下结果会更糟(因为put的时间复杂度在JAVA 7中为O(n)而在Java 8中为O(log(n))。 使用HashMap,您需要为您的密钥找到一个哈希函数,以将密钥散布到尽可能多的存储桶中。 为此,您需要避免哈希冲突。 字符串对象是一个很好的键,因为它具有良好的哈希功能。 整数也很好,因为它们的哈希码是它们自己的值。

怎么减小内存消耗

如果需要存储大量数据,则应创建初始容量接近预期容量的HashMap。

如果您不这样做,map将采用默认大小16,factorLoad为0.75。第11个put()会非常快,但是第12个(16 * 0.75)将重新创建一个新的内部数组(及其关联的链表/树),其新容量为32。

从13号到23号很快,但是24号(32 * 0.75)将重新创建(再次)昂贵的新表示,将内部数组的大小加倍。内部调整大小操作将出现在put()的第48、96、192等处。在低容量下,内部阵列的完全恢复速度很快,但在高容量下,可能需要几秒钟到几分钟。通过最初设置您的预期大小,您可以避免这些昂贵的操作。

对于简单的用例,您不需要了解HashMap的工作原理,因为您不会看到O(1)和O(n)或O(log(n))操作之间的区别。 但是,最好了解最常用的数据结构之一的底层机制。 此外,对于Java开发人员来说,这是一个典型的面试问题。 在高容量下,了解其工作原理并了解密钥的哈希函数的重要性变得很重要。 我希望本文能帮助您对HashMap实现有一个深刻的了解。

参考至: http://coding-geek.com/how-does-a-hashmap-work-in-java/

追本溯源,方能阔步前行。

原文地址:https://www.cnblogs.com/chentang/p/12670462.html

时间: 2024-10-27 22:40:00

搞懂HashMap,这一篇就够了的相关文章

搞懂HashMap

HashMap是Java中对散列表(也叫哈希表)的实现,是Java程序员使用频率最高的用于映射(键值对)处理的数据类型,同时也是面试官的最爱.搞懂HashMap,很重要. 看了那么多篇文章,不如走读一次代码. 变量参数 先来看看HashMap相对重要的变量, /** * The default initial capacity - MUST be a power of two. * 默认的初始化容量16,这个值一定是2的幂 */ static final int DEFAULT_INITIAL_

Java程序员,如果你想要搞明白CDN,这篇应该够了!

程序员,如果想要搞明白CDN,这篇应该够了!最近在了解边缘计算,发现我们经常听说的CDN也是边缘计算里的一部分.那么说到CDN,好像只知道它中文叫做内容分发网络.那么具体CDN的原理是什么?能够为用户在浏览网站时带来什么好处呢?解决这两个问题是本文的目的. CDN概念CDN全称叫做"Content Delivery Network",中文叫内容分发网络. 实际上CDN这个概念是在1996年由美国麻省理工学院的一个研究小组为改善互联网的服务质量而提出的.那么它到底是怎么改善互联网服务质量

[Java集合] 彻底搞懂HashMap,HashTable,ConcurrentHashMap之关联.

注: 今天看到的一篇讲hashMap,hashTable,concurrentHashMap很透彻的一篇文章, 感谢原作者的分享. 原文地址: http://blog.csdn.net/zhangerqing/article/details/8193118 Java集合类是个非常重要的知识点,HashMap.HashTable.ConcurrentHashMap等算是集合类中的重点,可谓"重中之重",首先来看个问题,如面试官问你:HashMap和HashTable有什么区别,一个比较简

【白话篇】10分钟搞懂字符编码

如上图所示为常见的,让人看了头晕的 几个种编码. 看懂下面几条规则,你就明白他们的关系了. [1]有些人说,GBK严格来说是字符集,而utf-8则是编码,这种区分已经相当模糊了,他们都是"字节到字符的映射关系",所以下面都用编码来说吧. [2] ISO-8859-1 这种编码是单字节编码,衍生于ASCII,表示范围0-255,只要按照ASCII的规则设计的编码,不管是几字节的,都可以和ISO-8859-1兼容. [3]比如说,GBK编码(双字节)能转化成ISO-8859-1编码,是因为

搞懂分布式技术7:负载均衡概念与主流方案

搞懂分布式技术7:负载均衡概念与主流方案 负载均衡的原理 原创: 刘欣 码农翻身 4月23日 这是1998年一个普通的上午. 一上班,老板就把张大胖叫进了办公室,一边舒服地喝茶一边发难:"大胖啊,我们公司开发的这个网站,现在怎么越来越慢了? " 还好张大胖也注意到了这个问题,他早有准备,一脸无奈地说: "唉,我昨天检查了一下系统,现在的访问量已经越来越大了,无论是CPU,还是硬盘.内存都不堪重负了,高峰期的响应速度越来越慢." 顿了一下,他试探地问道:"老

彻底搞懂最短路算法

转载自:戳 彻底弄懂最短路径问题 只想说:温故而知新,可以为师矣.我大二的<数据结构>是由申老师讲的,那时候不怎么明白,估计太理论化了(ps:或许是因为我睡觉了):今天把老王的2011年课件又看了一遍,给大二的孩子们又讲了一遍,随手谷歌了N多资料,算是彻底搞懂了最短路径问题.请读者尽情享用…… 我坚信:没有不好的学生,只有垃圾的教育.不过没有人理所当然的对你好,所以要学会感恩. 一.问题引入 问题:从某顶点出发,沿图的边到达另一顶点所经过的路径中,各边上权值之和最小的一条路径——最短路径.解决

搞懂分布式技术9:Nginx负载均衡原理与实践

搞懂分布式技术9:Nginx负载均衡原理与实践 本篇摘自<亿级流量网站架构核心技术>第二章 Nginx负载均衡与反向代理 部分内容. 当我们的应用单实例不能支撑用户请求时,此时就需要扩容,从一台服务器扩容到两台.几十台.几百台.然而,用户访问时是通过如的方式访问,在请求时,浏览器首先会查询DNS服务器获取对应的IP,然后通过此IP访问对应的服务. 因此,一种方式是域名映射多个IP,但是,存在一个最简单的问题,假设某台服务器重启或者出现故障,DNS会有一定的缓存时间,故障后切换时间长,而且没有对

搞懂分布式技术13:缓存的那些事

搞懂分布式技术13:缓存的那些事 缓存和它的那些淘汰算法们 为什么我们需要缓存? 很久很久以前,在还没有缓存的时候--用户经常是去请求一个对象,而这个对象是从数据库去取,然后,这个对象变得越来越大,这个用户每次的请求时间也越来越长了,这也把数据库弄得很痛苦,他无时不刻不在工作.所以,这个事情就把用户和数据库弄得很生气,接着就有可能发生下面两件事情: 1.用户很烦,在抱怨,甚至不去用这个应用了(这是大多数情况下都会发生的) 2.数据库为打包回家,离开这个应用,然后,就出现了大麻烦(没地方去存储数据

搞懂分布式技术4:ZAB协议概述与选主流程详解

搞懂分布式技术4:ZAB协议概述与选主流程详解 ZAB协议 ZAB(Zookeeper Atomic Broadcast)协议是专门为zookeeper实现分布式协调功能而设计.zookeeper主要是根据ZAB协议是实现分布式系统数据一致性. zookeeper根据ZAB协议建立了主备模型完成zookeeper集群中数据的同步.这里所说的主备系统架构模型是指,在zookeeper集群中,只有一台leader负责处理外部客户端的事物请求(或写操作),然后leader服务器将客户端的写操作数据同步