0%

Hadoop

架构

HDFS

69cef71cd3a6500cce831d9103cea037.jpg

Blocks

物理磁盘中有块的概念,磁盘的物理Block是磁盘操作最小的单元,读写操作均以Block为最小单元,一般为512 Byte。文件系统在物理Block之上抽象了另一层概念,文件系统Block物理磁盘Block的整数倍。通常为几KB。Hadoop提供的df、fsck这类运维工具都是在文件系统的Block级别上进行操作。

HDFS的Block块比一般单机文件系统大得多,默认为128M。HDFS的文件被拆分成block-sized的chunk,chunk作为独立单元存储。比Block小的文件不会占用整个Block,只会占据实际大小。例如, 如果一个文件大小为1M,则在HDFS中只会占用1M的空间,而不是128M。

HDFS的Block为什么这么大?

是为了最小化查找(seek)时间,控制定位文件与传输文件所用的时间比例。假设定位到Block所需的时间为10ms,磁盘传输速度为100M/s。如果要将定位到Block所用时间占传输时间的比例控制1%,则Block大小需要约100M。

但是如果Block设置过大,在MapReduce任务中,Map或者Reduce任务的个数 如果小于集群机器数量,会使得作业运行效率很低。

Block抽象的好处

block的拆分使得单个文件大小可以大于整个磁盘的容量,构成文件的Block可以分布在整个集群, 理论上,单个文件可以占据集群中所有机器的磁盘。

Block的抽象也简化了存储系统,对于Block,无需关注其权限,所有者等内容(这些内容都在文件级别上进行控制)。

Block作为容错和高可用机制中的副本单元,即以Block为单位进行复制。

NameNode

NameNode也叫名称节点,它管理文件系统的命名空间,维护着文件系统树及整棵树内所有的文件和目录。这些信息以两个文件形式永久保存在本地磁盘上:

  • 命名空间镜像文件(namespcae image)
  • 编辑日志文件(edit log)

对整个分布式文件系统进行总控制,会纪录所有的元数据分布存储的状态信息。比如文件是如何分割成数据块的,以及这些数据块被存储到哪些节点上,还有对内存和I/O进行集中管理。但是持久化数据中不包括Block所在的节点列表,及文件的Block分布在集群中的哪些节点上,这些信息是在系统重启的时候重新构建(通过Datanode汇报的Block信息)。用户首先会访问Namenode,获取文件分布的状态信息,然后访问相应的节点,把文件拿到。

整个HDFS可存储的文件数受限于NameNode的内存大小。

不过这是个单点,如果NameNode宕机,那么整个集群就瘫痪了。

Secondary Namenode

Secondary NameNode,该部分主要是定时对NameNode数据进行备份,这样尽量降低NameNode崩溃之后,导致数据的丢失,其实所作的工作就是从NameNode获得fsimage(命名空间镜像文件)和edits(编辑日志文件)把二者重新合并然后发给NameNode,这样,既能减轻NameNode的负担又能保险地备份。

DataNode

数据节点,提供真实文件数据的存储服务。每台从服务器节点都运行一个,负责把HDFS数据块读、写到本地文件系统。

文件写入

67c3fe04d3b08c86544a99de14ed1c2e.png

  1. Client向NameNode发起文件写入的请求
  2. NameNode根据文件大小和文件块配置情况,返回给Client它所管理部分DataNode的信息。
  3. Client将文件划分为多个文件块,根据DataNode的地址信息,按顺序写入到每一个DataNode块中。

文件读取

deadf8e74fffebfdb743bfff497b6d2b.png

  1. Client向NameNode发起文件读取的请求
  2. NameNode返回文件存储的DataNode的信息。
  3. Client读取文件信息。

MapReduce

MapReduce是一种编程模型,用于大规模数据集的并行运算。Map(映射)和Reduce(化简),采用分而治之思想,先把任务分发到集群多个节点上,并行计算,然后再把计算结果合并,从而得到最终计算结果。多节点计算,所涉及的任务调度、负载均衡、容错处理等,都由MapReduce框架完成,不需要编程人员关心这些内容。

1.0

MapReduce采用Master/Slave结构。

  • Master:是整个集群的唯一的全局管理者,功能包括:作业管理、状态监控和任务调度等,即MapReduce中的JobTracker。
  • Slave:负责任务的执行和任务状态的回报,即MapReduce中的TaskTracker。

JobTracker

作业跟踪器,运行在主节点(Namenode)上的一个很重要的进程,是MapReduce体系的调度器。用于处理作业(用户提交的代码)的后台程序,决定有哪些文件参与作业的处理,然后把作业切割成为一个个的小task,并把它们分配到所需要的数据所在的子节点。(程序跟着数据跑,这样子就可以避免对数据进行重新分配)

TaskTracker

任务跟踪器,运行在每个slave节点上,与datanode结合(代码与数据一起的原则),管理各自节点上的task(由jobtracker分配),每个节点只有一个tasktracker,但一个tasktracker可以启动多个JVM,用于并行执行map或reduce任务,它与jobtracker交互通信,可以告知jobtracker子任务完成情况。

两者联系:JobTracker会给TaskTracker下达各种命令,主要包括:启动任务(LaunchTaskAction)、提交任务(CommitTaskAction)、杀死任务(KillTaskAction)、杀死作业(KillJobAction)和重新初始化(TaskTrackerReinitAction)。

2.0

Yarn

HA

MapReduce流程

bfa448bc34abbb54be7e829a46c8147d.png

Map阶段

  1. 读取输入文件的内容,并解析成键值对(<key, value>)的形式,输入文件中的每一行被解析成一个<key, value>对,每个<key, value>对调用一次map()函数。
  2. 用户写map()函数,对输入的<key,value>对进行处理,并输出新的<key,value>对。
    3。 对Step 2中得到的<key,value>进行分区操作。
  3. 不同分区的数据,按照key值进行排序和分组,具有相同key值的value则放到同一个集合中。
  4. 分组后的数据进行规约。

Shuffle

39fbc5dfa0947b5ef45d89e99a90e2ec.png

3b3ea4985b0374411973e1861f8c3f7d.jpg

f535cdbe61002ca7249595176d10342f.png

Map Shuffle

  1. 环形内存缓存区:每个split数据交由一个map任务处理,map的处理结果不会直接写到硬盘上,会先输送到环形内存缓存区中,默认的大小是100M(可通过配置修改),当缓冲区的内容达到80%后会开始溢出,此时缓存区的溢出内容会被写到磁盘上,形成一个个spill file,注意这个文件没有固定大小。
  2. 在内存中经过分区、排序后溢出到磁盘:分区主要功能是用来指定 map 的输出结果交给哪个 reduce 任务处理,默认是通过 map 输出结果的 key 值取hashcode 对代码中配置的 redue task数量取模运算,值一样的分到一个区,也就是一个 reduce 任务对应一个分区的数据。这样做的好处就是可以避免有的 reduce 任务分配到大量的数据,而有的 reduce 任务只分配到少量甚至没有数据,平均 reduce 的处理能力。并且在每一个分区(partition)中,都会有一个 sort by key 排序,如果此时设置了 Combiner,将排序后的结果进行 Combine 操作,相当于 map 阶段的本地 reduce,这样做的目的是让尽可能少的数据写入到磁盘。
  3. 合并溢出文件:随着 map 任务的执行,不断溢出文件,直到输出最后一个记录,可能会产生大量的溢出文件,这时需要对这些大量的溢出文件进行合并,在合并文件的过程中会不断的进行排序跟 Combine 操作,这样做有两个好处:减少每次写入磁盘的数据量&减少下一步 reduce 阶段网络传输的数据量。最后合并成了一个分区且排序的大文件,此时可以再进行配置压缩处理,可以减少不同节点间的网络传输量。合并完成后着手将数据拷贝给相对应的reduce 处理,那么要怎么找到分区数据对应的那个 reduce 任务呢?简单来说就是 JobTracker 中保存了整个集群中的宏观信息,只要 reduce 任务向 JobTracker 获取对应的 map 输出位置就可以了。具体请参考上方的MapReduce工作原理。

Reduce Shuffle

reduce 会接收到不同 map 任务传来的有序数据,如果 reduce 接收到的数据较小,则会存在内存缓冲区中,直到数据量达到该缓存区的一定比例时对数据进行合并后溢写到磁盘上。随着溢写的文件越来越多,后台的线程会将他们合并成一个更大的有序的文件,可以为后面合并节省时间。这其实跟 map端的操作一样,都是反复的进行排序、合并,这也是 Hadoop 的灵魂所在,但是如果在 map 已经压缩过,在合并排序之前要先进行解压缩。合并的过程会产生很多中间文件,但是最后一个合并的结果是不需要写到磁盘上,而是可以直接输入到 reduce 函数中计算,每个 reduce 对应一个输出结果文件。

Reduce阶段

  1. 对于多个map任务的输出,按照不同的分区,通过网络传输到不同的Reduce节点。
  2. 对多个map任务的输出结果进行合并、排序,用户书写reduce函数,对输入的key、value进行处理,得到新的key、value输出结果。
  3. 将reduce的输出结果保存在文件中。

例子

22c99cc806e317ad2fd48ea3effdf8a6.png

MySQL

参考资料:

万字总结:学习MySQL优化原理,这一篇就够了!

架构

逻辑架构

f0039219555298fce3dc6102cfb4a490.jpg

MySQL逻辑架构整体分为三层,最上层为客户端层,并非MySQL所独有,诸如:连接处理、授权认证、安全等功能均在这一层处理。

MySQL大多数核心服务均在中间这一层,包括查询解析、分析、优化、缓存、内置函数(比如:时间、数学、加密等函数)。所有的跨存储引擎的功能也在这一层实现:存储过程、触发器、视图等。

最下层为存储引擎,其负责MySQL中的数据存储和提取。和Linux下的文件系统类似,每种存储引擎都有其优势和劣势。中间的服务层通过API与存储引擎通信,这些API接口屏蔽了不同存储引擎间的差异。

查询过程:

c626d39062d06c79b87212fd841d872f.jpg

客户端/服务端通信协议

MySQL客户端/服务端通信协议是“半双工”的:在任一时刻,要么是服务器向客户端发送数据,要么是客户端向服务器发送数据,这两个动作不能同时发生。一旦一端开始发送消息,另一端要接收完整个消息才能响应它,所以我们无法也无须将一个消息切成小块独立发送,也没有办法进行流量控制。

客户端用一个单独的数据包将查询请求发送给服务器,所以当查询语句很长的时候,需要设置max_allowed_packet参数。但是需要注意的是,如果查询实在是太大,服务端会拒绝接收更多数据并抛出异常。

与之相反的是,服务器响应给用户的数据通常会很多,由多个数据包组成。但是当服务器响应客户端请求时,客户端必须完整的接收整个返回结果,而不能简单的只取前面几条结果,然后让服务器停止发送。因而在实际开发中,尽量保持查询简单且只返回必需的数据,减小通信间数据包的大小和数量是一个非常好的习惯,这也是查询中尽量避免使用SELECT *以及加上LIMIT限制的原因之一。

查询优化器

MySQL的查询优化器是一个非常复杂的部件,它使用了非常多的优化策略来生成一个最优的执行计划:

  • 重新定义表的关联顺序(多张表关联查询时,并不一定按照SQL中指定的顺序进行,但有一些技巧可以指定关联顺序)
  • 优化MIN()和MAX()函数(找某列的最小值,如果该列有索引,只需要查找B+Tree索引最左端,反之则可以找到最大值)
  • 提前终止查询(比如:使用Limit时,查找到满足数量的结果集后会立即终止查询)
  • 优化排序(在老版本MySQL会使用两次传输排序,即先读取行指针和需要排序的字段在内存中对其排序,然后再根据排序结果去读取数据行,而新版本采用的是单次传输排序,也就是一次读取所有的数据行,然后根据给定的列排序。对于I/O密集型应用,效率会高很多)

索引

b+ tree

介绍

e75a56bac9177d68b8c178550efa47a8.jpg

浅蓝色的块我们称之为一个磁盘块,可以看到每个磁盘块包含几个数据项(深蓝色所示)和指针(黄色所示),如磁盘块1包含数据项17和35,包含指针P1、P2、P3,P1表示小于17的磁盘块,P2表示在17和35之间的磁盘块,P3表示大于35的磁盘块。真实的数据存在于叶子节点即3、5、9、10、13、15、28、29、36、60、75、79、90、99。非叶子节点不存储真实的数据,只存储指引搜索方向的数据项,如17、35并不真实存在于数据表中。

如图所示,如果要查找数据项29,那么首先会把磁盘块1由磁盘加载到内存,此时发生一次IO,在内存中用二分查找确定29在17和35之间,锁定磁盘块1的P2指针,内存时间因为非常短(相比磁盘的IO)可以忽略不计,通过磁盘块1的P2指针的磁盘地址把磁盘块3由磁盘加载到内存,发生第二次IO,29在26和30之间,锁定磁盘块3的P2指针,通过指针加载磁盘块8到内存,发生第三次IO,同时内存中做二分查找找到29,结束查询,总计三次IO。真实的情况是,3层的b+树可以表示上百万的数据,如果上百万的数据查找只需要三次IO,性能提高将是巨大的,如果没有索引,每个数据项都要发生一次IO,那么总共需要百万次的IO,显然成本非常非常高。

性质

间距越小,数据项的数量越多,树的高度越低。这就是为什么每个数据项,即索引字段要尽量的小,比如int占4字节,要比bigint8字节少一半。这也是为什么b+树要求把真实的数据放到叶子节点而不是内层节点,一旦放到内层节点,磁盘块的数据项会大幅度下降,导致树增高。当数据项等于1时将会退化成线性表。

当b+树的数据项是复合的数据结构,比如(name,age,sex)的时候,b+数是按照从左到右的顺序来建立搜索树的,比如当(张三,20,F)这样的数据来检索的时候,b+树会优先比较name来确定下一步的所搜方向,如果name相同再依次比较age和sex,最后得到检索的数据;但当(20,F)这样的没有name的数据来的时候,b+树就不知道下一步该查哪个节点,因为建立搜索树的时候name就是第一个比较因子,必须要先根据name来搜索才能知道下一步去哪里查询。比如当(张三,F)这样的数据来检索时,b+树可以用name来指定搜索方向,但下一个字段age的缺失,所以只能把名字等于张三的数据都找到,然后再匹配性别是F的数据了, 这个是非常重要的性质,即索引的最左匹配特性。

意义

一般来说,索引本身也很大,不可能全部存储在内存中,因此索引往往以索引文件的形式存储的磁盘上。这样的话,索引查找过程中就要产生磁盘I/O消耗,相对于内存存取,I/O存取的消耗要高几个数量级,所以评价一个数据结构作为索引的优劣最重要的指标就是在查找过程中磁盘I/O操作次数的渐进复杂度。换句话说,索引的结构组织要尽量减少查找过程中磁盘I/O的存取次数。

简单点说说内存读取,内存是由一系列的存储单元组成的,每个存储单元存储固定大小的数据,且有一个唯一地址。当需要读内存时,将地址信号放到地址总线上传给内存,内存解析信号并定位到存储单元,然后把该存储单元上的数据放到数据总线上,回传。

写内存时,系统将要写入的数据和单元地址分别放到数据总线和地址总线上,内存读取两个总线的内容,做相应的写操作。

内存存取效率,跟次数有关,先读取A数据还是后读取A数据不会影响存取效率。而磁盘存取就不一样了,磁盘I/O涉及机械操作。磁盘是由大小相同且同轴的圆形盘片组成,磁盘可以转动(各个磁盘须同时转动)。磁盘的一侧有磁头支架,磁头支架固定了一组磁头,每个磁头负责存取一个磁盘的内容。磁头不动,磁盘转动,但磁臂可以前后动,用于读取不同磁道上的数据。磁道就是以盘片为中心划分出来的一系列同心环(如图标红那圈)。磁道又划分为一个个小段,叫扇区,是磁盘的最小存储单元。

c772de104b57c11868511193c119aaf7.png

磁盘读取时,系统将数据逻辑地址传给磁盘,磁盘的控制电路会解析出物理地址,即哪个磁道哪个扇区。于是磁头需要前后移动到对应的磁道,消耗的时间叫寻道时间,然后磁盘旋转将对应的扇区转到磁头下,消耗的时间叫旋转时间。所以,适当的操作顺序和数据存放可以减少寻道时间和旋转时间。为了尽量减少I/O操作,磁盘读取每次都会预读,大小通常为页的整数倍。即使只需要读取一个字节,磁盘也会读取一页的数据(通常为4K)放入内存,内存与磁盘以页为单位交换数据。因为局部性原理认为,通常一个数据被用到,其附近的数据也会立马被用到。

B-Tree:如果一次检索需要访问4个节点,数据库系统设计者利用磁盘预读原理,把节点的大小设计为一个页,那读取一个节点只需要一次I/O操作,完成这次检索操作,最多需要3次I/O(根节点常驻内存)。数据记录越小,每个节点存放的数据就越多,树的高度也就越小,I/O操作就少了,检索效率也就上去了。

B+Tree:非叶子节点只存key,大大滴减少了非叶子节点的大小,那么每个节点就可以存放更多的记录,树更矮了,I/O操作更少了。所以B+Tree拥有更好的性能。

索引实现

MyISAM:MyISAM引擎使用B+Tree作为索引结构。MyISAM索引文件和数据文件是分离的,叶节点的data域存放的是数据记录的地址。MyISAM主键索引和辅助索引结构是一样的。


InnoDB:InnoDB的数据文件本身就是索引文件。在InnoDB中,表数据文件本身就是按B+Tree组织的一个索引结构,这棵树的叶节点data域保存了完整的数据记录。这个索引的key是数据表的主键,因此InnoDB表数据文件本身就是主索引。

InnoDB要求表必须有主键(MyISAM可以没有),如果没有显式指定,则MySQL系统会自动选择一个可以唯一标识数据记录的列作为主键,如果不存在这种列,则MySQL自动为InnoDB表生成一个隐含字段作为主键,这个字段长度为6个字节,类型为长整形。

InnoDB的辅助索引data域存储相应记录主键的值而不是地址。换句话说,InnoDB的所有辅助索引都引用主键作为data域。

聚集索引这种实现方式使得按主键的搜索十分高效,但是辅助索引搜索需要检索两遍索引:首先检索辅助索引获得主键,然后用主键到主索引中检索获得记录。

  • 为什么不建议使用过长的字段作为主键:
    因为所有辅助索引都引用主索引,过长的主索引会令辅助索引变得过大。
  • 用非单调的字段作为主键在InnoDB中不是个好主意:
    因为InnoDB数据文件本身是一颗B+Tree,非单调的主键会造成在插入新记录时数据文件为了维持B+Tree的特性而频繁的分裂调整,十分低效,而使用自增字段作为主键则是一个很好的选择。
  • 为何自增主键好:
    InnoDB使用聚集索引,数据记录本身被存于主索引(一颗B+Tree)的叶子节点上。这就要求同一个叶子节点内(大小为一个内存页或磁盘页)的各条数据记录按主键顺序存放,因此每当有一条新的记录插入时,MySQL会根据其主键将其插入适当的节点和位置,如果页面达到装载因子(InnoDB默认为15/16),则开辟一个新的页(节点)。如果表使用自增主键,那么每次插入新的记录,记录就会顺序添加到当前索引节点的后续位置,当一页写满,就会自动开辟一个新的页。
    f7a706274d3ee7b336e8e26f130e7a98.png
    这样就会形成一个紧凑的索引结构,近似顺序填满。由于每次插入时也不需要移动已有数据,因此效率很高,也不会增加很多开销在维护索引上。
    如果使用非自增主键(如果身份证号或学号等),由于每次插入主键的值近似于随机,因此每次新纪录都要被插到现有索引页得中间某个位置,如下:
    0187a1f178bb99ceba99abc9a69a078f.png
    此时MySQL不得不为了将新记录插到合适位置而移动数据,甚至目标页面可能已经被回写到磁盘上而从缓存中清掉,此时又要从磁盘上读回来,这增加了很多开销,同时频繁的移动、分页操作造成了大量的碎片,得到了不够紧凑的索引结构,后续不得不通过OPTIMIZE TABLE来重建表并优化填充页面。

最左前缀原则

https://www.jianshu.com/p/b7911e0394b0

https://www.cnblogs.com/wmbg/p/6800354.html

联合索引会按照b+tree排序,例如(a,b,c),索引的结果是order by a,b,c ,所以当查询(a),(a,b),(a,b,c)时都可以使用索引。而查询(b,c),(c)

  1. 最左前缀匹配原则,非常重要的原则,mysql会一直向右匹配直到遇到范围查询(>、<、between、like)就停止匹配,比如a = 1 and b = 2 and c > 3 and d = 4 如果建立(a,b,c,d)顺序的索引,d是用不到索引的,如果建立(a,b,d,c)的索引则都可以用到,a,b,d的顺序可以任意调整。

  2. =和in可以乱序,比如a = 1 and b = 2 and c = 3 建立(a,b,c)索引可以任意顺序,mysql的查询优化器会帮你优化成索引可以识别的形式

索引规则

  • 最左前缀匹配原则,非常重要的原则,mysql会一直向右匹配直到遇到范围查询(>、<、between、like)就停止匹配,比如a = 1 and b = 2 and c > 3 and d = 4 如果建立(a,b,c,d)顺序的索引,d是用不到索引的,如果建立(a,b,d,c)的索引则都可以用到,a,b,d的顺序可以任意调整。
  • =和in可以乱序,比如a = 1 and b = 2 and c = 3 建立(a,b,c)索引可以任意顺序,mysql的查询优化器会帮你优化成索引可以识别的形式
  • 尽量选择区分度高的列作为索引,区分度的公式是count(distinct col)/count(*),表示字段不重复的比例,比例越大我们扫描的记录数越少,唯一键的区分度是1,而一些状态、性别字段可能在大数据面前区分度就是0,那可能有人会问,这个比例有什么经验值吗?使用场景不同,这个值也很难确定,一般需要join的字段我们都要求是0.1以上,即平均1条扫描10条记录
  • 索引列不能参与计算,保持列“干净”,比如from_unixtime(create_time) = ’2014-05-29’就不能使用到索引,原因很简单,b+树中存的都是数据表中的字段值,但进行检索时,需要把所有元素都应用函数才能比较,显然成本太大。所以语句应该写成create_time = unix_timestamp(’2014-05-29’);
  • 尽量的扩展索引,不要新建索引。比如表中已经有a的索引,现在要加(a,b)的索引,那么只需要修改原来的索引即可,当然要考虑原有数据和线上使用情况

隔离级别

  1. 原子性
    事务必须是原子工作单元;对于其数据修改,要么全都执行,要么全都不执行。
  2. 一致性
    事务的一致性指的是在一个事务执行之前和执行之后数据库都必须处于一致性状态。事务执行的结果必须是使数据库从一个一致性状态变到另一个一致性状态。
  3. 隔离性(关于事务的隔离性数据库提供了多种隔离级别)
    一个事务的执行不能干扰其它事务。即一个事务内部的操作及使用的数据对其它并发事务是隔离的,并发执行的各个事务之间不能互相干扰。
  4. 持久性
    事务完成之后,它对于数据库中的数据改变是永久性的。该修改即使出现系统故障也将一
    直保持。
    在介绍数据库提供的各种隔离级别之前,我们先看看如果不考虑事务的隔离性,会发生的几种问题:
  • 脏读
    脏读是指在一个事务处理过程里读取了另一个未提交的事务中的数据。
  • 不可重复读
  • 幻读
    幻读和不可重复读都是读取了另一条已经提交的事务,不可重复读重点在于update和delete,而幻读的重点在于insert。

在可重复读中,该sql第一次读取到数据后,就将这些数据加锁,其它事务无法修改这些数据,就可以实现可重复 读了。但这种方法却无法锁住insert的数据,所以当事务A先前读取了数据,或者修改了全部数据,事务B还是可以insert数据提交,这时事务A就会 发现莫名其妙多了一条之前没有的数据,这就是幻读,不能通过行锁来避免。需要Serializable隔离级别 ,读用读锁,写用写锁,读锁和写锁互斥,这么做可以有效的避免幻读、不可重复读、脏读等问题,但会极大的降低数据库的并发能力。

现在来看看MySQL数据库为我们提供的四种隔离级别:

  1. Serializable (串行化):可避免脏读、不可重复读、幻读的发生。在可串行化级别上,MySQL执行S2PL并发控制协议, 一阶段申请,一阶段释放。读写都要加锁。
  2. Repeatable read (可重复读):可避免脏读、不可重复读的发生。读不加锁,只有写才加锁,读写互不阻塞,并发度相对于可串行化级别要高,但会有Write Skew异常。事务在开始时创建一个ReadView,当读一条记录时,会遍历版本链表,通过当前事务的ReadView判断可见性,找到第一个对当前事务可见的版本,读这个版本。对于写操作,包括Locking Read(SELECT … FOR UPDATE), UPDATE, DELETE,需要加写锁。根据谓词条件上索引使用情形,锁定有不同的方式:
    1. 有索引:对于索引上有唯一约束且为等值条件的情形,不用GAP LOCK,只锁定索引记录。对于其它情形,使用GAP LOCK,相当于谓词锁。
    2. 没有索引:由于MySQL没有实现通用的谓词锁,这时就相当于锁全表。
  3. Read committed (读已提交):可避免脏读的发生。MySQL的读已提交实际是语句级别快照。与可重复读级别主要有两点不同:
    1. 获得ReadView的时机。每个语句开始执行时,获得ReadView,可见性判断是基于语句级别的ReadView。读的策略与可重复读类似。
    2. 写锁的使用方式。这里不需要GAP LOCK,只使用记录锁。并且事务只持有被UPDATE/DELETE记录的写锁(可重复读需要保留全部写锁直到事务结束,而读已提交只保留真正更改的)。
  4. Read uncommitted (读未提交):最低级别,任何情况都无法保证。读最新的数据,不管这条记录是不是已提交。不会遍历版本链,少了查找可见的版本的步骤。这样可能会导致脏读。

在MySQL数据库中默认的隔离级别为Repeatable read (可重复读)。

innodb可重复读级别可以避免读数据时产生的幻读问题(mvcc缘故,读不到版本号大于当前版本号的行),但不能避免写数据时产生的幻读问题。

因为读是快照度,写是当前读。要解决幻读需要用间隙锁或者串行化。

共享锁,排它锁

间隙锁

调优

explain

返回结果

  • id : 表示SQL执行的顺序的标识,SQL从大到小的执行
  • select_type:表示查询中每个select子句的类型
  • table:显示这一行的数据是关于哪张表的,有时不是真实的表名字
  • type:表示MySQL在表中找到所需行的方式,又称“访问类型”。常用的类型有: ALL, index, range, ref, eq_ref, const, system, NULL(从左到右,性能从差到好)
  • possible_keys:指出MySQL能使用哪个索引在表中找到记录,查询涉及到的字段上若存在索引,则该索引将被列出,但不一定被查询使用
  • Key:key列显示MySQL实际决定使用的键(索引),如果没有选择索引,键是NULL。
  • key_len:表示索引中使用的字节数,可通过该列计算查询中使用的索引的长度(key_len显示的值为索引字段的最大可能长度,并非实际使用长度,即key_len是根据表定义计算而得,不是通过表内检索出的)
  • ref:表示上述表的连接匹配条件,即哪些列或常量被用于查找索引列上的值
  • rows: 表示MySQL根据表统计信息及索引选用情况,估算的找到所需的记录所需要读取的行数,理论上行数越少,查询性能越好
  • Extra:该列包含MySQL解决查询的详细信息

EXPLAIN的特性

  • EXPLAIN不会告诉你关于触发器、存储过程的信息或用户自定义函数对查询的影响情况
  • EXPLAIN不考虑各种Cache
  • EXPLAIN不能显示MySQL在执行查询时所作的优化工作
  • 部分统计信息是估算的,并非精确值
  • EXPALIN只能解释SELECT操作,其他操作要重写为SELECT后查看执行计划。

log

Bin Log:是mysql服务层产生的日志,常用来进行数据恢复、数据库复制,常见的mysql主从架构,就是采用slave同步master的binlog实现的

Redo Log:记录了数据操作在物理层面的修改,mysql中使用了大量缓存,修改操作时会直接修改内存,而不是立刻修改磁盘,事务进行中时会不断的产生redo log,在事务提交时进行一次flush操作,保存到磁盘中。当数据库或主机失效重启时,会根据redo log进行数据的恢复,如果redo log中有事务提交,则进行事务提交修改数据。

Undo Log: 除了记录redo log外,当进行数据修改时还会记录undo log,undo log用于数据的撤回操作,它记录了修改的反向操作,比如,插入对应删除,修改对应修改为原来的数据,通过undo log可以实现事务回滚,并且可以根据undo log回溯到某个特定的版本的数据,实现MVCC

MVCC

MySQL InnoDB实现了多版本并发控制(MVCC),在多版本存储上,MySQL采用从新到旧(Newest To Oldest)的版本链。B+Tree叶结点上,始终存储的是最新的数据(可能是还未提交的数据)。而旧版本数据,通过UNDO记录(做DELTA)存储在回滚段(Rollback Segment)里。每一条记录都会维护一个ROW HEADER元信息,存储有创建这条记录的事务ID,一个指向UNDO记录的指针。通过最新记录和UNDO信息,可以还原出旧版本的记录。

MVCC是通过在每行记录后面保存三个隐藏的列来实现的。一个保存了行的创建版本号,一个保存行的过期版本号,一个指向最近的undo log。每开始一个新的事务,系统版本号都会自动递增。事务开始时刻的系统版本号会作为事务的版本号,用来和查询到的每行记录的版本号进行比较。

下面看一下在REPEATABLE READ隔离级别下,MVCC具体是如何操作的。

  • SELECT
    InnoDB会根据以下两个条件检查每行记录:
    InnoDB只查找版本早于当前事务版本的数据行(也就是,行的系统版本号小于或等于事务的系统版本号),这样可以确保事务读取的行,要么是在事务开始前已经存在的,要么是事务自身插入或者修改过的。
    行的删除版本要么未定义,要么大于当前事务版本号。这可以确保事务读取到的行,在事务开始之前未被删除。
    只有符合上述两个条件的记录,才能返回作为查询结果
  • INSERT
    InnoDB为新插入的每一行保存当前系统版本号作为行版本号。
  • DELETE
    InnoDB为删除的每一行保存当前系统版本号作为行删除标识。
  • UPDATE
    InnoDB为插入一行新记录,保存当前系统版本号作为行版本号,同时保存当前系统版本号到原来的行作为行删除标识。
    保存这两个额外系统版本号,使大多数读操作都可以不用加锁。这样设计使得读数据操作很简单,性能很好,并且也能保证只会读取到符合标准的行,不足之处是每行记录都需要额外的存储空间,需要做更多的行检查工作,以及一些额外的维护工作

Innodb vs Myisam

  1. InnoDB 支持事务,MyISAM 不支持事务。这是 MySQL 将默认存储引擎从 MyISAM 变成 InnoDB 的重要原因之一;
  2. InnoDB 支持外键,而 MyISAM 不支持。对一个包含外键的 InnoDB 表转为 MYISAM 会失败;
  3. InnoDB 是聚集索引,MyISAM 是非聚集索引。聚簇索引的文件存放在主键索引的叶子节点上,因此 InnoDB 必须要有主键,通过主键索引效率很高。但是辅助索引需要两次查询,先查询到主键,然后再通过主键查询到数据。因此,主键不应该过大,因为主键太大,其他索引也都会很大。而 MyISAM 是非聚集索引,数据文件是分离的,索引保存的是数据文件的指针。主键索引和辅助索引是独立的。
  4. InnoDB 不保存表的具体行数,执行 select count(*) from table 时需要全表扫描。而MyISAM 用一个变量保存了整个表的行数,执行上述语句时只需要读出该变量即可,速度很快;
  5. InnoDB 最小的锁粒度是行锁,MyISAM 最小的锁粒度是表锁。一个更新语句会锁住整张表,导致其他查询和更新都会被阻塞,因此并发访问受限。这也是 MySQL 将默认存储引擎从 MyISAM 变成 InnoDB 的重要原因之一;

如何选择:

  1. 是否要支持事务,如果要请选择 InnoDB,如果不需要可以考虑 MyISAM;
  2. 如果表中绝大多数都只是读查询,可以考虑 MyISAM,如果既有读写也挺频繁,请使用InnoDB。
  3. 系统奔溃后,MyISAM恢复起来更困难,能否接受,不能接受就选 InnoDB;
  4. MySQL5.5版本开始Innodb已经成为Mysql的默认引擎(之前是MyISAM),说明其优势是有目共睹的。如果你不知道用什么存储引擎,那就用InnoDB,至少不会差。

微服务一致性

CAP

CAP 是指在一个分布式系统下,包含三个要素:Consistency(一致性)、Availability(可用性)、Partition tolerance(分区容错性),并且三者不可得兼。

  • 一致性(Consistency),是指对于每一次读操作,要么都能够读到最新写入的数据,要么错误,所有数据变动都是同步的。
  • 可用性(Availability),是指对于每一次请求,都能够得到一个及时的、非错的响应,但是不保证请求的结果是基于最新写入的数据。即在可以接受的时间范围内正确地响应用户请求。
  • 分区容错性(Partition tolerance),是指由于节点之间的网络问题,即使一些消息丢包或者延迟,整个系统仍能够提供满足一致性和可用性的服务。

事务模式 -两阶段提交(2PC)

事物模式的代表就是两阶段提交,它的含义就是每个服务有自己的子事务,服务之间其实也是通过分布式事务来保障事务的一致性。

所以它在第一阶段的时候,这个协调器会发一个propose的请求给三个服务,如果每个服务都可以接受接下来的事务请求,它就会回复yes或者是no。回复之后就会到第二个阶段,如果前面三个服务回复的都是一个yes,那协调器就会发送commit这个请求,去执行这三个服务的事务操作,否则就会发送一个abort请求。执行完成之后,三个服务会回来一个ack,表示这个事务已经结束,或者是已经取消。

优点:有事务的原子性,因为在每次做事务操作的时候,都会锁定资源,所以所有进入协调器的请求,其实都是一个线性的请求,它不能去同步所有的事务。

缺点:它是一个阻塞式的模式,它需要锁定资源,它的复杂度也会相对比较高,比较难扩展。

预约模式 -TCC(Try-Confirm/Cancel)

预约模式比较典型的叫Try-Confirm/Cancel,缩写就是TCC。它也是一个两阶段的模式。第一个阶段它会发一个try的请求给三个服务,如果三个服务可以执行,它们就会回复yes,不能回复no,这和两阶段提交是一致的。不一样的地方是它不会锁定资源。所以在A处理完它的子事务,B可能还没有处理完它的子事务的时候,A可以接受其他的请求。在第二阶段,像两阶段提交一样,协调器会根据第一阶段ABC的返回结果去发送Confirm或者Cancel,这样的请求,最后ABC会返回ack。

优点:

在try阶段如果失败,服务会回复至原状,什么意思呢?比如说要发一个会议邀请,如果用TCC的模式,第一阶段会先发一个询问的请求给所有的与会人员,询问是否可以在这一个时间点来参会,如果所有的参会人员都回答yes,第二步就会发送一个确认请求,实际的一个会议邀请,如果有任何一个人员不能参加,就会发送一个Cancel,取消这个会议。

缺点:

  • 它不是原子操作,它可以并行处理好多的分布式的事务,然后它在Confirm这个阶段,只能重试,如果有一个服务失败的话,只能重试,直到成功,或者是采取回退措施,需要人工去做干预。
  • 它需要一个额外的try流程,服务需要提供额外的try的结构,也就需要提供额外的reserved的状态。

可靠消息最终一致性

可靠事件模式属于事件驱动架构,微服务完成操作后向消息代理发布事件,关联的微服务从消息代理订阅到该 事件从而完成相应的业务操作,关键在于可靠事件投递和避免事件重复消费。

可靠事件投递有两个特性:

  • 每个服务原子性的完成业务操作和发布事件;
  • 消息代理确保事件投递至少一次 (at least once)。避免重复消费要求消费事件的服务实现幂等性。

有两种实现方式:

本地事件表

本地事件表方法将事件和业务数据保存在同一个数据库中,使用一个额外的“事件恢复”服务来恢 复事件,由本地事务保证更新业务和发布事件的原子性。考虑到事件恢复可能会有一定的延时,服务在完成本 地事务后可立即向消息代理发布一个事件。

a9bcbd355bae4ae06f623413a0ef3cee.png

使用本地事件表将事件和业务数据保存在同一个数据库中,会在每个服务存储一份数据,在一定程度上会造成代码的重复冗余。同时,这种模式下的业务系统和事件系统耦合比较紧密,额外增加的事件数据库操作也会给数据库带来额外的压力,可能成为瓶颈。

外部事件表

针对本地事件表出现的问题,提出外部事件表方法,将事件持久化到外部的事件系统,事件系统 需提供实时事件服务以接收微服务发布的事件,同时事件系统还需要提供事件恢复服务来确认和恢复事件。

%!(EXTRA markdown.ResourceType=, string=, string=)

Saga的补偿机制

Saga支持向前和向后恢复:

向后恢复:如果任意一个子事务失败,则补偿所有已完成的事务

向前恢复:如果子事务失败,则重试失败的事务

BBR

今天花了一个下午学习bbr,真的是太奇妙了这个东西。计算机网络的东西我都忘得差不多了,一边学习一边回顾,也不敢写什么研究心得,主要是看了知乎上李博杰关于bbr的回答,然后看了原文(以下简称,对于一些不太清楚的概念做一些笔记,以便时候回顾。

一些概念

  • 加性增,乘性减:简单讲就是说,tcp loss-based拥塞控制传输时,拥塞窗口会已加的形式增大,而一旦遇到包丢失的情况,就会以除二的方式减小窗口。详情可以查看tcp慢启动和指数退避相关内容。

ebea4cbf1e53cb23dc31199e706ddda6.png

这张图我看了半个小时(学渣!),图的整体意思是说:单位时间内,随着发送数据的增加,到达链路运输能力(实际带宽)前发送速率(数据成功到达receiver的)增加,数据延迟不变;一旦达到链路的运输能力,发送速率不会变化,而数据发送耗时会增加。BBR将数据发送量限制到带宽,从而达到发送速率最大同时耗时最短,而传统的基于丢包的拥塞控制则控制的是数据量为buffer的极限。一点上图中的概念解析:

  • inflight:拥塞窗口(?不确定),可以理解为发送的数据量
  • loss-based congestion control:基于包丢失的拥塞控制。传统TCP拥塞控制方法,由于链路中存在buffer,buffer容量会比链路大,当buffer缓冲区到达limit时,会产生丢包行为,因此会把‘丢包’这个行为视为拥塞控制信号。因此,在高丢包率的长肥管道(带宽大,延迟高)中,这种机制表现非常差,会不停地进行指数退避。
  • RTT:round-trip time,指一个包从sender方到达receiver方耗时
  • RTprop:物理延迟。当链路中没有任何排队和其他耗时时,RTprop=RTT
  • deliveryRate:发送速率。虽然有rate但并不是说的比率,Delivery Rate = CWND/SRTT,其中CWND = 可发送包的个数 * 包的大小;SRTT 是平滑RTT,动态测量的结果。所以deliveryRate 其实可以解释为每单位时间可以发送的数据量。中有个公式,大意为BtlBw=max(deliveryRatet),也就是评估瓶颈带宽的方式。
  • BtlBw:瓶颈带宽,也就是链路的最大传输能力。
  • BDP带宽时延积:(Bandwidth-Delay Product ,BDP)即链路上的最大比特数,也称以比特为单位的链路长度。计算方法:Bandwidth-Delay Product = delay_bandwidth=RTprop_BtlBw
    BBR的方法可以理解为将链路上的比特数控制为BDP的值。
    论文:
    一开始讲了RTprop和BtlBw的回归方程计算。表明BBR需要实时统计deliveryRate和RTT的值来获得RTprop和BtlBw的值。
    然后说了RTprop和BtlBw不能同时测得。

自旋锁 ,自旋锁的其他种类,阻塞锁,可重入锁 ,读写锁 ,互斥锁 ,悲观锁 ,乐观锁 ,公平锁 ,偏向锁, 对象锁,线程锁,锁粗化, 锁消除,轻量级锁,重量级锁, 信号量,独享锁,共享锁,分段锁

c1108590c9b36555c13c9114731207a7.png

不可不说的Java“锁”事

乐观锁 VS 悲观锁

对于同一个数据的并发操作,悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。Java中,synchronized关键字和Lock的实现类都是悲观锁。

而乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作(例如报错或者自动重试)。

  1. 乐观锁在Java中是通过使用无锁编程来实现,最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的。例如AtomicInteger.incrementAndGet()
  2. 版本号机制。一般是在数据表中加上一个数据版本号version字段,表示数据被修改的次数,当数据被修改时,version值会加一。当线程A要更新数据值时,在读取数据的同时也会读取version值,在提交更新时,若刚才读取到的version值为当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功。

场景:

  • 悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。
  • 乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。

CAS虽然很高效,但是它也存在三大问题:

  1. ABA问题。CAS需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是A,后来变成了B,然后又变成了A,那么CAS进行检查时会发现值没有发生变化,但是实际上是有变化的。ABA问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从“A-B-A”变成了“1A-2B-3A”。
    • JDK从1.5开始提供了AtomicStampedReference类来解决ABA问题,具体操作封装在compareAndSet()中。compareAndSet()首先检查当前引用和当前标志与预期引用和预期标志是否相等,如果都相等,则以原子方式将引用值和标志的值设置为给定的更新值。
  2. 循环时间长开销大。CAS操作如果长时间不成功,会导致其一直自旋,给CPU带来非常大的开销。
  3. 只能保证一个共享变量的原子操作。对一个共享变量执行操作时,CAS能够保证原子操作,但是对多个共享变量操作时,CAS是无法保证操作的原子性的。
    • Java从1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作。

自旋锁 VS 适应性自旋锁

阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。

在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失。如果物理机器有多个处理器,能够让两个或以上的线程同时并行执行,我们就可以让后面那个请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就会释放锁。

而为了让当前线程“稍等一下”,我们需让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销。这就是自旋锁。

自旋锁本身是有缺点的,它不能代替阻塞。自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源。所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用-XX:PreBlockSpin来更改)没有成功获得锁,就应当挂起线程。

自旋锁的实现原理同样也是CAS,AtomicInteger中调用unsafe进行自增操作的源码中的do-while循环就是一个自旋操作,如果修改数值失败则通过循环来执行自旋,直至修改成功。

自旋锁在JDK1.4.2中引入,使用-XX:+UseSpinning来开启。JDK 6中变为默认开启,并且引入了自适应的自旋锁(适应性自旋锁)。

自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

例:

  • TicketLock,基于先进先出(FIFO) 队列的自旋锁,保证首先注册的线程有更高级别优先性获取锁。有两个值,一个当前处理队列号,一个等待处理队列号。TicketLock 虽然解决了公平性的问题,但是多处理器系统上,每个进程/线程占用的处理器都在读写同一个变量queueNum ,每次读写操作都必须在多个处理器缓存之间进行缓存同步,这会导致繁重的系统总线和内存的流量,大大降低系统整体的性能。CLHLock和MCSLock是更好的解决方式
  • CLHLock,基于隐式链表的自旋锁,线程将会获取上一个线程注册的CLHNode对象,只有当CLHNode.isLocked==false时,自己才获得了锁
  • MCSLock,基于显式链表的自旋锁

无锁 VS 偏向锁 VS 轻量级锁 VS 重量级锁

偏向锁通过对比Mark Word解决加锁问题,避免执行CAS操作。而轻量级锁是通过用CAS操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能。重量级锁是将除了拥有锁的线程以外的线程都阻塞。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁(但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级)

Java并发——关键字synchronized解析

Java锁—偏向锁、轻量级锁、自旋锁、重量级锁

synchronized & Java对象头

synchronized是悲观锁,在操作同步资源之前需要给同步资源先加锁,这把锁就是存在Java对象头里的,而Java对象头又是什么呢?

我们以Hotspot虚拟机为例,Hotspot的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。

Mark Word:默认存储对象的HashCode,分代年龄和锁标志位信息。这些信息都是与对象自身定义无关的数据,所以Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。

下面给出四种锁状态对应的的Mark Word内容

锁状态 存储内容 存储内容
无锁 对象的hashCode、对象分代年龄、是否是偏向锁(0) 01
偏向锁 偏向线程ID、偏向时间戳、对象分代年龄、是否是偏向锁(1) 01
轻量级锁 指向栈中锁记录的指针 00
重量级锁 指向互斥量(重量级锁)的指针 10

f46717d97f3012274c5be1f2b5c952d9.png

Klass Point:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

Monitor

Monitor可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。每一个Java对象就有一把看不见的锁,称为内部锁或者Monitor锁。

Monitor是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联,同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。

结构如下:

9c8258bd38cb99dab01b37f75d22a040.png

Owner:初始时为NULL表示当前没有任何线程拥有该monitor record,当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为NULL

EntryQ:关联一个系统互斥锁(semaphore),阻塞所有试图锁住monitor record失败的线程

RcThis:表示blocked或waiting在该monitor record上的所有线程的个数

Nest:用来实现重入锁的计数

HashCode:保存从对象头拷贝过来的HashCode值(可能还包含GC age)

Candidate:用来避免不必要的阻塞或等待线程唤醒,因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后因为竞争锁失败又被阻塞)从而导致性能严重下降。Candidate只有两种可能的值0表示没有需要唤醒的线程1表示要唤醒一个继任线程来竞争锁

这四种锁是指锁的状态,专门针对synchronized的,synchronized通过Monitor来实现线程同步,Monitor是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的线程同步。

依赖于操作系统Mutex Lock所实现的锁我们称之为“重量级锁”,JDK 6中为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。

所以目前锁一共有4种状态,级别从低到高依次是:无锁、偏向锁、轻量级锁和重量级锁。锁状态只能升级不能降级。

无锁

无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。

无锁的特点就是修改操作在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。上面我们介绍的CAS原理及应用即是无锁的实现。无锁无法全面代替有锁,但无锁在某些场合下的性能是非常高的。

偏向锁

偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。

在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。其目标就是在只有一个线程执行同步代码块时能够提高性能。

当一个线程访问同步代码块并获取锁时,会在Mark Word里存储锁偏向的线程ID。在线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测Mark Word里是否存储着指向当前线程的偏向锁。引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令即可。

偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。

偏向锁在JDK 6及以后的JVM里是默认启用的。可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,关闭之后程序默认会进入轻量级锁状态。

轻量级锁

是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。

在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,然后拷贝对象头中的Mark Word复制到锁记录中。

拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向对象的Mark Word。

如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。

如果轻量级锁的更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁。

若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。

重量级锁

轻量级锁自旋

升级为重量级锁时,锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。

锁的转换

b6a12dd002fbd1bb74d5fdcd8243c1e3.png

公平锁 VS 非公平锁

公平锁是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。公平锁的优点是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。

非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。

可重入锁 VS 非可重入锁

可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。

独享锁 VS 共享锁

独享锁也叫排他锁,是指该锁一次只能被一个线程所持有。如果线程T对数据A加上排它锁后,则其他线程不能再对A加任何类型的锁。获得排它锁的线程即能读数据又能修改数据。JDK中的synchronized和JUC中Lock的实现类就是互斥锁。

共享锁是指该锁可被多个线程所持有。如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。

独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。

锁粗化/锁消除

锁消除:

锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。

锁粗化:

如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部

1
2
3
4
5
6
7
8
9
10
11
package com.paddx.test.string;

public class StringBufferTest {
StringBuffer stringBuffer = new StringBuffer();

public void append(){
stringBuffer.append("a");
stringBuffer.append("b");
stringBuffer.append("c");
}
}

这里每次调用stringBuffer.append方法都需要加锁和解锁,如果虚拟机检测到有一系列连串的对同一个对象加锁和解锁操作,就会将其合并成一次范围更大的加锁和解锁操作,即在第一次append方法时进行加锁,最后一次append方法结束后进行解锁。