Freeman's Blog

一个菜鸡心血来潮搭建的个人博客

0%

标准I/O

将IO数据先复制到内核缓冲区,然后从内核缓冲区复制到应用程序的地址空间。

5种IO模型

  • blocking/nonblocking:一直block到操作完成/内核缓冲区数据准备好时才开始block,否则立刻返回
  • sync/async:做IO operation时(内核缓冲区和应用程序的数据交换)是否会block

    阻塞IO

    blockingIO

    同步非阻塞IO

    nonblockingIO

    IO多路复用

  • select/poll/epoll
  • multiplexing函数可以具有超时机制
  • 单进程可以处理多个IO
    IOmultiplexing

    信号驱动IO

    signalDrivenIO

    异步IO

    asyncIO

3个IO多路复用函数

select

  • 几乎全平台支持
  • 3个fd_set
  • 底层数据结构使用数组,能监视的文件描述存在最大限制
  • select返回后需要轮询所有的fd

poll

  • 一个pollfd指针
  • 没有最大数量限制(可能受OS的最大描述符数量限制)
  • 返回后也需要轮询所有的pollfd

epoll

  • 3个接口
    1
    2
    3
    4
    int epoll_create(int size);//创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大
    // 只是一个初始大小
    int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
    int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
    • epoll_create: 创建epoll句柄(一个fd),因此epoll会创建一个fd。
    • epoll_ctl: 可以对指定fd(fd)添加、删除、修改希望监听的事件(操作由op指定)。event指定监听事件,也可以设置当前epoll为水平触发和边缘触发。
    • epoll_wait: 等待epfd上的IO事件。最多返回maxevent个事件。返回值是事件触发数。
  • 水平触发LT:epoll_wait检测到描述符发事件发生时通知程序,程序可以不立即处理事件。如果不处理,下次调用该方法时会再次通知应用程序事件的发生
  • 垂直触发ET(edge trigger): 应用程序必须立即处理事件,不处理的话下次不会再通知。

Java 3种IO

BIO

listen就是直到返回才会完成

NIO

Selector: 多路复用选择器,将事件绑定到此处
Channel: 读写数据的通道,可以注册到Selector中并选择感兴趣的事件(read就绪、write就绪、accept就绪…)。注册后在Selector上调用select会选出所有就绪的Channel对应的SelectionKey。
SelectionKey: 表示一个Channel在一个Selector上的一次注册,可以向其中附加数据(比如附加Handler方法)
ByteBuffer:用于向Channel中读写数据的缓冲区。

AIO

腾讯csig智慧供应链:

JVM:

  • 运行时内存模型√
  • 垃圾回收机制:标记算法、回收算法√
  • 类加载机制√
  • 移动到老年代有哪几种情况√

JUC:

  • 线程池怎么实现的,底层有什么数据结构
  • synchronized的底层实现√
  • volatile是什么,跟synchronized的区别,volatile是不是锁√
  • CAS√

网络:

  • 浏览器中输入地址回车会发生什么√
  • TCP/IP协议栈有哪几层√

字节跳动:

Java SE

  • HashMap的实现√
  • 接口跟抽象类的区别(Java 8, default关键字)√

JVM

  • 老年代的垃圾回收出现的时机是什么√

数据库

  • SQL语句
  • 三大范式、BCNF范式√

手撕环节

  • 给定有n个元素一个数组,求和为x的所有可能性 Leetcode 40。(15:13-15:35, 总计用时22分钟)

东方财富

面向对象

  • 抽象 封装 继承 多态√

设计模式

  • 是什么?:解决一类问题的方式√
  • 用过什么?√
  • UML?

网络

  • TCP和UDP的区别,优缺点√
  • ICMP:控制信息
  • TCP为什么要等2MSL√

操作系统

  • 进程间通信的方式√
  • 线程间通信的方式
    • 共享内存
  • IO的几种方式√
  • 进程和线程的区别√
  • 多线程和多进程的区别√
  • 虚拟内存:交换分区√

数据库

  • ACID特性√
  • 索引的数据结构√
  • 怎么用的索引√
  • 表锁和行锁√
  • 内连接和外连接√

数据结构

  • 红黑树
  • 字典树?

JVM

  • 栈内存是什么
  • 值传递 -> 引用类型,引用的复制,引用的地址

Java SE

  • List、Set、Map
  • Java泛型
  • Java内存泄漏?
  • 对象复制:浅拷贝、深拷贝
  • Java native关键字
  • 反射
  • lambda表达式

Spring

  • 是什么
  • SpringMVC
  • Spring Bean

?

Java SE

  • 多态
  • 重载和重写
  • 静态代码块(static)
  • 静态内部类

多线程

  • 线程池的原理
  • 什么时候新建线程(最大线程数)
  • 线程状态转换
  • 进程中断发生了什么
  • 不响应中断?
    • 什么时候线程不会响应中断?
  • Lock和Synchronized的区别
  • Lock的实现原理
  • volatile了解吗
    • 做了什么(原理?)
    • 防止指令重排
    • volatile读:需要读取内存
    • volatile写:刷新到内存

设计模式

  • 策略模式

JVM

  • 运行时内存模型
  • 堆里有什么
  • CMS
  • JVM调优参数
  • 堆转储
  • Full GC(Major GC)的发生时机

数据库

  • 索引数据结构
    • B+树的数据结构
  • 索引失效?
    • 复合索引
  • 事务默认隔离级别
    • 默认隔离级别
    • 可重复读
  • MVCC
    • 隐藏字段:隐藏主键,上一版本,时间戳

Redis?

  • 如何实现分布式锁

网络

  • HTTP状态码
  • TCP
    • TIME_WAIT
    • CLOSE_WAIT

Spring

  • SpringAOP
    • 实现接口:JDK、不实现接口:CGLIB
  • Bean生命周期
    • Aware接口

SpringBoot

  • 自动装配的原理

JWT

TLA+ ToolBox

项目总述

  • TLA+:TLA意为行为时序逻辑,TLA+则是一种形式化语言,以行为时序逻辑的方式对软件程序、算法或者协议进行高层次的建模,描述系统的行为,帮助开发人员对程序和算法有更清楚的认识,在系统设计阶段避免一些可能出现的错误。(对代码进行测试是一种发现编码错误的手段,但不是一种高效的对系统设计进行检查的手段)
  • TLA+的做法是使用基于状态的方式来描述系统的行为。用对所有状态变量的一次赋值来表示一个系统的全局状态。用状态转移前后状态变量的关系来表现所有可能出现的状态变化。于是系统的可能行为集合就可以被分为两个部分:所有可能的初始状态和所有可能的状态转移方式。
  • TLA+ Toolbox是形式化语言TLA+的集成开发环境,其中包括了模型检查器TLC。用户使用TLA+对系统建立的模型包括了系统的初始状态和所有可能的状态转换,实际上建立了整个系统的状态图。模型检查器TLC可以遍历用户系统对应的状态图,检查每个状态是否能够满足用户定义的一些约束,以此来帮助用户发现系统模型的潜在问题。
  • 个人负责的工作:提供TLC的运行时API,用户使用这些API可以在TLC运行时获取他们关心的一些状态信息,例如当前已经检查的模型深度和当前正在检查的状态。用户还可以使用这些API对运行中的TLC进行一些操作,例如让计算任务暂停,为当前任务创建检查点以便恢复执行等。这些API计划使用JMX进行提供。同时开发云计算功能模块:用户提供云服务提供商的token,TLC可以自动地使用云服务提供商的SDK创建云实例,将TLC主程序和用户编写的模型打包上传到云实例,在云实例上执行计算任务,并在计算任务完成时自动给用户发送邮件并关闭云实例。

实际应用场景

TLA+可以举个例子吗

  • TLA+ 比较常用的地方是用于精确地对并发系统或分布式算法进行建模

    2PC协议的TLA实现

JMX?

  • 定义符合JMX规范的Java对象MBean,将其注册到Java内置的MBean服务器中让MBean服务器对其进行管理,即可暴露一些当前应用的运行时信息,提供一些可读写的字段,提供一些可以在运行时执行的操作。
  • 定义MBean一般是先定义一个XXXMBean接口,在接口中说明所有可以访问的属性和所有可以执行的方法,然后定义XXXMBean的实现类XXX,在实现类中提供实际的数据访问、执行实际的操作。
  • MBean的名称规范(ObjectName): 至少要包括一个域(domain)和一系列的键值对。Domain一般可以使用包名。
  • MXBean: 只会包含预定义的数据类型,因此任何客户端(包括远程客户端)都可以使用,而不需要引用特定的自定义类型。MXBean接口不要求实现类名与接口名有对应关系。
  • 如果要在MXBean里使用复杂类型,复杂类型的数据会被映射为简单类型。

项目实现细节

模型检查器的基本原理

  • 本质上,用户所编写的模型规定了系统的初始状态和所有可能的状态转换,相当于定义了一个状态图。模型检查器的原理大致是对这个状态图进行广度优先遍历,并检查每个状态是否出现不满足Safety属性。为了实现广度优先遍历,TLC需要维护2个数据结构:一个记录所有已经检查过的状态的集合,一个用于实现广度优先遍历的队列。TLC可以开启多个worker线程,worker线程对已检查过的状态集和状态队列的独写使用ReadWriteLock来保证线程安全。
  • 系统的一次完整执行称为一次系统的行为。而系统的一次完整执行通常是可以用一个条件来定义的(某些事情总要发生,Liveness属性)。如果一个执行trace一次都没有触发过termination,说明检查到了违反liveness属性的执行序列。

集合怎么实现?

队列怎么实现?

  • 队列实现为一个硬盘上的文件,开头和结尾保留一定量的元素在内存中。使用了专有的线程对队列开头和结尾的缓冲区进行预载。

提供什么运行时API?

  • 启动与暂停:
    • 暂停:设置队列的标志变量stop,worker线程调用入队列或出队列的方法使用synchornized关键字修饰,调用时必须检查标志变量的值,如果发现stop = true则会调用wait方法让自己挂起。
    • 重启:修改stopfalse,并且调用notifyAll唤醒所有在队列上等待的线程
  • 单步调试:
    • 使用模型检查器的内部类解析用户编写的模型,生成所有的初始状态,让用户自行挑选下一状态并单步观察状态的变化。

云计算模块的基本原理

  • 让用户选择云服务的提供商并设置必要的认证凭据(ID, SECRET),云计算模块负责自动创建特定规格的实例,并通过SSH连接到云实例上,完成云实例的准备并下载运行所需要的依赖(例如JDK)。同时云计算模块会负责将用户在本地创建的模型文件与模型检查器的可执行jar文件打包上传到云实例上。模型检查器执行完毕后首先将执行结果的输出文件使用邮件发送到用户提供的邮件地址。
  • 主要负责为该模块添加阿里云支持,项目原本使用了jclouds屏蔽不同云服务提供商的接口差异,但是jclouds并未在正式版中提供阿里云支持,因此计划是尝试将阿里云的SDK进行包装和适配,尽可能地复用原有的代码。
  • 怎么发送邮件
    • 让用户提供邮件地址,可以通过邮件地址的@后的域名查询邮件地址对应的SMTP服务器域名。DNS服务器会返回对应的MX记录
    • 至于怎么知道哪台服务器才存放这个MX记录,我觉得只需要和查找A记录一样,先找到@后的域名所在的服务器就可以找到对应的MX记录了。
    • 或者可以使用阿里云的邮件推送服务
  • JVM调优:-XX:+UseParallelGC,使用

  • [] MVCC彻底理解清楚
  • [] 分库分表
  • [] MySQL Cluster在CAP中的定位

关系数据库基本概念

3大范式是什么

  • 函数依赖:对于X, Y, 如果不存在关系,使得X上的属性值相等而Y的属性值不等,称Y函数依赖于X。此时X称为决定因素。
  • 完全函数依赖:对于X的任何一个真子集X’,都不满足X’ -> Y,说明Y完全函数依赖与X
  • 部分函数依赖:如果存在X的真子集X’,满足X’->Y,说明Y对X部分函数依赖
  • 如果X->Y, Y不属于X(防止Y->X), Y -> Z, Z不属于Y(防止Z->Y),称Z对X传递函数依赖。
  • 码:如果K -F-> U(完全函数确定),K是R的候选码。
  • 1NF: 关系模式中的每一个分量是不可分的数据项
  • 2NF: 如果某个关系满足1NF,并且任何一个非主属性都完全函数依赖于任何一个候选码,则该关系模式满足2NF
  • 3NF: 如果任何一个非主属性既不部分依赖于码,也不传递依赖于码,说明该关系模式满足3NF
  • BCNF: 如果每一个决定因素都包含码,说明该关系模式满足BCNF

ACID

  • A原子性:一个事务中的操作要么全部完成,要么都不完成。
  • C一致性:数据从一个一致的状态转换为另一个一致的状态,维持数据性约束,数据的完整性不会被破坏。对数据库作出的修改必须符合预定义的规则,不能违反预定义的不变量。(这些“规则”和“不变量”都是与业务强相关的)
  • I隔离性:多个事务的执行不能互相干扰。隔离性可以防止多个事务并发执行时由于交叉执行而导致数据不一致。
  • D持久性:事务做出的更改应该是持久的,即使系统故障也不会丢失。

MyISAM vs InnoDB

  • 是否支持行级锁 : MyISAM 只有表级锁(table-level locking),而InnoDB 支持行级锁(row-level locking)和表级锁,默认为行级锁。
  • 是否支持事务和崩溃后的安全恢复: MyISAM 强调的是性能,每次查询具有原子性,其执行速度比InnoDB类型更快,但是不提供事务支持。但是InnoDB 提供事务支持,外部键等高级数据库功能。 具有事务(commit)、回滚(rollback)和崩溃修复能力(crash recovery capabilities)的事务安全(transaction-safe (ACID compliant))型表。
  • MyISAM不支持外键,InnoDB支持外键
  • 是否支持MVCC :仅 InnoDB 支持。应对高并发事务, MVCC比单纯的加锁更高效;MVCC只在 READ COMMITTED 和 REPEATABLE READ 两个隔离级别下工作;MVCC可以使用 乐观(optimistic)锁 和 悲观(pessimistic)锁来实现;

索引

为什么要用?

  • 创建唯一性索引可以保证每行数据的唯一性
  • 加快数据检索速度
  • 帮助服务器避免排序和临时表(?)
  • 将随机IO变为顺序IO
  • 加速表之间的连接

    为什么不为每一个列创建索引

  • 索引需要维护,加入索引会降低对数据更新的速度
  • 索引要占据物理空间

注意事项

  • 在经常需要搜索的列上,可以加快搜索的速度;

  • 在经常使用在WHERE子句中的列上面创建索引,加快条件的判断速度。

  • 在经常需要排序的列上创 建索引,因为索引已经排序,这样查询可以利用索引的排序,加快排序查询时间;

  • 对于中到大型表索引都是非常有效的,但是特大型表的话维护开销会很大,不适合建索引

  • 在经常用在连接的列上,这 些列主要是一些外键,可以加快连接的速度;

  • 避免 where 子句中对字段施加函数,这会造成无法命中索引。

  • 在使用InnoDB时使用与业务无关的自增主键作为主键,即使用逻辑主键,而不要使用业务主键。

  • 单行访问是很慢的。特别是在机械硬盘存储中(SSD的随机I/O要快很多,不过这一点仍然成立)。如果服务器从存储中读取一个数据块只是为了获取其中一行,那么就浪费了很多工作。最好读取的块中能包含尽可能多所需要的行。使用索引可以创建位置引用,以提升效率。

  • 按顺序访问范围数据是很快的,这有两个原因。第一,顺序 I/O 不需要多次磁盘寻道,所以比随机I/O要快很多(特别是对机械硬盘)。第二,如果服务器能够按需要顺序读取数据,那么就不再需要额外的排序操作,并且GROUPBY查询也无须再做排序和将行按组进行聚合计算了。
  • 索引覆盖查询是很快的。如果一个索引包含了查询需要的所有列,那么存储引擎就 不需要再回表查找行。这避免了大量的单行访问,而上面的第1点已经写明单行访 问是很慢的。

BTree索引

  • 数据结构:B+树

    B树 vs B+树

  • B树的所有节点既存放key也存放data,但B+树只有叶子节点存放key和data,其它节点只存放key
  • B 树的叶子节点都是独立的;B+树的叶子节点有一条引用链指向与它相邻的叶子节点。
  • B 树的检索的过程相当于对范围内的每个节点的关键字做二分查找,可能还没有到达叶子节点,检索就结束了。而 B+树的检索效率就很稳定了,任何查找都是从根节点到叶子节点的过程。

    MyISAM实现

  • 非聚簇索引,在索引数据结构(叶子节点的data域)上存放的是数据记录的地址。在索引检索的时候,首先按照B+Tree搜索算法搜索索引,如果指定的Key存在,则取出其 data 域的值,然后以 data 域的值为地址读取相应的数据记录。非聚集索引的叶子节点并不一定存放数据的指针, 因为二级索引的叶子节点就存放的是主键,根据主键再回表查数据。
  • MyISAM使用前缀压缩以减少索引,可以让更多的索引进入内存以减少磁盘IO的时间。MyISAM压缩索引库的方法是完全保存索引块中的第一个值,后续的值只保存和第一个值相同前缀的字节数和后续的不同部分。这样每个值的压缩前缀都依赖前面的值,所以MyISAM查找时无法在索引块使用二分查找而只能从头开始扫描,这会影响某些操作的性能,例如倒序扫描。
  • 硬要说的话适合UPDATE密集的表

    InnoDB实现

  • 聚簇索引,表数据文件本身就是按B+Tree组织的一个索引结构,树的叶节点data域保存了完整的数据记录。这个索引的key是数据表的主键,因此InnoDB表数据文件本身就是主索引。其它的索引都作为辅助索引,辅助索引的data域保存的是主键的值而不是地址。在根据主索引搜索时,直接找到key所在的节点即可取出数据;在根据辅助索引查找时,则需要先取出主键的值,再走一遍主索引。 因此,在设计表的时候,不建议使用过长的字段作为主键,也不建议使用非单调的字段作为主键,这样会造成主索引频繁分裂。
  • MyISAM索引按照行存储的物理位置引用被索引的行,但是InnoDB按照主键值引用行。如果使用DML对表中数据进行操作(插入、删除、修改…),行的存储位置可能会发生变化,如果使用主键值引用行,此时就不需要对索引进行更新。

    关于B+树索引的NULL值和 != 条件

  • 记录的主键值不允许存储NULL值,因此在有与主键相关的IS NULL条件下查询优化器会直接优化这部分。(Impossible WHERE)
  • 对于二级索引来说,索引列的值可以为NULL,这些索引记录存放在B+树的最左边。(Define the SQL NULL to be the smallest possible value of a field)
  • 对于IS NULL, IS NOT NULL, != 条件,什么时候能够使用索引?
    • 读取二级索引记录的成本
    • 将二级索引记录执行回表操作,也就是到聚簇索引中找到完整的用户记录的操作所付出的成本。
    • 如果要扫描的二级索引记录条数越多,需要执行的回表操作次数也就越多。当二级索引的执行查询成本超过全表扫描的成本时,不如直接扫描聚簇索引。

哈希索引

  • 数据结构:哈希表
  • 对于哈希索引来说,底层的数据结构就是哈希表,因此在绝大多数需求为单条记录查询的时候,可以选择哈希索引,查询性能最快;其余大部分场景,建议选择BTree索引。哈希索引无法支持范围查询和顺序查询。
  • InnoDB存储引擎和MyISAM存储引擎都无法建立Hash索引,只有Memory/HEAP/NDB存储引擎才能建立哈希索引
    • 然而Memory引擎效率差,没有数据备份恢复机制(只能依赖Server层的binlog,引擎层没有任何机制保障),重启之后数据表中的数据就会丢失

最左前缀原则

MySQL中的索引可以以一定顺序引用多列,这种索引叫作联合索引。而最左前缀原则指的是,如果查询的时候查询条件精确匹配索引的左边连续一列或几列,则此列就可以被用到。

联合索引的数据结构

对于(a, b, c)上的联合索引(只考虑(a, b, c),不考虑自动创建的a, (a, b)等索引),B+树的每个节点都会包括索引中的所有列。排序规则是先按a排序,如果a相等时再按b排序,ab都相等时再按c排序。

问题:对于(a, b, c)上的联合索引,对于条件where a = 'xx' and c = 'yy',是否可以利用索引?
会利用索引。(why?)
问题:MySQL 8.0还需要遵守最左匹配原则吗?

查询缓存

事务

事务的特性

  • 事务是逻辑上的一组操作,要么都执行,要么都不执行。
  • 原子性(Atomicity): 事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用;
  • 一致性(Consistency): 执行事务后,数据库从一个正确的状态变化到另一个正确的状态;
  • 隔离性(Isolation): 并发访问数据库时,一个用户的事务不被其他事务所干扰,各并发事务之间数据库是独立的;
  • 持久性(Durability): 一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响。

MySQL如何保证事务的一致性

  • 是目的,AID是手段(存疑)。保证事务的原子性、隔离性和持久性是数据库保证一致性的手段。数据库提供的外键约束、级联操作和触发器也是保证一致性的手段。但是一致性是业务强相关的特性,而在代码中或在事务操作中故意写出违反约束的代码是可能的。
  • MySQL如何保证事务的原子性:利用InnoDB的Undo log。Undo Log记录了回滚数据库事务操作所需要的信息,当事务执行失败或者主动调用ROLLBACK,事务可以利用Undo Log将数据回滚到未作出修改的状态。
  • MySQL如何保证事务的持久性:利用InnoDB的Redo Log。
    • MySQL先把磁盘上的数据加载到内存中,在内存中对数据进行修改,最后再刷回硬盘。MySQL以页为单位与硬盘进行交互,单页的大小为16KB。而单次事务可能对多个页的一小部分数据进行修改,如果每次事务提交都需要将修改的页写回硬盘,性能将会大幅下降。因此MySQL使用Redo log,事务中的操作执行的同时将修改写在redo log,事务提交时将redo log写回硬盘。如果事务提交过程中出现宕机,在数据库恢复时可以恢复redo log的内容,再根据具体情况决定对修改进行回滚或是提交。
  • MySQL如何保证事务的隔离性:利用锁机制和多版本并发控制来实现不同级别的事务隔离性。

并发事务的问题

  • 脏读(Dirty read):事务修改了数据但是还没有将修改提交到数据库中,另一个事务也访问了这个数据,然后使用了这个数据。因为这个数据是还没来得及提交的旧数据,读取的事务读到的是脏数据,根据这样的脏数据做的操作可能是不正确的
  • 丢失修改(Lost to modify):两个事务同时读取一个数据,并同时修改这个数据。第一个事务修改了这个数据后第二个事务也修改了这个数据,导致第一个事务的修改被丢失。
  • 不可重复读(Unrepeatableread):在一个事务内多次读同一数据。在这个事务还没结束时另一个事务也访问这个数据,并且可能对这个数据进行修改。这样,一个事务内的多次读取到的同一数据的值可能不一样,这种情况称为不可重复读
  • 幻读(Phantom read):在一个事务内用相同的条件查询多次数据,在这个事务查询一次数据之后,另一个并发的事务插入或删除了一些数据,因此事务后续用相同的条件进行查询时结果集会发生变化。

事务的隔离级别

  • READ-UNCOMMITTED(读取未提交):最低的隔离级别,允许读取尚未提交的数据变更。可能会导致脏读、幻读、不可重复读。
  • READ-COMMITTED(读取已提交):允许读取并发事务已经提交的数据,可以阻止脏读,但是无法阻止幻读和不可重复读。
  • REPEATABLE-READ(可重复读):默认的隔离级别。一个事务内对同一字段的多次读取结果是一致的,除非数据本身已经被事务自己改变。可以阻止脏读和不可重复读,但是仍然可能发生幻读
  • SERIALIZABLE(可串行化):最高的隔离级别,分布式事务的情况下一般使用可串行化的隔离级别,完全满足ACID性质,所有的事务依次逐个执行,互不干扰。可以防止脏读、不可重复读和幻读。

MySQL 锁机制

按照锁的粒度分类

  • 表级锁:对整张表加锁,实现简单,消耗资源少,加锁快,不会出现死锁,粒度最大,并发度最低。
  • 行级锁:粒度最小的锁,减少数据库操作的锁冲突,并发度高,但加锁开销大,加锁慢,可能出现死锁。

共享/排他锁

  • S锁:可以是行级也可以是表级
  • X锁:可以实行级也可以是表级
  • IS锁:表级
  • IX锁:表级

其他锁

记录锁(Record locks)

  • 记录锁是施加在索引记录上的锁。(是指主索引吗?)
  • 记录锁永远只会对索引记录施加,即使表没有定义任何索引。在这种情况下,InnoDB会创建一个隐藏的聚簇索引,用该索引实现记录锁。

间隙锁(Gap locks) <- ?

  • 间隙锁是施加在索引记录之间间隙、在第一个索引记录之前和最后一个索引记录之后的锁。例如查询
    SELECT c1 FROM t WHERE c1 BETWEEN 10 and 20 FOR UPDATE会阻止其他事务插入一个t.c1 = 15的行,因为在这个范围内的间隙都已被加锁。(一个间隙指的是一个区间)
  • 间隙锁是性能和并发性之间权衡的一部分,在某些事务隔离级别中使用(目测是为了防止幻读),而在其他级别中则不使用。
  • 当使用唯一索引搜索唯一行时,不需要使用间隙锁。但是使用多列唯一索引的部分列进行搜索时,间隙锁会发生作用。
  • 不同事务可以持有互相冲突的间隙锁。
  • InnoDB中的间隙锁是纯禁止性的,间隙锁的唯一目的就是防止其它事务向间隙中插入数据。

邻键锁(Next-Key locks) <- ?

  • 邻键锁是记录锁和在索引记录之前的间隙锁的结合(a combination of a record lock on the index record and a gap lock on the gap before the index record)。
  • InnoDB是这样实现行级锁的:当搜索或扫描表的索引时,InnoDB在它遇到的索引记录上施加共享锁或排他锁。因此行级锁实际上是索引记录锁。
  • 邻键锁对索引记录加锁,同时对索引记录之前的(一个)间隙加锁。
  • REPEATABLE_READ事务隔离级别下,InnoDB使用邻键锁来进行搜索和索引扫描,避免幻读的出现(->因此REPEATABLE_READ隔离级别不存在幻读问题?

插入意向锁(Insert Intension locks)

  • 插入意图锁是一种间隙锁,是由INSERT操作在行插入之前设置的。该锁表明插入数据的意向,多个事务向同一个间隙(区间)插入数据时,如果他们插入的位置不同,则不需要互相等待。

自增锁(AUTO-INC locks)

  • 自增锁是一种特殊的表级锁,当事务需要向包含AUTO_INCREMENT列的表插入数据时自增锁会发挥作用。如果一个事务正在向表中插入数据,另一个需要插入数据的事务必须等待,这样插入表中的行才能有连续的自增主键。
  • 通过更改innodb_autoinc_lock_mode配置项可以选择用于自增锁的算法,更改该选项用户可以自由地在自增值的可预测性和插入操作的并发性之间进行权衡取舍。

大表优化

限定数据范围,禁止不带任何限制范围条件的查询语句

读写分离

主库写、从库读

垂直分区

根据数据库里面数据表的相关性进行拆分。 例如,用户表中既有用户的登录信息又有用户的基本信息,可以将用户表拆分成两个单独的表,甚至放到单独的库做分库。
简单来说垂直拆分是指数据表列的拆分,把一张列比较多的表拆分为多张表。
优点:列数据变小,减少读取的block数,减少IO次数,简化表的结构,易于维护。
缺点:主键冗余,增加join操作,让事务变得更复杂。

水平分区

保持数据表结构不变,通过某种策略存储数据分片。这样每一片数据分散到不同的表或者库中(比如,按时间区间水平分区,按ID水平分区),达到了分布式的目的。 水平拆分可以支撑非常大的数据量。
分表仅仅是解决了单一表数据过大的问题,但由于表的数据还是在同一台机器上,其实对于提升MySQL并发能力没有什么意义,所以 水平拆分最好分库
水平拆分能够 支持非常大的数据量存储,应用端改造也少,但 分片事务难以解决 ,跨节点Join性能较差,逻辑复杂。

  • 分片的常见方案:
    • 客户端代理:分片逻辑在应用端,封装在jar包中,通过修改或封装JDBC层来实现。(?)
    • 中间件代理:在应用和数据中间加了一个代理层。分片逻辑统一维护在中间件服务中。(?)

      SQL

  • 使用慢查询日志找出较慢的SQL
  • 不做列运算:SELECT id WHERE age + 1 = 10,任何对列的操作都将导致表扫描,它包括数据库教程函数、计算表达式等等,查询时要尽可能将操作移至等号右边
  • 对列进行函数操作可能会导致无法使用索引 -> 函数索引。隐式类型转换也会导致索引失效。
  • 尽量不用SELECT *,而是显式指定需要的列
  • 不用函数和触发器,在应用程序实现(?)
  • 少用JOIN
  • 尽量避免在WHERE中使用!=, <>, 这类条件无法利用索引,而只能使用全表扫描。

索引

  • 频繁的查询优先考虑使用覆盖索引(包含了所查询字段的索引),这样可以必满InnoDB表进行索引的二次查询。
    • 例如,要通过某个建立了二级索引的列A上的条件查询列B的值,如果只使用A上的索引,由于该索引的叶子节点只保存了主键的值,因此此时需要回表,即在主键索引上再次进行查找,才能找到对应行的B的值。如果在AB上建立一个联合索引,在叶子节点处就会存在B列的值,不需要进行回表。
  • 一个 SQL 只能利用到复合索引中的一列进行范围查询。如:有 a,b,c 列的联合索引,在查询条件中有 a 列的范围查询,则在 b,c 列上的索引将不会被用到。

模糊查询

  • 严禁左模糊(例如,a like '%123'。形如a like '123%',即左侧具体的条件,是可以使用列上的索引的)或者全模糊,有这种需求的时候需要使用搜索引擎。B树索引有最左前缀匹配的特性,最左边的值不确定时无法使用。

外键

  • 对于外键和级联的一种好的方式是在应用层实现。外键与级联更新适用于单机低并发,不适合分布式、高并发集群;级联更新是强阻塞,存在数据库更新风暴的风 险;外键影响数据库的插入速度。并且分库分表的情况下数据库级别的外键也无法生效。
  • 外键也有一定的好处:在DB层面就保证了数据的一致性和完整性,并且由数据库自动完成级联操作也可以减少代码量。
  • 在定义联合索引时,如果 a 列要用到范围查找的话,就要把 a 列放到联合索引的右侧,使用 left join 或 not exists 来优化 not in 操作,因为 not in 也通常会使用索引失效。

池化(数据库连接池)

建立连接是需要消耗时间的,如果有较多的任务陆续提交而不进行任何特殊处理,就需要重复建立连接-关闭连接的过程,浪费时间。因此池化技术的思想就是复用这些创建的连接。(数据库连接的本质可以视为一个socket连接)。

SQL语句的执行过程

sqlProcess

主要组件

  • Server层
    • 连接器: 身份认证和权限相关(登录 MySQL 的时候)。
    • 查询缓存: 执行查询语句的时候,会先查询缓存(MySQL 8.0 版本后移除,因为这个功能不太实用)。执行查询语句的时候,会先查询缓存,MySQL 会先校验这个 sql 是否执行过,以 Key-Value 的形式缓存在内存中,Key 是查询预计,Value 是结果集。如果缓存 key 被命中,就会直接返回给客户端,如果没有命中,就会执行后续的操作,完成后也会把结果缓存起来,方便下一次调用。
    • 分析器: 没有命中缓存的话,SQL 语句就会经过分析器,分析器说白了就是要先看你的 SQL 语句要干嘛,再检查你的 SQL 语句语法是否正确。词法分析提取关键字,语法分析校验SQL语法是否正确。
    • 优化器: 按照 MySQL 认为最优的方案,生成查询计划去执行。 比如决定如何选择索引。
    • 执行器: 执行语句,然后从存储引擎返回数据。执行器在执行前会首先检查用户有没有权限。
  • 存储引擎:InnoDB包括Redolog模块

更新语句的执行流程(Server层和InnoDB存储引擎的交互)

执行更新语句时要记录日志。MySQL使用binlog进行日志记录,InnoDB还自带一个日志模块redo log。

  1. 拿到待修改的数据,进行修改,然后调用引擎API接口写入修改后的数据。InnoDB会将数据保存在内存中,同时记录Redo log(写不写硬盘?),此时redo log进入prepare状态,然后告诉执行器执行完成,随时可以提交。
  2. 执行器收到通知后记录binlog(写不写硬盘?),然后调用引擎接口,提交redo log为提交状态(写不写硬盘?)。
  3. 更新完成。(怎么记录undo log?
  • 为什么要用两个日志模块?

    • 这是因为最开始 MySQL 并没有 InnoDB 引擎( InnoDB 引擎是其他公司以插件形式插入 MySQL 的) ,MySQL 自带的引擎是 MyISAM,但是我们知道 redo log 是 InnoDB 引擎特有的,其他存储引擎都没有,这就导致会没有 crash-safe 的能力(crash-safe 的能力即使数据库发生异常重启,之前提交的记录都不会丢失),binlog 日志只能用来归档(??)。
  • 为什么要先让redo log进入prepare状态,然后记录binlog,最后commit redo log?

    • 先写 redo log 直接提交,然后写 binlog,假设写完 redo log 后,机器挂了,binlog 日志没有被写入,那么机器重启后,这台机器会通过 redo log 恢复数据,但是这个时候 bingog 并没有记录该数据,后续进行机器备份的时候,就会丢失这一条数据,同时主从同步也会丢失这一条数据。
    • 先写 binlog,然后写 redo log,假设写完了 binlog,机器异常重启了,由于没有 redo log,本机是无法恢复这一条记录的,但是 binlog 又有记录,那么和上面同样的道理,就会产生数据不一致的情况。(直接用binlog恢复可以吗?
      如果采用 redo log 两阶段提交的方式就不一样了,写完 binglog 后,然后再提交 redo log 就会防止出现上述的问题,从而保证了数据的一致性。那么问题来了,有没有一个极端的情况呢?假设 redo log 处于预提交状态,binglog 也已经写完了,这个时候发生了异常重启会怎么样呢? 这个就要依赖于 MySQL 的处理机制了,MySQL 的处理过程如下:
    • 判断 redo log 是否完整,如果判断是完整的,就立即提交。(什么叫是否完整?意思是是否已经是commit状态?)
    • 如果 redo log 只是预提交但不是 commit 状态,这个时候就会去判断 binlog 是否完整(什么叫是否完整?),如果完整就提交 redo log, 不完整就回滚事务。
      这样就解决了数据一致性的问题。

MySQL日志

  • binlog:二进制日志
  • redo log、undo log:事务日志(?)
  • redo log:物理日志,记录数据页的物理修改,而不是对某一行的具体修改
  • undo log:逻辑日志(?)
  • 慢查询日志

binlog

binlog 用于记录数据库执行的写入性操作(不包括查询)信息,以二进制的形式保存在磁盘中。 binlog 是 mysql的逻辑日志,并且由 Server 层进行记录,使用任何存储引擎的 mysql 数据库都会记录 binlog 日志。

  • 包含引起或可能引起数据库改变的事件信息。
  • 对于事务操作,二进制日志只在事务提交的时候一次性写入。提交前的每个二进制日志记录都会先写到内存,事务提交时写入硬盘。
  • 是Server层产生的,不管什么存储引擎对数据库进行了修改都会产生binlog
  • 逻辑日志: 逻辑性的语句。基于语句时直接记录SQL语句,基于行时记录行数据的修改情况。
  • 物理日志: mysql 数据最终是保存在数据页中的,物理日志记录的就是数据页变更。

使用场景

  • 主从复制 :在 Master 端开启 binlog ,然后将 binlog 发送到各个 Slave 端, Slave 端重放 binlog 从而达到主从数据一致。
  • 数据恢复 :通过使用 mysqlbinlog 工具来恢复数据。

刷盘时机

对于 InnoDB 存储引擎而言,只有在事务提交时才会记录 binlog ,此时记录还在内存中。mysql 通过 sync_binlog 参数控制 biglog 的刷盘(调用fsync)时机(影响的是如果操作系统崩溃,是否会丢失修改。如果单纯数据库服务器崩溃,似乎操作系统缓冲区的内容还是能够刷入磁盘),取值范围是 0-N:

  • 0:不去强制要求,由系统自行判断何时写入磁盘;
  • 1(默认):每次 commit 的时候都要将 binlog 写入磁盘;
  • N:每N个事务,才会将 binlog 写入磁盘。

redo log

事务的持久性:只要提交成功,对数据库的修改就要永久保存。
内存与硬盘数据的一致性如何保证?最简单的方法是每次事务提交的时候就把涉及到的数据页全部刷写到磁盘中。可能每次事务只会改一个页里的几个字节,却要把整个页完整地刷新到磁盘(InnoDB以页为单位与磁盘交互),而且一个事务可能涉及多个数据页,这样性能太差。

  • redo log:记录事务对数据页做了哪些修改。
    redo log 包括两部分:一个是内存中的日志缓冲(redo log buffer),另一个是磁盘上的日志文件(redo logfile)。 mysql 每执行一条 DML 语句,先将记录写入redo log buffer,后续某个时间点再一次性将多个操作记录写到 redo log file 。这种 先写日志,再写磁盘 的技术就是 MySQL里经常说到的 WAL(Write-Ahead Logging) 技术。
    • redo log buffer是易失性的。磁盘上的redo log file是持久的。
    • log file其实指的是内存中内核空间的操作系统缓冲区(OS Buffer)中的redo log。InnoDB会以一定的时间间隔(例如每个事务提交时)调用fsync将OS Buffer中的redo log内容刷到硬盘上的真正的redo log文件。
    • 可以通过调整innodb_flush_log_at_trx_commit的参数值来调整调用fsync的时机。
      • 1:每次提交都写入os buffer并fsync。主从复制时必须设置为1.
      • 0:每秒将log buffer写入os buffer并调用fsync。
      • 2:每次提交都写入os buffer,但每秒调用一次fsync。
        RedoLogFile
  • redo log记录数据页的变更(物理日志?)redo log采用大小固定、循环写入的方式,到达结尾时会回到开头循环写日志。日志上的记录在数据落盘后会被覆盖掉。
    • redo log group:一组redo log需要由几个redo log file构成。
    • 第一个redo log file写完后会开始写第二个。第二个写完后会开始覆盖第一个redo log file的内容。
  • redolog会在数据准备修改前先写入缓存中的redolog,然后再对缓存中的数据执行实际操作。

redo log的内容

  • redo_log_type:redo log的日志类型
  • space:表空间的ID(?)
  • page_no:页的偏移量(??)
  • redo_log_body:数据部分

redo log和binlog的区别

  • redo log是InnoDB引擎特有的;binlog是MySQL的Server层实现的,所有引擎都可以用。

BinlogVSRedolog

undo log

数据库事务四大特性中有一个是 原子性 ,具体来说就是 原子性是指对数据库的一系列操作,要么全部成功,要么全部失败,不可能出现部分成功的情况。实际上, 原子性 底层就是通过 undo log 实现的。 undo log 主要记录了数据的逻辑变化,比如一条INSERT语句,对应一条 DELETE 的 undo log ,对于每个UPDATE 语句,对应一条相反的 UPDATE 的undo log ,这样在发生错误时,就能回滚到事务之前的数据状态。同时, undo log 也是 MVCC(多版本并发控制)实现的关键。

慢查询日志

  • 执行时间、执行用户、查询用时、具体的查询语句
  • 如何分析?使用explain语句

MVCC(多版本并发控制)

  • 为什么要引入MVCC?:只让写写互相阻塞,其它操作可以并行
  • MySQL的大多数事务型存储引擎实现的其实都不是简单的行级锁。基于提升并发性能的考虑, 它们一般都同时实现了多版本并发控制(MVCC)。可以认为MVCC是行级锁的一个变种, 但是它在很多情况下避免了加锁操作, 因此开销更低。虽然实现机制有所不同, 但大都实现了非阻塞的读操作,写操作也只锁定必要的行。MVCC的实现方式有多种, 典型的有乐观(optimistic)并发控制 和 悲观(pessimistic)并发控制。
  • 只在READ COMMITTEDREAD REPEATABLE隔离级别下工作。因为 READ UNCOMMITTED 总是读取最新的数据行, 而不是符合当前事务版本的数据行。而 SERIALIZABLE 则会对所有读取的行都加锁。

    MVCC在InnoDB中的实现

    在InnoDB中,会在每行数据后添加3个额外的隐藏的值来实现MVCC:
  • DB_TRX_ID: 插入或更新该行的最后一个事务的标识符。删除也算一种更新,有特殊的标志位说明该行已经被删除。
  • DB_ROLL_PTR: (Roll Pointer)指向回滚段中的undo log记录。如果该行被更新,使用undo log可以重构出该行更新前的数据。
    • undo log记录的是旧版本的数据,其它事务读取数据的时候,根据DB_TRX_ID和DB_ROLL_PTR从undo log链中找到符合可见性要求的数据。undo log中的数据通过DB_ROLL_PTR和主数据关联。
  • DB_ROW_ID: 行标识,是一个随着新行插入单调自增的行ID。如果InnoDB自动生成了聚簇索引(用户没有指定主键),这个聚簇索引的索引列就是这个单调自增行ID。否则这一列不会出现在任何索引中。

一致性读和加锁读

  • Consistent Nonlocking reads:Consistent read是一种读操作,使用基于某个时间点的快照信息来展示查询结果,而忽略同时运行的其他事务做出的修改。对于查询而言,在快照的时间点之前已经提交的事务所做出的更改是可见的,而在该时间点之后做出的更改或未提交的事务所作出的更改是不可见的(例外情况:同一事务中之前的语句做出的修改是可见的)。
    • REPEATABLE_READREAD_COMMITTED隔离级别下InnoDB处理SELECT语句的默认模式。
    • 不会对行或表加锁,同时会忽略所有在读视图记录上加的任何锁。
  • Locking Read: 如果想要确保读到最新数据,需要使用FOR UPDATEFOR SHARE加入互斥锁或共享锁,或者使用READ_UNCOMMITTED隔离级别。

快照的本质

  • 每个事务启动的瞬间(还是创建快照时?)都会记录当前所有“活跃事务”(已经启动但还没有提交)的事务标识符(ID)。
  • 最小的活跃事务ID为低水位
  • 所有已经出现过的最大事务ID + 1为高水位(即下一个开启的事务会被分配的ID
  • 可见性判断:用当前数据的DB_TRX_ID和数组中的事务ID进行比较。低水位以前的数据版本可见(肯定已经提交了),大于等于高水位的数据版本不可见(还没开始)。如果处于高水位和低水位之间,需要看当前数据版本的DB_TRX是否存在于高低水位之间,如果存在,则说明事务仍未提交,该版本对当前事务不可见,否则说明事务已经提交,该版本对当前事务可见。
  • 寻找可见的数据版本:如果发现当前版本的数据不可见,则根据隐藏列DB_ROLL_PTR在undo log中找到旧版本的版本号,重新判断,知道找到可见的版本,读出该版本的数据。

快照的创建时机

REPEATABLE_READ可重复读

  • 对于可重复读的事务隔离级别,快照会基于事务的第一次读操作进行构建,同一事务中的所有consistent Read操作读出的内容都是(基于)这一快照的。如果数据已经被其他事务修改,原始数据会通过undo log的内容进行重构。在该隔离级别下,每个在同一事务中的普通的(不使用锁的)SELECT语句都是一致的。

READ_COMMITTED读已提交

  • 对于每次的Consistent Read操作,即使他们都在同一个事务中,他们都会对快照进行更新并读取最新快照。

START TRANSACTION WITH CONSISTENT SNAPSHOT

  • 立即启动事务并创建一致性读快照,相当于马上执行一个普通的SELECT语句。

Undo Log

  1. INSERT UNDO LOG:对插入数据产生的UNDO LOG,只有在事务回滚的时候才需要,事务提交之后就可以丢弃。
  2. UPDATE UNDO LOG: 对修改和删除数据产生的UNDO LOG,快照读也是需要的。只有当数据库所使用的快照不涉及该日志记录(低于低水位?)时对应的回滚日志才会被删除。
  • purge线程:需要删除时只是先设置旧记录的标志位,InnoDB使用专门的purge线程来清理这些记录。

加锁读

读已提交

  • 对于locking read操作、UPDATE语句、DELETE语句,InnoDB只会对索引记录加锁,而不会对他们之前的gap加锁,因此允许在这些被锁住的记录旁边插入新记录。Gap锁只会被用于外键约束检查和重复键检查。
  • 个人理解:已提交读不会出现脏读,而可能出现幻读(允许插入数据)和不可重复读(要对快照进行更新并读取最新快照)。

可重复读

  • 对于locking reads(使用FOR UPDATEFOR SHARESELECT),如何加锁取决于该SELECT语句是在唯一索引(unique index)上使用了唯一的搜索条件,还是使用了范围型的搜索条件。
    • 对于在使用唯一索引和唯一的搜索条件的情况,InnoDB只会对结果集中的记录的索引进行加锁,而不会对其之前的gap(其它能够插入数据的位置)进行加锁。(此时结果集中的记录的索引已经被加锁,不会被删除也不会被修改)。
    • 对于其他搜索条件,InnoDB会对所有扫描的索引范围加锁,使用gap locks或next-key locks对其它试图在范围中的gap进行数据插入的操作进行阻塞。(个人理解:结果集中的行的间隙(gap)也会加锁,覆盖到的范围都会加锁,不让其它session在可以插入数据的位置进行插入)
  • 个人理解:可重复读隔离级别解决了脏读、不可重复读问题,交替使用快照读和加锁读会出现幻读。

高级主题

MySQL 主从模式

  • 主从模式的好处
    • 一定程度上解决了单点故障问题
    • 可以实现读写分离,降低单台服务器的负载
  • 主从复制的策略
    • 同步复制:等到所有Slave的回应才会提交并返回结果
    • 半同步复制:等到至少一个Slave的回应就返回
    • 异步复制:Master不等Slave回应就返回结果
    • 延迟策略?

TODOS

  • [] Redis分布式锁

5大数据类型

String

  • 分布式锁
    • set if not exists: setnx key value
    • set key value [EX seconds] [PX milliseconds] [NX|XX]
      • ex: 几秒后过期
      • px:几毫秒后过期
      • NX:不存在的时候创建key
      • XX:存在的时候覆盖key
  • 需要计数的场景
  • 商品编号、订单号,使用INCR生成
  • 赞/踩

    List

  • 有序有重复/可以左右加入
  • lpush/rpush
  • brpop: 阻塞的pop操作
  • lrange: 分页查询
  • (离线的)消息推送。
  • 消息队列、慢查询

    Hash

  • hset/hmset
  • hget/hmget
  • 对象存储
  • 也可以实现分布式锁 -> 可重入锁
    • 记录过期时间和释放次数
  • 购物车?

    set

  • smembers
  • 无序无重复
  • 删除/加入/判重
  • 随机弹x个(删/不删)
  • 集合运算:差/交/并
  • 场景
    • 数据不能重复,需要进行集合操作
    • 抽奖:参与sadd,显示参与scard,抽取srandmember/spop
    • 点赞
    • 社交关系
    • 可能认识的人

      zset(Sorted set)

  • 有序无重复
  • 需要根据权重进行排序的情况。
  • 在线用户列表、排行榜、弹幕…

    bitmap

  • 用一个bit位来表示某个元素对应的值或者状态。key就是对应元素本身。-> 节省存储空间
  • 用户签到:offset = day % 365,key = 年份#用户ID
  • 统计活跃用户?BITOP [AND | OR | NOT | XOR]
  • 用户在线状态:用户ID为offset

    hyperloglogs

    geospatial

    stream

Redis底层数据结构

Redis为什么单线程,又为什么开始使用多线程

单线程模型

  • IO多路复用 + Reactor模式
    • 单线程带来更好的可维护性,方便开发调试
    • 单线程也能够并发。
    • 绝大多数操作的性能瓶颈不是CPU
      • 简单总结一下,Redis 并不是 CPU 密集型的服务,如果不开启 AOF 备份,所有 Redis 的操作都会在内存中完成不会涉及任何的 I/O 操作,这些数据的读写由于只发生在内存中,所以处理速度是非常快的;整个服务的瓶颈在于网络传输带来的延迟和等待客户端的数据传输,也就是网络 I/O,所以使用多线程模型处理全部的外部请求可能不是一个好的方案。
    • 减少线程创建和切换的开销
    • 不需要同步机制,可以保障单次操作的原子性

为什么引入多线程模型

  • 如果某个任务特别大,单次任务可能要消耗较多的时间,这些操作会阻塞待处理的任务。
  • 删除超大键值对:UNLINK,将key从元数据中删除,开启新线程在后台执行释放内存空间的工作。
  • 接受数据包并解析Redis命令,发送返回数据包:这些过程可以引入多线程进行并发处理。

Redis如何判断数据过期、删除策略、内存淘汰机制

Redis判断数据过期

过期字典:key就是数据库中的key,value是时间戳

Redis过期删除策略

  • 惰性删除:只有在取出key的时候对数据进行过期检查。对CPU友好,但可能会造成过期key太多。
  • 定期删除:隔一段时间抽取一批key执行删除过期key操作。执行时长和执行频率会对CPU时间造成影响。
  • 定期删除+惰性删除:每隔一段时间进行检查,但是控制请求的时间和范围,只对key进行随机抽检。然后在取出某个key的时候进行检查,如果过期则删除。
    • 即使使用这种策略,如果定期删除没有删除完全/没有对key进行请求/没有设置过期时间,redis的内存占用会越来越高,此时需要采用内存淘汰机制。

Redis内存淘汰机制

  • redis.conf中:maxmemory-policy [policy name]
  • volatile-lru:从设置了过期时间的数据集里进行LRU,只有在将redis既作为持久存储又作为缓存的时候才使用。
  • volatile-ttl:从设置了过期时间的数据级中选择即将过期的数据进行淘汰
  • volatile-random:从已设置过期时间的数据集中任意选择数据淘汰
  • allkeys-lru: 当内存不足以容纳新数据时对键空间中所有的key进行lru
  • allkeys-random:从数据集中任意选择数据淘汰
  • no-eviction(默认策略):禁止驱逐数据,也就是说当内存不足以容纳新写入数据时,新写入操作会报错。
  • volatile-lfu(least frequently used):从已设置过期时间的数据集中挑选最不经常使用的数据淘汰
  • allkeys-lfu(least frequently used):当内存不足以容纳新写入数据时,在键空间中,移除最不经常使用的 key

Redis持久化

  • 1.重启机器后重用数据、2. 防止系统故障将数据备份

    快照持久化(Snapshotting,RDB,默认持久化方式)

    创建快照,建立内存数据在某个时间点上的副本。可以对快照进行备份,可以将快照复制到其它服务器上(主从复制、读写分离)

    RDB的触发机制

  • save命令,是同步命令,会占用Redis主进程
  • bgsave命令,执行一个异步操作,使用fork()生成子进程将数据保存到硬盘上。主进程调用fork()时也会阻塞,但是比让主进程亲自创建RDB快多了。缺点在于消耗内存。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    # Redis.conf
    save 900 100 # 在900秒后,如果至少有100个key发生变化,Redis就自动触发BGSAVE命令创建快照。

    # RDB持久化文件名
    dbfilename dump-<port>.rdb

    # 数据持久化文件存储目录
    dir /var/lib/redis

    # bgsave发生错误时是否停止写入,通常为yes
    stop-writes-on-bgsave-error yes

    # rdb文件是否使用压缩格式
    rdbcompression yes

    # 是否对rdb文件进行校验和检验,通常为yes
    rdbchecksum yes

    创建RDB的机制:写时复制

    当 Redis 需要保存 dump.rdb 文件时, 服务器执行以下操作:
  1. Redis 调用forks。同时拥有父进程和子进程。
  2. 子进程将数据集写入到一个临时 RDB 文件中。
  3. 当子进程完成对新 RDB 文件的写入时,Redis 用新 RDB 文件替换原来的 RDB 文件,并删除旧的 RDB 文件。

RDB的优缺点

  • 优点
    • 紧凑、适合数据集的备份、用RDB进行备份恢复比AOF快、可以隔段时间进行备份,创建符合不同要求的数据还原点。
    • 方便传送到远端机器,适合容灾恢复。
    • 使用BGSAVE的方式,Redis主进程除了fork不需要任何其他操作。
  • 缺点
    • 频繁进行fork操作也会影响性能。
    • 不可控,存在数据丢失风险。将数据集进行全量备份是耗时的工作,如果Redis宕机,只能保存上一个检查点记录的数据。

AOF持久化(Append-Only File)

比起RDB持久化,AOF持久化的实时性更好。

1
2
# Redis.conf
appendonly yes

每执行一条更改Redis中数据的命令,Redis就将命令写入硬盘中的AOF文件,保存位置和RDB文件的位置相同。

AOF持久化的不同方式

通过配置文件设置Redis隔多长时间fsync到磁盘一次。

1
2
3
4
# Redis.conf
appendfsync always #每次有数据修改发生时都会写入AOF文件,好处是不丢数据,但IO开销大,这样会严重降低Redis的速度
appendfsync everysec #每秒钟同步一次,显示地将多个写命令同步到硬盘,但可能丢失1秒数据
appendfsync no #让操作系统决定何时进行同步

AOF重写

因为 AOF 的运作方式是不断地将命令追加到文件的末尾, 所以随着写入命令的不断增加, AOF 文件的体积也会变得越来越大。
AOF 重写可以产生一个新的 AOF 文件,这个新的 AOF 文件和原有的 AOF 文件所保存的数据库状态一样,但体积更小。可以减少磁盘占用量,加速数据恢复。
AOF 重写由 Redis 自行触发,bgrewriteaof 仅仅用于手动触发重写操作。在执行 BGREWRITEAOF 命令时,Redis 服务器会维护一个 AOF 重写缓冲区,该缓冲区会在子进程创建新 AOF 文件期间,记录服务器执行的所有写命令。当子进程完成创建新 AOF 文件的工作之后,服务器会将重写缓冲区中的所有内容追加到新 AOF 文件的末尾,使得新旧两个 AOF 文件所保存的数据库状态一致。最后,服务器用新的 AOF 文件替换旧的 AOF 文件,以此来完成 AOF 文件重写操作

AOF的优缺点

  • 优点
    • AOF有序地保存了对数据库执行的所有写入操作,可以对文件进行解析。如果出现了错误的操作,可以对AOF文件解析后通过编辑去除错误的操作,进行数据恢复。
  • 缺点
    • 一般AOF文件的体积大于RDB文件
    • 使用的fsync策略不当的时候可能会降低速度

Redis事务

事务的四大特性:

  • 原子性(Atomicity): 事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用;Redis事务并不支持原子性,虽然Redis事务的多个操作是原子的,事务机制也可以保证多个命令的正确执行不被打断,但是Redis事务不支持回滚。
    • DISCARD命令:清除之前放入队列的命令,然后恢复连接状态。
    • WATCH命令: 将特定的key设置为受监控的状态,如果该key被修改,之后的事务调用EXEC时队列中的所有命令均不会执行。调用UNWATCH命令可以接触之前的WATCH操作。
    • Redis命令语法错误:后续的命令即使是正确地添加到命令队列中,所有的命令(命令队列中的所有命令,包括出错命令之前和之后的命令)都不会被执行。
    • Redis命令运行时错误(Redis似乎并不会在加入命令队列时对命令进行类型匹配检查):
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      127.0.0.1:6379> multi
      OK
      127.0.0.1:6379> set test1 value1-3
      QUEUED
      127.0.0.1:6379> lpush test2 value2-3
      QUEUED
      127.0.0.1:6379> set test3 value3-3
      QUEUED
      127.0.0.1:6379> exec
      1) OK
      2) (error) WRONGTYPE Operation against a key holding the wrong kind of value
      3) OK
      127.0.0.1:6379> get test1
      "value1-3"
      127.0.0.1:6379> get test3
      "value3-3"
      可以看到,运行时错误只会影响出错的命令执行,其它命令都会正常执行,而不会回滚。
  • 隔离性:一个用户的事务不被其他事务干扰。Redis是单线程的,命令在主线程上串行执行,因此Redis事务可以保证隔离性。
  • 持久性:一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响,除非下一个事务覆盖了这次事务的修改。
  • 一致性(Consistency): 执行事务前后,数据保持一致,多个事务对同一个数据读取的结果是相同的。

Redis缓存性能问题

  • key的一种设计:表名:列名:主键名:主键值

    缓存穿透

  • 问题:缓存穿透说简单点就是大量请求的 key 根本不存在于缓存中,进而需要到数据库上进行查找,同时数据库中也不存在相应的数据。大量的无效请求会落到数据库上造成数据库压力过大。
  • 解决
    • 做好参数校验,在查询前过滤错误的、不合法的参数,向客户端返回错误。校验可以在后端接口层完成,也可以在前端完成。
    • 缓存无效key:在缓存中放置一些无效key,通过这些key进行查询时让缓存返回无意义值或者null。可以防范使用重复无效key的攻击,但是对于大量变化的无效key会占用内存,因此要为无效key设置较短的过期时间。
    • 布隆过滤器:把所有可能存在的请求的值都存放在布隆过滤器中,当用户请求过来,先判断用户发来的请求的值是否存在于布隆过滤器中。不存在的话,直接返回请求参数错误信息给客户端。
      • 场景:有限的存储空间存储大量需要查找/匹配的元素,提供判断一个元素是否在集合中的功能,允许较低的查找误差。
      • 大致的做法:使用k个hash函数,对于每个要加入的元素,计算出k个hash值。准备一个长度为m的bitmap,然后令这k个hash值对m取模,得到k个在[0, m - 1]范围内的下标,将bitmap中这k个下标对应位置的bit设置为1。对于一个待查找的元素,用同样的k个hash函数计算出k个hash值并对m取模得到k个下标,如果这k个下标对应的位都是1,则说明这个元素可能在集合中,否则,说明这个元素一定不在集合中。布隆过滤器有将不存在的元素判断为在集合中的可能,但是一定不会将存在于集合中的元素判断为不在集合中。
      • 缺点:有误判率、删除困难。降低概率:增加bitmap的大小、调整hash函数

缓存击穿

  • 问题:读取到了缓存中不存在但是数据库中存在的数据,需要向数据库发送请求,并发用户多的时候去数据库读取数据。
  • 解决:
    • 设置热点数据永不过期。
      • 要对数据库发送请求的情况下,加入互斥锁(信号量?),保证只有一定量的请求能够到达数据库。在缓存重生效之前避免数据库崩溃。

缓存雪崩

  • 问题:缓存在同一时间大面积的失效/Redis突然不可用,后面的请求都直接落到了数据库上,造成数据库短时间内承受大量请求。
  • 解决
    • 针对Redis服务不可用的情况:
      • 采用Redis集群
      • 限流(?
    • 针对热点缓存失效的情况:
      • 为不同缓存设置不同的(随机的)生效时间
      • 要对数据库发送请求的情况下,加入互斥锁(信号量?),保证只有一定量的请求能够到达数据库。在缓存重生效之前避免数据库崩溃。
      • 对热点数据不设置过期时间

Redis实现分布式锁

怎么实现分布式锁

  • mysql、zookeeper、redis
  • Redlock算法 -> redisson(Redlock算法的Java实现) lock/unlock
  • 解锁问题:加了锁一定要解锁 -> 怎么删锁?
    • 代码可能出现异常:finally中解锁
    • 整个节点直接炸了,或者网给掐了:过期策略
      • 取得锁后直接为锁添加过期时间:这个操作必须是原子的(否则刚加完锁还没设置过期时间节点直接被扬了,这把锁就不会过期了)。然而redis的setnx命令本身并没有提供直接的timeout参数。
    • 节点从宕机状态中恢复/网络恢复,由于过期策略的存在节点持有的锁有可能已经过期。
      • 该节点会进行一些不持有锁时不应该继续进行的操作
      • 该节点可能会进行锁释放,在redis上删除了别的节点的锁
      • 解决:删除锁时只能删除自己的锁,先尝试获得锁进行判断
        • 这个判断-解锁的操作必须是原子的:否则可能出现在锁过期前一刻判断出锁属于自己,而其它节点在锁过期后获得锁,当前节点判断完成后误删其它节点的锁的情况
        • Redis事务?MULTI EXEC WATCH
        • lua脚本?(Jedis,eval()
      • 锁的过期时间应该确保大于业务执行时间 -> 续期
  • Redis集群分布式锁:主从复制
    • Redis是AP型的(??):有可能存在Redis异步复制造成的锁丢失
      • Master写,Slave读。Redis先直接返回成功信息,再将锁同步到Slave节点。但是复制成功之前Master可能会故障,Master进行降级 -> 锁丢失(似乎并没有什么好的办法,这是Redis方案的固有缺陷)
    • 加锁操作
    • 对比ZK:ZK属于CP型,实行同步复制,先将数据从Master复制到从节点,再返回对外成功消息。此时master故障,新master会保留最新的锁信息。但是这会牺牲可用性(A):对网络延迟敏感,速度取决于最慢速度。

Safety and Liveness

  • Safety: 互斥
  • Liveness
    • A:不会死锁,最终总能获得锁
    • B: 容错性,只要多数的Redis节点还能工作,client总能获取和释放锁。

单实例下的Redis分布式锁实现

加锁

使用SETNX命令:set key value [EX seconds] [PX milliseconds] [NX|XX]。指定过期时间。

  • SET resource_name a_random_value NX PX 30000
  • NX: 只有key不存在的时候才会SET。PX: 以毫秒为单位指定key的过期时间。
  • value: 对于所有的锁请求,这个值必须是唯一的(防止误删)

解锁

  • 无法以单一命令实现判断和解锁,只能使用lua脚本。Redis对Lua脚本的执行具有原子性。
    1
    2
    3
    4
    5
    if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
    else
    return 0
    end
  • 使用EVAL指令可以执行匿名lua脚本:eval [脚本] [key的数量] [arg1, arg2, ...]
  • script load命令:将脚本传送到Redis服务器,Redis服务器返回通过sha1算法得到的哈希值
  • evalsha [哈希值] [key数量] [arg1, arg2, ...]:通过哈希值执行命令

主从模式下用单实例方法有什么问题

  • Redis使用异步复制实现主从复制。
    • A在master上写入了一个锁
    • master在将锁复制到slave之前就crash了
    • salve选出了新的master
    • 新的master并没有A写入的锁,此时B可以在新的master上写入锁,因为key并不存在

秒杀设计

  • 单机情况下的多线程同步只需要使用Java提供的同步机制(synchronized, ReentrantLock…)
  • 多机部署:Nginx负载均衡
  • 多机部署的情况下Java提供的锁机制不够用了 -> 分布式锁(用上Redis提供的分布式锁也没必要使用Java提供的同步机制了

缓存和数据库的数据一致性(读写策略)

 旁路缓存模式(Cache Aside Pattern)

  • 适用场景:需要同时维系DB和cache,读操作较多
  • 写步骤
    • 先更新DB
    • 然后删除cache
  • 读步骤
    • 从cache中读数据,读到就直接返回
    • 如果从cache中读不到,就从DB中读取数据返回
    • 再将数据放到cache中

      常见问题

  • 旁路缓存模式下数据一致性问题是无法避免的(无法完全保证DB和缓存的一致性。
  • 可以先删cache再更新DB吗
    • 不可以。因为这样可能会造成数据库(DB)和缓存(Cache)数据不一致的问题。
    • 例如,两个对同一数据的并发操作,一个是更新操作,另一个是查询操作。更新操作删除缓存后,查询操作没有命中缓存,然后查询操作到数据库中读出了旧数据并将旧数据写入缓存,最后更新操作更新了数据库。此时缓存中的旧数据成为了脏数据(和当前数据库内的值不一致了),如果没有过期的话很可能一直脏下去。
  • 我就是要先删缓存再更新DB有什么解决办法吗?
    • 延时双删策略:先删除cache,然后更新数据库,隔一定时间后再次删除cache,在这段时间内造成的缓存脏数据就会被删除。但是这个间隔时间需要自行进行评估。
  • 这种方式就没有问题吗
    • 理论上也会出现导致数据不一致的case:一个是读操作,但是没有命中缓存,然后就到数据库中取数据,此时来了一个写操作,写完数据库后,让缓存失效,然后,之前的那个读操作再把老的数据放进去,所以,会造成脏数据。不过,实际上出现的概率可能非常低,因为这个条件需要发生在读缓存时缓存失效,而且并发着有一个写操作。而实际上数据库的写操作会比读操作慢得多,而且还要锁表,而读操作必需在写操作前进入数据库操作,而又要晚于写操作更新缓存,所有的这些条件都具备的概率基本并不大(至少比先删cache再更新数据库要小得多)。
  • 写操作的时候为什么要删除cache,而不是写cache(?
    • 写操作频繁的时候删除cache确实会影响缓存命中率而导致性能下降。选择更新cache的方式需要考虑安全问题。并发的写操作也可能导致脏数据。
    • 例如:写操作A更新了数据库 -> 写操作B更新了数据库 -> 写操作B更新了缓存 -> 写操作A更新了缓存
  • 如果更新数据库成功而删除缓存失败?
    • 增加cache更新重试机制:如果cache服务当前不可用,就存入队列中等缓存服务可用的时候再将缓存中的key删除。

读写穿透模式(Read/Write Through Pattern)

主从、集群、哨兵

主从

  • conf文件中加入slave的IP和端口
  • 读写分离,master可读写、slave一般只读
  • 将rdb传送给从数据库
  • 异步复制:master执行完写操作立级将结果返回给客户端,再将命令发送给slave

哨兵

集群

  • 主从是全部都放在一个库里

  • [x] OS里进程持有的”资源”具体有哪些?
  • [x] 进程切换需要完成哪些工作?(为什么说进程切换开销大?
  • [x] 线程切换需要完成哪些工作?

操作系统基础概念

  • 管理计算机软硬件资源的程序
  • 屏蔽了底层硬件的复杂性,对裸机进行了扩展
  • 操作系统内核是OS的核心部分,负责进程调度(应用程序管理),内存管理,外部设备(硬件设备)管理,文件系统管理

    系统调用

  • 用户态:用户态运行的进程可以直接读取用户程序的数据。
  • 内核态:内核态运行的进程可以不受限制地访问计算机的任何资源。
  • 用户的程序中凡是请求系统资源的操作都需要通过系统调用的方式向操作系统提出服务请求,由操作系统代为完成这些操作。

  • 用途:记录当前任务的函数调用关系以便维护函数返回的跳转,保存当前任务的执行状态以完成任务切换。

用户态栈

  • 进程栈
  • 线程栈

内核态栈

  • 进程内核栈:进程通过系统调用陷入内核之后,有一个单独内核空间的栈,用以支持内核函数的调用。
    • 在内核态中可能会发生进程切换(例如开始IO)。不同线程可以同时进入内核态,因此要为不同的进程维护不同的内核栈完成来为不同的进程维护调用关系。
  • 中断栈:?

进程与线程

进程与线程的区别

  • 进程:是程序的一次运行,是对运行中的程序的描述。
  • 线程:也是对运行中的程序的描述,但是粒度小于进程。
  • 进程是资源分配的最小单位,线程是CPU执行时间分配的最小单位。
  • 同一进程下的多个线程可以共享进程的资源。
  • 资源:地址空间,快表,文件描述符(fd)

进程持有的资源究竟是什么

  • 进程映像:进程执行的上下文环境。是序列化的一个进程运行状态,主要用于进程调度和恢复。
    • 进程控制块(PCB)
      • 进程标识符
      • CPU通用寄存器的内容,例如PSW、PC
      • 进程调度的状态(ready、suspended、…)、调度优先级信息、进程等待的事件
      • 进程结构信息:子进程标识符…
      • 内存管理信息:页表、段表…
      • I/O状态信息:被分配的设备、I/O缓冲区地址…
    • 进程执行的程序(指令)
    • 进程执行时所用的数据
    • 进程栈:用户栈和内核栈
  • 内存区域(虚拟内存空间):包括了可执行代码、数据段、调用栈、堆内存
  • 被分配给进程的资源描述符,例如文件描述符fd
  • 处理器状态(处理器执行上下文),例如寄存器的内容

进程控制块与线程控制块?(Thread Control Block, TCB)

  • 线程控制块有与PCB相似的字段:
    • 寄存器值
    • 栈指针:记录调用该函数的上一个函数的返回地址。
    • 程序计数器
    • 调度状态
  • 线程控制块(可能)特有的内容
    • 线程标识符(ID)
    • 指向所属进程(控制块)的指针(??)

进程切换(上下文切换)的开销

  • 切换虚拟地址空间(加载下一个线程的页表):
    • 有寄存器保存页表的起始地址和页表的大小,进程切换时需要完成这部分内容的切换。
  • 切换内核栈
  • 切换硬件上下文。最显著的开销是保存寄存器中的内容

线程切换的开销

  • 不需要切换虚拟地址空间,其余与线程一致。

为什么进程切换比线程切换的开销大

  • 进程切换涉及到虚拟地址空间的切换,而线程切换不涉及这个切换,因为同一进程的不同线程使用相同的虚拟地址空间和地址映射规则。将虚拟地址转换为物理地址需要查找页表,而到内存中查找页表是较慢的过程,因此通常使用快表来加速页表查询。当进行进程切换后页表也要切换,此时快表的内容就会失效,地址转换就会变慢。

进程的7状态模型

创建、就绪、运行、阻塞、就绪挂起、阻塞挂起、结束

进程间通信方式

  • 各个进程有不同的地址空间,一个进程的内存空间对于另一个内存而言是无法访问的。因此进程间要交换数据需要通过内核,进程1将数据从用户空间拷贝到内核空间,然后进程2再从内核空间将数据读取走。

    管道/匿名管道

  • 管道中数据只能向一个方向流动,需要双方交换数据时需要建立两个管道。
  • 只能用于父子进程或者兄弟进程之间进行通信
  • 单独构成一种独立的文件系统。管道对于管道两端的进程而言是一个文件,但它并不是普通的文件,不属于某种文件系统,只存在于内存中。
  • 本质是一个内核缓冲区,进程以FIFO的方式在这个缓冲区中存取数据。
  • 缓冲区的大小是有限的,需要进行生产/消费同步
  • 传送的是无格式字节流,通信双方需要约定

    具名管道

  • 与匿名管道相比,提供了一个路径名与之关联,以具名管道文件的形式存在于系统中,因此不相关的进程只要可以访问该路径就能通过有名管道相互通信。
  • 严格遵循FIFO。
  • 有名管道的名字存放于文件系统,内容存放在内存中。

    信号

  • Linux系统中进程通信的机制,信号可以在任何时候发给某一进程而不需要知道其状态。(相比之下,管道在创建/发送的时候可能需要对方进程的存在,否则就会阻塞)
  • 如果该进程当前并未处于执行状态,则该信号就有内核保存起来,直到该进程回复执行并传递给它为止。
  • 如果一个信号被进程设置为阻塞,则该信号的传递被延迟,直到其阻塞被取消是才被传递给进程。
  • 是在软件层次上对中断的一种模拟,是一种异步通信方式。

    消息队列

  • 消息的链表,存在于内存中,由消息队列标识符标识。
  • 允许一个或多个进程向他写入或读取消息
  • 另外与管道不同的是,消息队列在某个进程往一个队列写入消息之前,并不需要另外某个进程在该队列上等待消息的到达。
  • 消息队列可以随机查询,可以按消息的类型读取

    共享内存

  • 使得多个进程可以可以直接读写同一块内存空间,是最快的可用IPC形式。是针对其他通信机制运行效率较低而设计的。
  • 为了在多个进程间交换信息,内核专门留出了一块内存区,可以由需要访问的进程将其映射到自己的私有地址空间。进程就可以直接读写这一块内存而不需要进行数据的拷贝,从而大大提高效率。
  • 需要同步机制(如信号量)达到进程间同步和互斥

    信号量

  • 信号量是一个计数器,用于多进程对共享数据的访问,信号量的意图在于进程间同步。
  • 对信号量值的测试和减1操作应该是原子的

    Socket

  • socket是一种通信机制,使用这种机制,进程通信可以在单机上运行,也可以跨网络进行,即通过网络让不同计算机上的进程进行通信。

线程间同步方式

互斥量

  • 用互斥对象机制,只有拥有互斥对象的线程才有访问公共资源的权限。因为互斥对象只有一个,所以可以保证公共资源不会被多个线程同时访问。
  • 信号量:它允许同一时刻多个线程访问同一资源,但是需要控制同一时刻访问此资源的最大线程数量。互斥量是一种特殊的信号量(最多允许一个线程访问资源)
  • 事件(Event):通过通知操作(wait/notify,await/signal)来保持线程同步。

进程调度算法

  • FCFS
  • SJF
  • 时间片轮转RR
  • 多级反馈队列:除了最低级的队列都使用FCFS,高优先级的队列中没有进程时才会选取低优先级队列中的进程,得到时间片并使用完毕的线程需要降到低一级的优先级队列。最低级的队列使用时间片轮转算法进行调度。
  • 优先级调度

内存管理

  • 内存的分配与回收、内存地址映射:将逻辑地址转换为物理地址。

    内存管理的方式

  • 为每个进程分配单块连续的内存空间。-> 内存碎片
  • 页式内存管理:将主存分为大小相等且固定的页,按页为进程分配内存,内存空间可以是不连续的。减少了内存碎片,通过页表完成逻辑地址到物理地址的映射。
    • 逻辑地址:页号+业内位移
    • 页表:页号-块号
    • 页表寄存器:页表始地址、页表长度
  • 段式内存管理:将分配给进程的内存空间进行分段,每段赋予逻辑信息(主程序段、子程序段、数据段、(堆)栈段),即按程序本身的逻辑对程序所用的内存空间进行分段(便于内存共享,某些进程所用的一些内存空间可能内容是一样的,例如同一个程序多次运行产生的多个进程)。通过段表完成逻辑地址到物理地址的映射。
    • 逻辑地址:段号+段内位移
    • 段表:段始地址、段长度
    • 段表寄存器:段表始地址、段表长度
  • 段页式内存管理:先将主存进行分页,在此基础上定义段,每段包含若干页。
    • 等分内存:与页式存储管理对内存的分块一样
    • 作业或进程的地址空间与段式存储管理对作业地址空间的处理一样
    • 内存分配:以页为单位
    • 逻辑地址:段号、段内页号、页内位移 -> 物理地址:页框号、页内地址
      segmentPageMemory
    • 段表中的每一项都有自己的页表,记录了页表首地址和页表长度
    • 页表中记录对应的页号和页架号
    • 快表:段号、页号、页架号

页式内存管理中的快表

两个问题:

  • 地址转换要快
  • 地址空间大,要使用恰当的数据结构完成地址转换。
  • 快表:页表的一部分,放在cache中,命中时只需要一次访存即可完成内存访问。未命中时需要到内存中查询页表,至少需要两次才能完成内存访问。快表还可以利用程序的局部性原理,因此快表的性能很好。
  • 多级页表:避免把全部页表放进内存占用过多空间,不需要的页表可以置换到外存(硬盘)中。

页式内存管理和段式内存管理的区别和联系

  • 共同:
    • 都为了提高内存利用率,减少内存碎片
    • 都是离散存储的,进程使用的内存地址都可以不连续。
  • 区别:
    • 页的大小是固定的,由OS决定。段的大小取决于当前运行的程序。
    • 分页仅仅是为了满足OS更好地管理内存的需求。段通常是逻辑信息的单位,对用户程序进行分段可以满足用户分段装入/换出的需求。

逻辑地址和物理地址

  • 因为用户程序每次运行时被装入内存的位置有可能是不一样的,用户程序每次运行的进程可能都获得了不同的物理地址空间,因此用户程序声明地址时一般只使用统一的逻辑地址。物理地址就是真实物理内存的地址,是内存单元真正的地址。
  • 用户程序直接访问和修改真实内存地址可能会有意无意地破坏OS或者其它程序。
  • 使用统一的虚拟地址,用户编写程序时无需考虑程序被装入内存时的真实位置,不需要担心程序中编写的内存地址是否被其它程序所拥有,同时也不需要知道OS为进程分配的内存是否连续,内存管理的复杂性一定程度上可以通过使用逻辑地址对编写用户程序的程序员进行屏蔽。

虚拟内存

  • 内存与外存的速度与价格差距。
  • 通过 虚拟内存 可以让程序可以拥有超过系统物理内存大小的可用内存空间。另外,虚拟内存为每个进程提供了一个一致的、私有的地址空间,它让每个进程产生了一种自己在独享主存的错觉(每个进程拥有一片连续完整的内存空间)。这样会更加有效地管理内存并减少出错。
  • 理论支持:程序的局部性原理。
    • 时间局部性:如果程序中的某条指令一旦执行,不久以后该指令可能再次执行;如果某数据被访问过,不久以后该数据可能再次被访问。产生时间局部性的典型原因,是由于在程序中存在着大量的循环操作。
    • 空间局部性:空间局部性 :一旦程序访问了某个存储单元,在不久之后,其附近的存储单元也将被访问,即程序在一段时间内所访问的地址,可能集中在一定的范围之内,这是因为指令通常是顺序存放、顺序执行的,数据也一般是以向量、数组、表等形式簇聚存储的。
    • 基于局部性原理,在程序装入时,可以将程序的一部分装入内存,而将其他部分留在外存,就可以启动程序执行。由于外存往往比内存大很多,所以我们运行的软件的内存大小实际上是可以比计算机系统实际的内存大小大的。在程序执行过程中,当所访问的信息不在内存时,由操作系统将所需要的部分调入内存,然后继续执行程序。另一方面,操作系统将内存中暂时不使用的内容换到外存上,从而腾出空间存放将要调入内存的信息。这样,计算机好像为用户提供了一个比实际内存大的多的存储器——虚拟存储器。

虚拟内存技术的实现

  • 内存和外存(页表/段表、快表)
  • 缺页中断机构:如果需要执行的指令或需要的数据尚未在内存 -> 由处理器通知OS将页/段调入到内存,然后继续执行程序
  • 地址转换

    请求分页存储管理

    建立在分页管理之上,为了支持虚拟存储器功能而增加了请求调页功能和页面置换功能。请求分页是目前最常用的一种实现虚拟存储器的方法。请求分页存储管理系统中,在作业开始运行之前,仅装入当前要执行的部分页即可运行。假如在作业运行的过程中发现要访问的页面不在内存,则由处理器通知操作系统按照对应的页面置换算法将相应的页面调入到主存,同时操作系统也可以将暂时不用的页面置换到外存中。

    请求分段存储管理

    建立在分段存储管理之上,增加了请求调段功能、分段置换功能。请求分段储存管理方式就如同请求分页储存管理方式一样,在作业开始运行之前,仅装入当前要执行的部分段即可运行;在执行过程中,可使用请求调入中断动态装入要访问但又不在内存的程序段;当内存空间已满,而又需要装入新的段时,根据置换功能适当调出某个段,以便腾出空间而装入新的段。

    请求段页式存储管理

页面置换算法

  • OPT:无法实现,衡量标准
  • FIFO
  • LRU
  • LFU:为每个页维护使用计数

CPU寻址

CPU需要将虚拟地址转换为物理地址才能访问到真正的物理内存。完成这一地址转换的硬件是CPU的内存管理单元(MMU)。

并发/多任务

进程与线程

  • Linux下(其它OS不知道)的内核级线程本质是轻量级进程,多个轻量级进程被OS进行调度的时候就当成进程进行调度,创建线程就像创建进程一样需要创建“进程控制块”之类的数据结构让OS感知其存在,不过在这个数据结构中可以标识这个轻量级进程所属的“组”(所属的进程)
    • 于是在同一个“组”中进行轻量级进程的切换的时候可以节省一些操作。
      • 内存寻址所需要的段表/页表/各种表应该是不同地址空间不一样的,每个进程的逻辑地址到物理地址应该有不同的映射规则。如果切换前后的轻量级进程属于同一个进程,那就不用进行页表切换了。至于快表中的内容在进程切换的时候是直接丢弃还是放进PCB之类的数据结构不得而知,但是在轻量级线程之间切换应该也不用对快表进行过多的操作了。
      • (除了地址空间和代码段,暂时想不出还有什么轻量级进程之间可以共享的资源,打开的文件描述符集合是可以在轻量级进程之间共享的吗?感觉应该可以,那么这些文件描述符需要进行互斥访问吗。)
    • 但是有些操作是省不了的
      • 栈内存区切换,但是由于同一进程内地址空间是一样的,改个指针应该没什么开销?
      • “运行上下文”的保存和切换,包括一些进程运行时放置在寄存器里的数据,最典型的是程序计数器
  • 对于让应用级线程和用户级线程进行一一对应的程序(HotSpot VM似乎就是这样的),在用户态创建一个线程需要进行系统调用让OS创建一个轻量级进程。进行线程切换等于让OS进行轻量级进程的切换。

    协程

  • 协程的概念:在一个轻量级进程里创建多个用户级线程。要阻塞的操作(IO)尽量使用异步方式,不让整个轻量级进程被阻塞。在一个轻量级线程里维护协程共有的资源和协程私有的资源(可能存在的协程栈和协程计数器?)。
  • 更直观的理解:非抢占调度的用户级线程。在用户程序中,编程者更清楚何时应该发生运行上下文的切换(比如IO操作时),因此非抢占调度可以减少无意义的上下文切换次数(多个活跃线程互相抢占CPU时间)。
  • 主要的应用场景是IO操作密集的情况下。在Java中使用IO多路复用(NIO)一定程度上可以近似实现协程调度的效果。但是编写程序的用户需要自己维护程序运行的状态和上下文。如果语言层面上能够支持协程,可以以近似同步代码的方式编写包含(异步)IO操作的代码,代码的可读性更好,降低编写难度。
    • 对于一个有多步IO操作的业务逻辑,在其Handler中按照IO操作进行分割,并在Handler中保存状态。
    • 每当需要进行IO操作时,将当前执行状态保存,用状态标志变量表示当前业务逻辑的执行进度,将当前IO操作对应的事件连同当前Handler的实例注册到选择器中,然后当前worker线程即可退出。
    • 下一次选择器选中该IO操作对应的事件时,就可以从SelectionKey取出绑定过的Handler实例,而Handler实例中保存了执行状态,此时再将该Handler的处理方法提交到线程池中即可恢复业务的执行。
  • 用户程序需要维护协程调度器。
    • 要利用多核怎么办:多对多映射,创建多个轻量级进程,每个轻量级进程负责多个协程

Linux

文件系统

Inode

  • 保存文件元信息的数据结构:每一块的地址,文件所有者,创建时间,权限,大小
  • 索引可以是多级的,Inode中的地址存放的可能是次一级的Inode
  • Block:实际文件的内容。一个block只能包含一个文件的信息。

文件类型

  • 普通文件-
  • 目录文件d
  • 块设备文件b:随机读写,如磁盘
  • 字符设备文件c:字符流。/dev/null:所有内容被忽略
  • 管道文件p:一头流入另一头流出
  • 套接字s:socket

Some random shit

IO

  • 可以利用操作系统缓冲区。OS在内存的内核区域维护缓冲区,用户程序在进行IO操作的时候可以只将数据写入该缓冲区。然后再定期通过fsync等操作将缓冲区中的内容同步到硬盘上。
  • 也可以不利用缓冲区,在flag参数中设置O_DIRECT标志,让用户将内存中用户区域的数据直接写到外部设备。适用于用户程序自己维护缓冲区的情况。

  • [x] TCP状态图
  • [x] TIME_WAIT/CLOSE_WAIT大量出现是为什么,怎么解决
  • [x] TCP为什么慢
  • [ ] HTTP/2
  • [x] 能不能用UDP代替TCP(QUIC协议)

协议分层(TCP/IP协议栈)

  • 应用层(application-layer)的任务是通过应用进程间的交互来完成特定网络应用。应用层协议定义的是应用进程(进程:主机中正在运行的程序)间的通信和交互的规则。对于不同的网络应用需要不同的应用层协议。在互联网中应用层协议很多,如域名系统DNS,支持万维网应用的 HTTP协议,支持电子邮件的 SMTP协议等等。我们把应用层交互的数据单元称为报文。
  • 传输层(transport layer)的主要任务就是负责向两台主机进程之间的通信提供通用的数据传输服务。应用进程利用该服务传送应用层报文。
  • 运输层主要使用以下两种协议:
    • 传输控制协议 TCP(Transmission Control Protocol)—提供面向连接的,可靠的数据传输服务。
    • 用户数据协议 UDP(User Datagram Protocol)—提供无连接的,尽最大努力的数据传输服务(不保证数据传输的可靠性)。
  • 网络层/网际层:在 计算机网络中进行通信的两个计算机之间可能会经过很多个数据链路,也可能还要经过很多通信子网。网络层的任务就是选择合适的网间路由和交换结点, 确保数据及时传送。
  • 网络接口层(数据链路层/物理层):数据链路层:两台主机之间的数据传输,总是在一段一段的链路上传送的,这就需要使用专门的链路层的协议。 在两个相邻节点之间传送数据时,数据链路层将网络层交下来的 IP 数据报组装成帧,在两个相邻节点间的链路上传送帧。每一帧包括数据和必要的控制信息(如同步信息,地址信息,差错控制等)。物理层:实现相邻计算机节点之间比特流的透明传送,尽可能屏蔽掉具体传输介质和物理设备的差异。

TCP协议

  • 网络层只会向上提供简单灵活的,无连接的,尽最大努力交付的数据报服务。网络层不提供服务质量的承诺。不保证分组交付的时限所传送的分组可能出错,丢失,重复和失序。进程之间通信的可靠性由运输层负责

TCP状态转换

  • LISTEN: 等待远程TCP应用程序的连接建立请求
  • SYN_SENT: 发送连接请求(SYN)后等待来自远程端点的确认。
  • SYN_REVD: 当前端点已经接收到连接请求并发送确认。该端点正在等待最终确认(ACK),TCP第二次握手后服务端所处的状态。
  • ESTABLISHED: 客户端发出ACK后的状态,服务端接收到客户端ACK的状态,TCP连接完全建立。
  • FIN_WAIT_1: 发出FIN后,等待远程端点对FIN的ACK
  • FIN_WAIT_2: 收到远程端点对自己发出的FIN的ACK,等待来自远程TCP端点的连接中止请求(FIN)。
  • CLOSE_WAIT: 接受到远程端点发送的FIN,并发送ACK之后的状态
  • LAST_ACK: 对方向自己的连接已经关闭时,向对方发送FIN之后的状态
  • TIME_WAIT: 收到远程端点的FIN,向远程端点发送ACK,等待2MSL以确保远程端点收到ACK,随后关闭连接进入CLOSED状态
  • CLOSING: 发出FIN后,在等待远程端点对FIN的ACK时收到远程端点的FIN,进入该状态(同时关闭)。此时接收到远程端点的ACK会进入TIME_WAIT状态。
    TCPStateMachine

三次握手

过程

  • TCP的三次握手:
    1. 发起者 SYN seq = x
    2. 接受者收到SYN seq = x,发回ACK x + 1 SYN seq = y。此时接收者的TCP状态切换为SYN RECEIVED,创建一个子连接加入到SYN_RCVD队列(半连接队列)
    3. 发起者收到ACK x + 1 SYN seq = y,发回ACK y + 1 seq = x + 1
    4. 接受者收到ACK x + 1 seq = y + 1,将2中创建的子连接移动到ESTABLISHED队列(全连接队列,accept队列)
    5. 接受者端用户程序调用accept()时,将连接从ESTABLISHED队列取出

为什么需要三次握手

  • 总而言之,TCP双方建立连接时采用三次握手是为了感知双方的存在,同时确认双方的发送/接收能力是否正常。同时,还可以在这个阶段交换一些参数
  • 第一次握手:发起者发送SYN x,在本次握手无法确认任何事。而接受者收到SYN x可以确定发起者的发送能力正常,接受者的接收能力正常。
  • 第二次握手,接受者发送SYN y ACK x + 1,在本次接受者无法确认任何事,发起者收到SYN y ACK x + 1可以确定发起者的发送能力正常,发起者的接收能力正常,接收者的发送能力和接收能力都正常。此时发起者完成了对接受者的感知,而接受者尚未完成对发起者的感知。
  • 第三次握手,发起者发送ACK y + 1,接受者接收到ACK y + 1后可以确认发起者的接受能力正常,接受者的发送能力正常。至此接受者也完成了对发起者的感知。

为什么第二次握手需要设置SYN = 1

意义不同。接收端传回发送端所发送的ACK是为了告诉发送端端,接收到的信息确实就是所发送的信号了,这表明从发送端到服务端的通信是正常的。而回传SYN则是为了建立并确认从接收端到发送端的通信。

TCP vs UDP(TCP为什么慢)

  • TCP: 要连接,要释放,无广播。因为要确保可靠传输,需要有序传输,确认机制,流量控制(发送方和接收方的速度同步),拥塞控制(网络状态差时减少发送),增加了很多开销。
  • UDP:无连接,不需要确认,不提供可靠交付,即时性强

TCP如何保证可靠传输

  1. 应用数据被分割成 TCP 认为最适合发送的数据块。-> 粘包问题
  2. TCP 给发送的每一个包进行编号,接收方对数据包进行排序,把有序数据传送给应用层。(有序传输)
  3. 校验和: TCP 将保持它首部和数据的检验和。这是一个端到端的检验和,目的是检测数据在传输过程中的任何变化。如果收到段的检验和有差错,TCP 将丢弃这个报文段和不确认收到此报文段。
  4. TCP 的接收端会丢弃重复的数据。
  5. 流量控制: TCP 连接的每一方都有固定大小的缓冲空间,TCP的接收端只允许发送端发送接收端缓冲区能接纳的数据。当接收方来不及处理发送方的数据,能提示发送方降低发送的速率,防止包丢失。TCP 使用的流量控制协议是可变大小的滑动窗口协议。 (TCP 利用滑动窗口实现流量控制)
  6. 拥塞控制: 当网络拥塞时,减少数据的发送。
  7. ARQ协议(自动重传请求): 也是为了实现可靠传输的,它的基本原理就是每发完一个分组就停止发送,等待对方确认。在收到确认后再发下一个分组。
  8. 超时重传: 当 TCP 发出一个段后,它启动一个定时器,等待目的端确认收到这个报文段。如果不能及时收到一个确认,将重发这个报文段。

ARQ协议:确认机制

  • 在OSI模型的数据链路层和传输层都使用的错误纠正协议之一。通过确认和超时两个机制在不可靠的基础上实现可靠的信息传输。

    停止-等待ARQ

    每发完一个分组就停止发送等待对方确认(等待ACK的序号)。过了一段时间后没有得到ACK确认就重发,直到收到确认。
    优点是简单,缺点是信道利用率低,等待时间长。
  • 维护超时计时器,超时未确认则重传发送过的分组(自动重传ARQ)。如果收到重复分组则丢弃,但要再次发送确认。确认消息丢失或确认消息迟到都会导致重传,对重复的数据和重复的确认的处理是相同的,都是丢弃,重复数据会发送确认。

连续ARQ

  • 发送方维护一个发送窗口,发送窗口内的分组连续发送出去而不需要等待确认,接收方采用累计确认,确认按序到达的最后一个分组,表明在此之前的所有分组都正确地收到了。
  • 优点:信道利用率高
  • 缺点:有时接收方无法向发送方反映正确收到的所有分组的信息。发送方发送了 5条 消息,中间第三条丢失(3号),这时接收方只能对前两个发送确认。发送方无法知道后三个分组的下落,而只好把后三个全部重传一次。这也叫 Go-Back-N(回退 N),表示需要退回来重传已经发送过的 N 个消息。

TCP流量控制:滑动窗口

  • 流量控制:控制发送方的发送速率,保证接收方来得及接收。

TCP拥塞控制(4阶段)

在某段时间,若对网络中某一资源的需求超过了该资源所能提供的可用部分,网络的性能就要变坏。这种情况就叫拥塞。拥塞控制就是为了防止过多的数据注入到网络中,这样就可以使网络中的路由器或链路不致过载。拥塞控制是一个全局性的过程,涉及到所有的主机,所有的路由器,以及与降低网络传输性能有关的所有因素。(需要对比流量控制)
维护拥塞窗口,根据网络的拥塞程度动态变化,发送方将发送窗口调整为拥塞窗口和接收窗口中较小的一个。

  • 慢开始:慢开始算法的思路是当主机开始发送数据时,如果立即把大量数据字节注入到网络,那么可能会引起网络阻塞,因为现在还不知道网络的符合情况。经验表明,较好的方法是先探测一下,即由小到大逐渐增大发送窗口,也就是由小到大逐渐增大拥塞窗口数值。cwnd初始值为1,每收到一个ACK,就将拥塞窗口大小+1。这样每经过一个传播轮次,cwnd就会加倍。
  • 拥塞避免:慢开始阶段,窗口达到慢开始的阈值或发生丢包时,就会进入拥塞避免阶段,发送窗口立级减半。拥塞避免算法的思路是让拥塞窗口cwnd缓慢增大,即每收到一个ACK就把发送放的cwnd加1。此时cwnd会呈线性增长。
  • 快重传与快恢复(Fast Retransmit and Recovery,FRR):如果不使用快重传,数据包丢失时TCP将会使用定时器来要求传输暂停(等待ACK到来或超时重传),这段时间内没有新的或复制的数据包被发送。如果使用FRR,接收机接收到一个不按顺序的数据段,它会立即给发送机发送一个重复确认。如果发送机接收到三个重复确认,它会假定确认件指出的数据段丢失了,并立即重传这些丢失的数据段。有了 FRR,就不会因为重传时要求的暂停被耽误。
    • 在拥塞避免阶段,如果收到连续的3个重复的ACK,认为这个报文的下一个报文就丢失了,进入快重传阶段,立即重传丢失的数据段。进入快重传阶段
    • 此时对于接收端,要求接收端在收到第一个失序的报文段后立即发出重复确认,而不要等到自己要发数据时才进行捎带确认。
    • 快重传后进入快恢复阶段,将慢启动阈值修改为当前拥塞窗口值的一半,同时拥塞窗口值等于慢启动阈值,然后进入拥塞避免阶段。

四次挥手

过程

  • 客户端-发送一个 FIN,用来关闭客户端到服务器的数据传送
  • 服务器-收到这个 FIN,它发回一 个 ACK,确认序号为收到的序号加1 。和 SYN 一样,一个 FIN 将占用一个序号
  • 服务器-关闭与客户端的连接,发送一个FIN给客户端
  • 客户端-发回 ACK 报文确认,并将确认序号设置为收到序号加1。此时客户端会进入TIME-WAIT状态,等待2MSL(最长报文段寿命)
  • 服务端收到ACK报文确认后会直接关闭TCP连接,客户端等待2MSL后也会关闭TCP连接

为什么要等待2MSL

  • MSL(Maximum Segment Lifetime):最大报文段寿命。
  • 第一,防止客户端发送的最后一个ACK报文丢失。在这种情况下,服务端收不到客户端发送的最后一个ACK,会再次发送释放连接报文。
    • 其实这一目的并不十分重要,并且2MSL也并不是最保守的一个估计值。最保守的估计值应该是一直等待到对方的超时重传次数全部用完再加一个MSL。第一个目标虽然重要,但并不十分关键,因为既然已经到了关闭连接的最后一步,说明在这个TCP连接上的所有用户数据已经完成可靠传输,所以要不要完美的关闭这个连接其实已经不是那么关键了。
  • 第二,如果不等待2MSL(不存在TIME_WAIT)状态,假如双方关闭连接后又经过三次握手建立了一个新的连接,使用的IP地址和端口和这个先前的连接完全相同,并且原先的连接中还有数据报残存在网络中,这样残存的数据报有可能成功到达通信的某一方,通信的某一方有可能接收到上一个连接残存的数据。为了防止这一点,TCP定义了TIME_WAIT状态,让释放连接的一方的socket等待2MSL后再接受同一个socket(同一个四元组)建立连接的请求。这足以让两个方向上的旧数据都过期。
    • 回忆四次挥手的最后两次:
      1. TCP的一端发送FIN报文后如果收不到对端针对该FIN的ACK,则会反复多次重传FIN报文(应该回有一个超时时间
      2. 被动关闭(第二次发送FIN报文的一端)处于LAST-ACK状态在收到最后一个ACK之后不会发送任何报文,立即进入CLOSED状态。
      3. 主动关闭的一端在收到FIN报文后回复ACK并进入TIME-WAIT状态。
      4. 处于TIME_WAIT状态的一端在收到重传的FIN时会重新计时
    • 假设A刚对B发出的FIN发回ACK,进入了TIME_WAIT状态,而B正处于LAST_ACK状态。B在收到最后一个ACK之前会重传FIN直至超时。
      • 如果ACK在网络中丢失,B会一直重传FIN直到超时。如果B的FIN能被A接收,那么计时器重置,双方的状态都不变。如果B的FIN始终无法传达到A,那么A在经历2MSL后状态会变为CLOSED,B会重传FIN到超时,在此之前都处于LAST_ACK状态。处于LAST_ACK状态的B不会向A建立新的连接,自然也就没有化身连接的问题。
      • 如果ACK被B接收到,从A发送到B接收经历了时间t,则必有0 <= t <= MSL。最极端的情况是,当t = MSL时,B在收到ACK的瞬间重传了一次FIN,那么这个FIN需要被丢弃,让A再等一个MSL即可让这个FIN失效。因此A至少需要等2MSL。

为什么TCP的初始序列号(Sequence Number)需要是随机的

  • 为了防范针对TCP的攻击:IP欺骗
    • 首先回忆一下,TCP连接的双方都要生成一个初始序列号。
    • 对于一对相互信任的主机A和B,主机C如果知道了主机B的IP,在TCP初始序列号可预测的情况下,可以冒充主机B与主机A建立TCP连接
    • 具体流程: 主机C使用主机B的IP向主机A发送TCP SYN,主机A会将它的SYN + ACK发给主机B,主机B此时不会理会(因为不是它发起的连接)。此时主机C无从得知主机A发出的SYN + ACK的序列号,但是如果TCP初始序列号非随机(可预测),则主机C就可以直接构造出正确的序列号与主机A建立TCP连接。在建立之后主机A就会正常接收来自主机C的消息了。

TCP Backlog,netstat中的RECV-Q和SEND-Q

TCP的全连接队列和半连接队列

  • 半连接队列:SYN_RCVD队列
  • 全连接队列:accept队列

backlog参数会影响什么,怎么影响

  • backlog是系统调用listen中的一个参数,能够影响半连接队列和全连接队列的长度
  • 能够影响上述两个队列长度的还有/proc/sys/net/core/somaxconn, /proc/sys/net/ipv4/tcp_max_syn_backlog这两个参数
  • 全连接队列的长度上限为min(backlog, somaxconn)
  • 半连接队列的长度上限为roundup_pow_of_two(max(8, min(tcp_max_syn_backlog, min(backlog, somaxconn))) + 1),其中roundup_pow_of_two(n)是指大于等于n且最接近n的2的x次幂(返回2的x次幂的值)。
  • tcp_max_syn_backlog - INTEGER
    Maximal number of remembered connection requests, which have not
    received an acknowledgment from connecting client.
    The minimal value is 128 for low memory machines, and it will
    increase in proportion to the memory of machine.
    If server suffers from overload, try increasing this number.
    根据大意是指半连接队列的最大长度。
  • somaxconn - INTEGER
    Limit of socket listen() backlog, known in userspace as SOMAXCONN.
    Defaults to 128. See also tcp_max_syn_backlog for additional tuning
    for TCP sockets.
  • 那为什么实际计算队列长的时候那么奇怪呢(

netstat中的RECV-Q和SEND-Q

  • 连接建立(ESTABLISHED)时,RECV-Q指的是连接到该socket的用户程序还没有复制(到用户空间)的字节数,SEND-Q指的是远端主机还没有确认收到的字节数。
  • 在侦听状态中(LISTENING),RECV-Q指的是当前的SYN BACKLOG(since 2.6.18),SEND-Q指的是当前SYN BACKLOG的最大值。
  • 根据文档,SYN backlog就是半连接队列(

TCP粘包

  • TCP会对用户数据进行调整后封装发送。这样会导致用户多次发送的数据被封装在一个TCP报文中,或者用户一次发送的数据被拆分成多个TCP报文;或者先被发送的数据需要等待一段时间,才能跟后面被发送的数据一起组成报文被发送出去。因为TCP是面向流的协议,同时为了解决大量小报文的情况下包头比负载大导致传输性价比太低的问题,TCP会对用户数据进行重新调整。
  • 如果开发者需要解决数据被合并发送的问题,需要对应用层协议进行重新设计,例如让应用层协议数据以特定的标志开头,在应用层协议头表明该协议数据的长度,以便于在处理字节流时重新获取完整的包数据。
  • 如果开发者希望让数据立级发送而不是等待,可以设置TCP_NODELAY来尝试解决。

TIME_WAIT大量出现

确认

netstat -an | grep TIME_WAIT

可能的原因

  • TIME_WAIT只会出现在主动发起四次挥手的一方。原因之一可能是因为HTTP服务器没有设置keepalive,导致每发送一个响应就要断开连接。而每次断开连接都需要等待2MSL。
  • 还有谁用TCP连接?数据库…例如redis…MySQL…如果没有连接池,也可能大量出现这种状态

CLOSE_WAIT大量出现

可能的原因

  • CLOSE_WAIT出现在被动接受四次挥手的一方。具体出现在接受对方发送的FIN后发送出ACK之后,会转换到CLOSE_WAIT状态。如果一直保留在这个状态,说明当前端可能没有对连接进行close操作,导致一直保持半连接。

TCP SYN洪泛攻击(SYN Flood)

是什么

  • TCB(TCP传输控制块)是一个让OS感知TCP连接的数据结构。在半连接队列(SYN-RCVD队列,存在于内核)中,保存了已经收到SYN报文但是没有收到ACK报文的半连接。
  • 服务器在接受到TCP连接请求时会为这个半连接分配资源,用这些资源保存此次请求的一些信息,例如四元组(目的IP和端口,源IP和端口)和一些TCP参数。
  • backlog队列参数,限制TCB上限的数量。当该队列被占满时,服务器不再会响应新的请求,除非TCB能够被回收或从SYN-RCVD状态中被移除。
  • SYN Flood:发送大量的SYN数据报占满服务器的半连接队列。
    • 直接攻击:不伪装IP,不对SYN-ACK报文进行响应
    • IP地址欺骗:在报文头中填入其它的IP地址,这些主机不会响应SYN-ACK报文
    • 分布式攻击:使用网络中的多台主机进行SYN洪泛攻击。

表现为?

  • 在进入SYN_RCVD状态时,连接请求的接收方会向发起方发送SYN+ACK报文,并且有一个重传并等待超时的机制,因此SYN_RCVD状态会大量发生(netstat)?

解决方案

  • 适当增大半连接队列的最大容量。(backlog参数,linux系统中的backlogsomaxconn参数)
  • SYN Cookies
    • 目的:在不预分配资源的情况下,验证之后可能到达的ACK的有效性,保证这是一次完整的握手(要保证当前收到的ACK在之前的却发送过SYN,接受过从本服务器发出过的SYN + ACK),同时获得SYN报文中携带的TCP选项信息。
    • SYN Cookies让服务器在收到客户端的SYN报文时,不分配资源保存客户端信息,而是将这些信息保存在SYN + ACK的初始序列号和时间戳中。对正常的连接,在回复ACK时会将该序列号+1发送到服务器。服务器通过将该序列号-1即可还原出这些客户端信息,确认当前连接是经历三次握手建立的合法的连接。
    • Linux中的/proc/sys/net/ipv4/tcp_syncookies可以设置SYN Cookie的开关。
  • 设置防火墙/代理,当服务端响应SYN+ACK后代理或防火墙伪装客户端直接向服务端返回假的ACK,避免半连接队列过长。如果客户端能再发回ACK,说明这是一个合法的连接。如果代理等待一段时间后检测不到客户端发回的ACK,则向服务端发送RST释放TCP连接。

ARP

建立TCP连接与ARP的关系

应用接受用户提交的数据,触发TCP建立连接,TCP的第一个SYN报文通过connect函数到达IP层,IP层通过查询路由表:

  • 如果目的IP和自己在同一个网段:
    • 当IP层的ARP高速缓存表中存在目的IP对应的MAC地址时,则调用网络接口send函数(参数为IP Packet和目的MAC))将数据提交给网络接口,网络接口完成Ethernet Header + IP + CRC的封装,并发送出去;
    • 当IP层的ARP高速缓存表中不存在目的IP对应的MAC地址时,则IP层将TCP的SYN缓存下来,发送ARP广播请求目的IP的MAC,收到ARP应答之后,将应答之中的<IP地址,对应的MAC>对缓存在本地ARP高速缓存表中,然后完成TCP SYN的IP封装,调用网络接口send函数(参数为IP Packet和目的MAC))将数据提交给网络接口,网络接口完成Ethernet Header + IP + CRC的封装,并发送出去。
  • 如果目的IP地址和自己不在同一个网段,就需要将包发送给默认网关,这需要知道默认网关的MAC地址:
    • 当IP层的ARP高速缓存表中存在默认网关对应的MAC地址时,则调用网络接口send函数(参数为IP Packet和默认网关的MAC)将数据提交给网络接口,网络接口完成Ethernet Header + IP + CRC
    • 当IP层的ARP高速缓存表中不存在默认网关对应的MAC地址时,则IP层将TCP的SYN缓存下来,发送ARP广播请求默认网关的MAC,收到ARP应答之后,将应答之中的<默认网关地址,对应的MAC>对缓存在本地ARP高速缓存表中,然后完成TCP SYN的IP封装,调用网络接口send函数(参数为IP Packet和默认网关的MAC)将数据提交给网络接口,网络接口完成Ethernet Header + IP + CRC的封装,并发送出去。

ICMP

  • Internet控制消息协议,用于探测网络情况,主机是否可达等信息。

HTTP

输入URL -> 页面加载完成

总体

  1. DNS解析
  2. TCP连接
  3. 发送HTTP请求
  4. 服务器处理请求并返回HTTP响应报文
  5. 浏览器解析渲染页面
  6. 连接结束

DNS解析

  • 唯一标识是IP地址,但是IP地址不方便记忆,在用户友好和可用性中做权衡 -> 域名到IP地址的解析(DNS解析)
  • DNS解析:递归查询。
    • 使用UDP协议
    • (host文件?) -> 本地域名服务器 -> 根域名服务器(com, net, …) -> 顶级域名服务器 -> (主)域名服务器
  • DNS优化 -> DNS缓存
    • DNS存在多级缓存:浏览器缓存、系统缓存、路由器缓存、ISP(Internet Service Provider)服务器缓存、根域名服务器缓存、顶级域名服务器缓存、主域名服务器缓存
  • DNS负载均衡:让DNS返回一个合适的机器IP给用户。

TCP连接

HTTP请求

构建HTTP请求报文,通过TCP协议发送到服务器指定端口。HTTP -> 80,HTTPS -> 443

请求行

1
Method Request-URL HTTP-Version CRLF

报头

允许客户端和服务器之间传递HTTP报文时携带附加信息和关于(客户端)自身的信息。
常见请求报头

  • Cookie
  • With-Cridential

自定报头:一般以X-开头

请求正文

发送的数据。

HTTP响应

状态码

  • 1xx:指示信息–表示请求已接收,继续处理。

  • 2xx:成功–表示请求已被成功接收、理解、接受。

    • 200 OK
    • 202 Accepted
  • 3xx:重定向–要完成请求必须进行更进一步的操作。

    • 301 Moved Permanently:
    • 302 Moved Temporarily
    • 304 Not Modified:不包含消息体,客户端可以直接使用本地缓存的请求结果
  • 4xx:客户端错误–请求有语法错误或请求无法实现。

    • 400 Bad Request:语义有误、参数错误
    • 401 Unauthorized:需要进行用户验证
    • 403 Forbidden:拒绝执行,通常可以表示权限不足
    • 404 Not Found:指定的资源未找到
  • 5xx:服务器端错误–服务器未能实现合法的请求。

    • 500 Internal Server Error:一般是服务器源代码出错。

响应头

常见响应报头

  • expires:http1.0的缓存控制响应头,表示未来资源会过期的时间,用于实现强制缓存。过期前会在本地缓存数据库中读取信息,过期后则向服务器发送请求。
  • Cache-Control:HTTP1.1的缓存控制响应头,用于实现强制缓存(max-age=xxx)。no-cache使用协商缓存。no-store则禁用缓存
  • Last-Modified:http1.0的缓存控制响应头,用于实现协商缓存。第二次及之后的请求时浏览器会首先带上If-Modified-Since请求头去访问服务器,而服务器将其中携带的时间与资源修改时间匹配。若不一致,服务器返回新的资源并更新Last-Modified。若一致,则返回304状态码。
  • Etag:http1.1通过响应头的Etag字段(内容特征值)作为缓存标识。第一次请求时服务器将资源和Etag一并返回给浏览器。之后的请求时浏览器将Etag信息放到If-None-Match请求头去访问服务器。服务器收到请求后将服务器中的文件标识与浏览器发来的标识进行对比,如果不相同则返回新资源和新的Etag,否则返回304状态码。
  • Connection:设置为Keep-Alive可以告诉客户端本次HTTP请求结束后不需要关闭TCP连接,方便下次HTTP请求使用相同的TCP连接。

响应报文

返回的信息:HTML/CSS/JS/json…

HTTPS

  • HTTP是明文传输的。HTTPS为HTTP的内容进行加密:HTTP + SSL/TLS
  • 对称加密需要提前协商密钥,但是协商密钥的过程可能会被发现,密钥可能会泄露。
  • 使用非对称加密无法防范中间人攻击:非对称加密需要通信双方生成密钥对并且交换公钥,中间人在交换公钥的过程中可以截获通信一方的公钥,将自己的公钥发给另一方。这样,中间人可以用自己的私钥对通信双方的通信内容进行解密,同时生成假消息发送给通信双方。此时问题在于通信双方如何确认对方的身份,如何确定发送的消息没有经过中间人篡改。并且,非对称加密的性能消耗高,一直使用非对称加密会降低应用的性能表现。
  • 确定消息没有经过篡改:摘要算法(数字签名,单向哈希)
    • 甲方对消息明文使用单向哈希算法生成摘要,再使用自己的私钥对摘要进行加密,得到一个数字签名。
    • 乙方对消息明文使用相同的单向哈希算法生成摘要,再使用甲方的公钥对数字签名进行解密,得到又一个摘要。如果这两个摘要是相等的,说明接收到的消息没有受到中间人篡改。
    • 单向哈希算法可以进行协商。
    • 但是,如果中间人彻底冒充了通信双方,即甲乙双方持有的是中间人的公钥,中间人持有甲乙双方的公钥,使用摘要算法也无法阻止消息被窃取和篡改:甲方持有的是中间人的公钥,因此无论是使用自己的私钥进行加密,还是用获得的公钥(中间人的公钥)进行加密,中间人都可以完成对消息内容的解密。不仅如此,因为乙方持有的也是中间人的公钥,所以中间人可以对消息内容进行篡改,然后用相同的单向哈希算法和他自己的私钥伪造数字签名再发送给乙方。乙方无法判断它在和甲方还是中间人通信。
    • 还需要一种机制,证明通信双方没有被冒充,防止公钥被替换
  • 解决方案:证书中心(Certificate Authority,CA)
    • 让证书中心使用证书中心的私钥(保证了证书的来源是CA)对服务端的公钥和关于服务端的一些基本信息进行加密,生成数字证书。证书的内容包括证书颁发机构、服务端网址、用CA私钥加密后的服务端公钥,用CA私钥加密后的证书签名,证书签名是对服务端网址等服务端基本信息使用单向哈希算法生成的签名。
    • 在通信双方的公钥交换阶段,服务端直接返回证书,客户端收到证书后对证书的真伪进行验证。各大浏览器和OS已经维护了所有权威的证书机构的名称和公钥(至于非权威的,需要进行下载。。。比如12306),因此可以从本地找到对应的机构公钥,解密出证书签名。回想一下,这个证书签名是使用单向哈希算法对服务端的一些基本信息(比如服务端的域名)生成签名(摘要)得到的,因此客户端只需要使用同样的信息和同样的单向哈希算法计算出这个证书签名,并与解密得到的证书签名进行比对就可以确认证书的真伪了。
    • 客户端确认了公钥的来源,就可以用CA的公钥解密得到服务端的公钥,并使用服务端的公钥加密客户端生成的对称加密密钥,发送给服务端。
    • 最后,服务端得到了客户端生成的对称加密密钥,可以使用对称加密进行通信。
    • 在这个通信过程中,由于数字证书中的证书签名是使用CA的私钥进行加密的,中间人无法使用自己的相关信息生成一个假的证书签名,因为中间人没有CA的私钥。
      • 为什么需要证书签名?
        • 首先数字证书中的服务端网址是一个凭证:证明这个返回的数字证书确实是由客户端想要通信的服务端发回来的,公钥也的确是这个服务端的公钥
        • 那么怎么验证这个凭证的真伪?中间人偷偷把CA加密的服务端公钥换成自己的怎么办?

HTTP长连接

  • 在HTTP/1.0中默认使用短连接。也就是说,客户端和服务器每进行一次HTTP操作,就建立一次连接,任务结束就中断连接。当客户端浏览器访问的某个HTML或其他类型的Web页中包含有其他的Web资源(如JavaScript文件、图像文件、CSS文件等),每遇到这样一个Web资源,浏览器就会重新建立一个HTTP会话。
  • 而从HTTP/1.1起,默认使用长连接,用以保持连接特性。使用长连接的HTTP协议,会在响应头加入Connection: keep-alive
  • 在使用长连接的情况下,当一个网页打开完成后,客户端和服务器之间用于传输HTTP数据的TCP连接不会关闭,客户端再次访问这个服务器时,会继续使用这一条已经建立的连接。Keep-Alive不会永久保持连接,它有一个保持时间,可以在不同的服务器软件(如Apache)中设定这个时间。实现长连接需要客户端和服务端都支持长连接。HTTP协议的长连接和短连接,实质上是TCP协议的长连接和短连接。
  • TCP长连接:

    • 理论上,TCP连接可以一直保持下去。但是需要一种机制判断当前连接是否具有通信能力。
    • TCP keepalive 保活机制
      • 如果一段时间(tcp_keepalive_time)内某一连接不活跃,开启保活功能的一端会向对方发送一个保活探测报文。
      • 如果对方正常存活且连接有效,对端会对探测报文进行响应,发送端如果能收到报文则可以判断TCP连接正常,此时重置保活时间计数器。
      • 若由于网络原因或其他原因导致,发送端无法正常收到保活探测报文的响应。那么在一定探测时间间隔(tcp_keepalive_intvl)后,将继续发送保活探测报文。直到收到对端的响应,或者达到配置的探测循环次数上限(tcp_keepalive_probes)都没有收到对端响应,这时对端会被认为不可达,TCP连接随存在但已失效,需要将连接做中断处理。
  • HTTP长连接:

    • 开启keep-alive之后,服务器再响应后不会直接断开TCP连接,而是将TCP连接维持一段时间。在这段时间内,如果同一客户端再次发起HTTP请求,便可以复用此TCP连接向服务端发请求,并重置timeout时间计数器,再接下来一段时间还可以继续服用。如果一段时间后没有发送HTTP请求,则可以关闭TCP连接。
  • 为什么有了TCP keepalive还需要HTTP keep-alive?

    • 如果双方节点和网络不出问题,连接双方不主动释放连接的话,理论上TCP连接可以永远维持下去,而维护大量的TCP连接显然是非常消耗资源的。
    • 但是,什么时候维持连接是合适的,什么时候应该断开连接,TCP协议是不知道的,TCP只知道“能不能维持当前连接”,至于维持和断开连接的时机判断更取决于应用层,不同应用层协议对应的场景不同,可能会有不同的策略。

HTTP 1.0 vs 1.1

  1. HTTP1.0默认使用短连接,不复用TCP连接。HTTP/1.1默认使用长连接。有非流水线方式和流水线方式。流水线:客户端收到HTTP响应报文之前就可以发送新的请求报文。非流水线:一定要收到前一个响应才可以发送下一个请求。
  2. 新增错误状态响应码
  3. 缓存处理:更多可选的缓存头
  4. 带宽优化和网络连接的使用:range请求头,只请求资源的某个部分,返回码是206.

HTTP 2.0

  1. Server Push: 在clinet请求之前server就将资源发送给client。例如在clinet请求index.html时将js和css文件一并发送。

HTTP3.0/QUIC协议/能不能用UDP代替TCP

URI vs URL

  • URI:资源的唯一标识
  • URL:资源的唯一定位符,一种具体的URI,提供访问该资源的方式(即协议)

如何保存用户状态?

Cookie - Session

  • Session:在服务端记录用户状态。那么当一个连接到来时,服务器如何判断这个连接对应的是哪一个用户?-> 给客户端一个对应Session的Session ID,让客户端后续发送请求时携带这个Session ID。
  • 客户端要怎么保存这个Session ID -> 存放在Cookie中。
  • 不要再Cookie中保存敏感用户信息。

Some Random Shit

TCP的传输成功和失败是否对编程者可感知?

  • TCP编程接口的调用似乎只能让调用者知道”从用户缓冲区copy到内核缓冲区再copy到网络设备上是否成功”。对于这个数据报是否顺利到达似乎是不可知的。(比如是一次就发到并顺利收到Client的ACK,还是没发到Client/发到了Client但是Client的ACK丢了重传一万次直到传送成功/Timeout)。。
  • 个人猜想:对于后一种情况,触发超时后TCP连接会被关闭(或者重启的客户端会对服务器发出Reset报文让TCP连接关闭)
    • 关闭TCP连接不代表已经发出去的TCP报文段不会重传
  • 于是在应用层编程者需要额外加入一些机制保证应用层协议的可靠性
  • 例如HTTP,假如用户发送一个请求需要修改某些数据,HTTP服务器收到请求后先改数据后发送响应,万一此时网络出现故障,响应可能相当长一段时间无法到达客户端。客户端可能无法知道操作是否发送成功而导致发送重复请求,造成重复操作。
    • 可能的解决方案1:让HTTP服务器收到请求后向客户端发送消息告知已经收到请求,并要求客户端显式地发回一个响应。如果收不到这个响应则不进行操作(或者回滚已经执行的操作) -> 重复套娃,无穷尽也?
    • 可能的解决方案2:在应用层协议层面不进行处理,让使用应用层协议的应用程序通过某些机制保证操作的幂等(完全相同的请求发送多次与发送一次的效果等价,But how?)

内存区域

运行时数据区域

线程私有

程序计数器:

  • 程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成。每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。唯一一个不会出现OutOfMemoryError的内存区域。

    虚拟机栈

  • 生命周期和线程相同,描述的是 Java 方法执行的内存模型,每次方法调用的数据都是通过栈传递的。
  • 实际上,Java 虚拟机栈是由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息。
  • 局部变量表主要存放了编译期可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。
  • StackOverflowError: 若 Java 虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError错误。
  • OutOfMemoryError: 若 Java 虚拟机堆中没有空闲内存,并且垃圾回收器也无法提供更多内存的话。就会抛出OutOfMemoryError错误。

    本地方法栈

  • 拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。
  • 也会出现StackOverflowErrorOutOfMemoryError错误。

线程共享:

  • Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
  • 逃逸分析/栈上分配: 如果某些方法中的对象引用没有被返回,或者未被外面使用,对象可以直接在栈上分配内存。
  • 分代垃圾回收:
    • JDK < 7:新生代、老年代、方法区(HotSpot 永久代)
    • JDK 7: JDK1.7 字符串常量池被从方法区拿到了堆中, 这里没有提到运行时常量池,也就是说字符串常量池被单独拿到堆,运行时常量池剩下的东西还在方法区, 也就是hotspot中的永久代 。
    • JDK >= 8: 移除堆中的方法区(永久代)、将除了StringTable以外的运行时常量池内容移动到直接内存中的元空间(Metaspace)
    • Eden区、From Survivor区、To Survivor区
  • OutOfMemoryError: GC Overhead Limit Exceeded: JVM花太多时间执行垃圾回收并且只能回收很少垃圾
  • java.lang.OutOfMemoryError: Java heap space: 创建新对象时堆内存空间不足存放新创建的对象(和配置的内存大小有关,和本机物理内存无关)

    (逻辑上的)方法区

  • 它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
  • JDK >= 8: StringTable存放于堆内存,其余内容存放于直接内存(元空间)
  • 为什么要将永久代替换为元空间?
    • 整个永久代有一个 JVM 本身设置固定大小上限,无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。(当元空间溢出时会得到如下错误: java.lang.OutOfMemoryError: MetaSpace
    • 元空间里面存放的是类的元数据,这样加载多少类的元数据就不由MaxPermSize控制了, 而可以只由由系统的实际可用空间来控制(也可以用参数进行限制,默认为unlimited),这样能加载的类就更多了。
  • 运行时常量池:

直接内存(非运行时数据区的一部分)

  • 直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致OutOfMemoryError错误出现。
  • NIO(New Input/Output) 类,引入了一种基于通道(Channel) 与缓存区(Buffer) 的 I/O 方式,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据。
  • 不会受到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。

对象的创建过程

1.类加载检查

虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

2.分配内存

基本步骤

在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

内存分配

  1. 指针碰撞
    • 适用场景:堆内存规整、没有碎片
    • 原理:用过的内存全部整合到一边、没用过的放在另一边,中间有个分界值指针、只要向着没用过的内存方向将该指针移动内存大小位置即可
    • 对应的GC收集器:Serial、ParNew
  2. 空闲列表
    • 适用:堆内存不规整
    • 原理:JVM维护列表,列表记录哪些内存块可用,分配时找出足够大的内存块为对象分配并更新列表
    • GC收集器:CMS

      内存分配的线程安全问题

      分配内存的操作需要保证原子性。
  3. CAS:一种乐观锁的实现,冲突则重试
  4. TLAB:为每个线程预先在Eden区分配一部分内存,优先使用TLAB内存,TLAB内存不够用时再通过CAS+重试的方法进行内存分配。

3.初始化零值

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(默认值?)(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

4.设置对象头

初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

5.执行init方法

在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,<init> 方法还没有执行,所有的字段都还为零。所以一般来说,执行new指令之后会接着执行<init>方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

对象的内存布局

对象在内存中的布局:对象头、实例数据、对齐填充。
Hotspot 虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的运行时数据(哈希码、GC 分代年龄、锁状态标志等等),另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是那个类的实例。

实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。

对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。 因为 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节的倍数(1 倍或 2 倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

对象的访问定位(如何通过引用访问一个具体对象)

句柄

  • Java 堆中划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息;(一次间接地址)
  • 优点:移动对象不需要改变引用,只需要改变句柄池中到对象实例数据的指针。

    直接指针

  • 如果使用直接指针,Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象的地址。
  • 优点:速度快,少一次指针定位

String常量池

1
2
3
4
5
String str1 = "abcd";//先检查字符串常量池中有没有"abcd",如果字符串常量池中没有,则创建一个,然后 str1 指向字符串常量池中的对象,如果有,则直接将 str1 指向"abcd"";
String str2 = new String("abcd");//堆中创建一个新的对象,如果常量池中没有这个字符串常量,则也要创建一个
String str3 = new String("abcd");//堆中创建一个新的对象,注意new关键字一定会在堆上创建新的对象
System.out.println(str1==str2);//false
System.out.println(str2==str3);//false

垃圾回收

GC的三个任务

  • 确定回收目标(哪些是垃圾)
  • 确定回收时机
  • 怎么回收

堆内存的区域划分

新生代

存放新生对象,一般占1/3,由于频繁创建对象,会频繁触发MinorGC进行垃圾回收。

  • Eden区:新对象的出生地(如果新对象占用内存很大会直接分配到老年代)。Eden区内存不够的时候会触发MinorGC,对新生代进行一次垃圾回收
  • Survivor From:上一次GC的幸存者,作为这一次GC的被扫描者
  • Survivor To:保留一次MinorGC过程中的幸存者

MinorGC的过程(复制算法)

  1. 将Eden区和Survivor From区域中经历过GC而存活的对象复制到Survivor To区域,如果有对象的年龄到达了进入老年代的标准,则移动到老年代区。复制的同时将这些对象的年龄+1。如果Survivor To空间不足则直接移动到老年代
  2. 然后清空Eden区和Survivor From区
  3. 将Survivor From区和Survivor To区的角色互换,原本的Survivor To区会成为下一次GC的Survivor From区

老年代

主要存放生命周期长的内存对象。因为对象较为稳定所以Old GC不会频繁执行。一般情况下,进行Minor GC使得新生代对象需要移动到老年代,导致老年代空间不足时才触发。如果找不到足够大的连续空间分配给新创建的较大对象时也会提前触发一次Old GC。

Old GC的过程(标记-清除算法)

首先扫描一次所有老年代,标记出存活的对象,然后回收没有标记的对象。OldGC 的耗时比较长,因为要扫描再回收。OldGC 会产生内存碎片,为了减少内存损耗,我们一般需要进行合并或者标记出来方便下次直接分配。当老年代也满了装不下的时候,就会抛出OOM(Out of Memory)异常。

如何确定垃圾

引用计数法

引用和对象是有关联的。如果要操作对象则必须用引用进行。因此,很显然一个简单的办法是通过引用计数来判断一个对象是否可以回收。简单说,即一个对象如果有任何与之关联的引用,即引用计数为0,则说明对象不太可能再被用到,那么这个对象就是可回收对象。但是该方法难以处理循环引用的情况。

可达性分析

通过GC Roots对象为起点进行搜索,节点走过的路径为引用链。GC Roots就是一组必须活跃的引用。当一个对象到GC Roots没有任何引用链相连的话,证明此对象是不可用的。

  • 可以作为GC Roots的对象
    • 虚拟机栈中(栈帧中的本地变量表)的对象
    • 本地方法栈(Native 方法)中引用的对象
    • 方法区中类静态属性引用的对象
    • 方法区中的常量引用的对象(static final…)
    • 所有被同步锁持有的对象

垃圾回收算法

  • 标记-清除:内存碎片
  • 标记-整理
  • 标记-复制:实现简单效率高,但是内存可用空间减小。存活对象增多的时候复制开销大(因此适合对象生命周期不长的新生代)
  • 分代收集:新生代多数采取复制算法。老年代可能使用标记-整理

3种垃圾回收算法的比较

四种引用类型

  • 在程序设计中一般很少使用弱引用与虚引用,使用软引用的情况较多,这是因为软引用可以加速 JVM 对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生。

    强引用

    在Java 中最常见的就是强引用,把一个对象赋给一个引用变量,这个引用变量就是一个强引用。当一个对象被强引用变量引用时,它处于可达状态,它是不可能被垃圾回收机制回收的,即使该对象以后永远都不会被用到JVM也不会回收。因此强引用是造成Java 内存泄漏的主要原因之一。

软引用

软引用需要用SoftReference 类来实现,对于只有软引用的对象来说,当系统内存足够时它不会被回收,当系统内存空间不足时它会被回收。软引用通常用在对内存敏感的程序中。软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,JAVA 虚拟机就会把这个软引用加入到与之关联的引用队列中。应用场景:内存cache,性能不足的设备上的多任务(多应用、后台应用)。

弱引用

弱引用需要用WeakReference 类来实现,它比软引用的生存期更短,对于只有弱引用的对象来说,只要垃圾回收机制一运行,不管JVM 的内存空间是否足够,总会回收该对象占用的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。

  • WeakHashMap: 不使用的Key会被移除。更准确地说,不会阻止垃圾回收器将Key进行回收。

虚引用

虚引用需要PhantomReference 类来实现,它不能单独使用,必须和引用队列联合使用。虚引用与软引用和弱引用的一个区别在于: 虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。
设置虚引用关联的唯一目的就是在这个对象被收集器回收的时候收到一个系统通知,或者后续添加进一步的处理。允许finalize()方法在垃圾收集器将对象从内存中清除出去之前做必要的清理工作。对虚引用调用get()会返回null。
清理工作:例如使用DirectByteBuffer的时候回收堆外内存。

引用队列

对象在被GC后会被添加到引用队列中。

被判定为需要回收的对象的回收时机

要真正宣告一个对象死亡,至少要经历两次标记过程;可达性分析法中不可达的对象被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize 方法。当对象没有覆盖 finalize 方法,或 finalize 方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要执行。
被判定为需要执行的对象将会被放在一个队列中进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被真的回收。(在finalize方法上强行将引用挂到一个可达对象上可以抢救一个对象,但是下次GC若被判定为需要回收,则不会再次调用finalize方法)

  • finalize方法并不推荐使用。JVM不会保证等到finalize方法执行完成。

方法区的垃圾回收

  • 方法区(“永久代”)并不是不会进行垃圾回收。方法区GC的主要对象是废弃常量和无用的类。

    运行时常量池的垃圾回收

    假如在字符串常量池中存在字符串 “abc”,如果当前没有任何 String 对象引用该字符串常量的话,就说明常量 “abc” 就是废弃常量,如果这时发生内存回收的话而且有必要的话,”abc” 就会被系统清理出常量池了。

如何判断无用的类

  • 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
  • 加载该类的ClassLoader已经被回收。
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
    虚拟机可以对满足上述 3 个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样不使用了就会必然被回收。

垃圾收集器(重点是CMS和G1)

Serial

Serial(串行)收集器是最基本、历史最悠久的垃圾收集器了。大家看名字就知道这个收集器是一个单线程收集器了。它的 “单线程” 的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程( “Stop The World” ),直到它收集结束。

新生代采用标记-复制算法,老年代采用标记-整理算法。
GCSerial

ParNew

ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和 Serial 收集器完全一样。它是许多运行在 Server 模式下的虚拟机的首要选择,除了 Serial 收集器外,只有它能与 CMS 收集器(真正意义上的并发收集器,后面会介绍到)配合工作。

会暂停用户线程(Stop The World),适用与科学计算、大数据处理等弱交互场景。

新生代采用标记-复制算法,老年代采用标记-整理算法。

  • 并行(Parallel) :指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
  • 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行,可能会交替执行),用户程序在继续运行,而垃圾收集器运行在另一个 CPU 上。
    GCParNew

Parallel Scavenge

Parallel Scavenge 收集器也是使用标记-复制算法的多线程收集器,它看上去几乎和 ParNew 都一样。 那么它有什么特别之处呢?(Parallel Scavenge无法与CMS配合工作)
Parallel Scavenge 收集器关注点是吞吐量(高效率的利用 CPU)。CMS 等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。(降低总的停顿时间 vs 降低单次停顿需要的时间)所谓吞吐量就是 CPU 中用于运行用户代码的时间与 CPU 总消耗时间的比值。 Parallel Scavenge 收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如果对于收集器运作不太了解,手工优化存在困难的时候,使用 Parallel Scavenge 收集器配合自适应调节策略,把内存管理优化交给虚拟机去完成也是一个不错的选择。
此为JDK1.8的默认收集器。

新生代采用标记-复制算法,老年代采用标记-整理算法。

Serial Old

Serial 收集器的老年代版本,它同样是一个单线程收集器。它主要有两大用途:一种用途是在 JDK1.5 以及以前的版本中与 Parallel Scavenge 收集器搭配使用,另一种用途是作为 CMS 收集器的后备方案。

Parallel Old

Parallel Scavenge 收集器的老年代版本。使用多线程和“标记-整理”算法。在注重吞吐量以及 CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器。

CMS(Concurrent Mark Sweep,也是用于老年代的垃圾收集器)

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用。是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。是一种标记-清除算法的实现。

  • 工作过程
    • 初始标记: 暂停所有的其他线程,并记录下直接与 root 相连的对象,速度很快
    • 并发标记: 同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。
    • 重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录(由于用户程序的并发运行,一部分被标记为不可达的对象又可达了),这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短。
    • 并发清除: 开启用户线程,同时 GC 线程开始对未标记的区域做清扫。
    • 并发重置(?)
      GCCMS
  • 优点:并发收集、低停顿
  • 缺点:
    • 对CPU资源敏感
    • 无法处理浮动垃圾:并发标记过程中,由于用户线程继续运行,可能会产生新的垃圾(这种现象成为Mutation, Mutator Problems),这部分垃圾并没有被GC线程识别(标记成了活动对象,不会被回收),称为浮动垃圾。而重新标记阶段的作用只是修改并发标记获得的不可达对象,没有办法处理,只能等到下一次GC再进行。
    • 标记清除算法会产生内存碎片 -> CMS怎么解决内存碎片问题?
      1. 在应用访问量少的时候调用System.gc()System.gc()会对堆内存进行整理。
      2. 在应用启动完并完成所有初始化工作之后,主动调用System.gc(),可以将初始化的数据整理到一个连续的块中,以腾出更多连续内存空间给新生代晋升使用。
      3. 降低-XX:CMSInitiatingOccupancyFraction=NNN参数,让CMS提前开始并发收集。并发收集不会进行整理,但是会合并老年代中相邻的可用空间,让新生代有足够的连续空间可以用于晋升。该参数是并发收集启动的老年代内存占用率阈值,当老年代内存占用达到NNN(百分比)时,启动并发收集。但是频繁GC也会带来频繁的停顿,需要避免。
      4. (9以上已废弃)开启-XX:+UseCMSCompactAtFullColletion,并设置-XX:CMSFullGCBeforeCompaction=NNN,可以在NNN次Full GC之后进行标记整理。
      5. 触发Concurrent Mode Failure:用户线程向老年代请求分配的空间超过预留空间,后台线程的收集没有赶上应用线程的分配速度
        • 可能触发后备收集器Serial Old进行标记整理(Full GC)。
        • 可能触发CMS的foreground模式进行标记整理(第4点,9以上已经废弃)
        • 都会触发Stop The World

G1(Garbage-First,新生代、老年代都可以,from Java 8)

G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征

  • 特点
    • 并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 java 程序继续执行。
    • 分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念。
      • G1并没有将堆划分为连续的新生代(以及其中的Eden区,Survivor0区,Survivor1区)、老年代,而是将堆划分为若干个区域(Region)。
      • 这些Region的一部分包含新生代,负责新生代的GC仍然采用暂停所有应用线程的方式(Stop The World),将存活对象复制到老年代或者Survivor To空间。
      • 这些Region的一部分包含老年代,G1收集器通过将对象从一个区域复制到另一个区域完成了清理工作。这意味着在正常的处理过程中G1完成了(一部分)堆的压缩。解决了CMS的内存碎片问题。
      • G1中有一种特殊区域:Humongous区。之前的做法是将巨型对象分配到老年代,但是如果该对象生存时间短就会对GC产生负面影响。G1为此专门划分了Humongous区来存放巨型对象。G1会寻找一个或连续的H分区来存储巨型对象,有时候可能会启用Full GC。
    • 空间整合:与 CMS 的“标记-清理”算法不同,G1 从整体来看是基于“标记-整理”算法实现的收集器;从局部上来看是基于“标记-复制”算法实现的。不会产生内存碎片。
    • 可预测的停顿:这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内。
    • G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字 Garbage-First 的由来) 。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)。

      工作过程(类似于CMS

    • 初始标记(STW)
    • 并发标记
    • 最终标记(STW)
    • 筛选回收

      G1收集器的Young GC/Minor GC

      针对Eden进行收集,当Eden区空间耗尽会触发。
      G1YoungGC
      G1YoungGCAfter

      常见参数

  • -XX:+UseG1GC
  • -XX:G1HeapRegionSize=N: G1划分单个区域的大小
  • -XX:MaxGCPauseMillis=N: 最大GC停顿时间,JVM追求尽可能小于该值(对G1的STW时间进行预测
  • -XX:InitiatingHeapOccupancyPercent=N: 堆占用达到多少的时候触发GC
  • -XX:ConcGCThreads=N: 并发GC使用的线程数
  • -XX:G1ReservePercent=N: 作为空闲空间的预留内存百分比,降低目标空间的溢出风险

内存分配策略

1. 对象优先在 Eden 分配

大多数情况下,对象在新生代 Eden 上分配,当 Eden 空间不够时,发起 Minor GC

2. 大对象直接进入老年代

大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组。

经常出现大对象会提前触发垃圾收集以获取足够的连续空间分配给大对象。

-XX:PretenureSizeThreshold,大于此值的对象直接在老年代分配,避免在 Eden 和 Survivor 之间的大量内存复制。

3. 长期存活的对象进入老年代

为对象定义年龄计数器,对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄就增加 1 岁,增加到一定年龄则移动到老年代中。

-XX:MaxTenuringThreshold 用来定义年龄的阈值。

4. 动态对象年龄判定

虚拟机并不是永远要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄。

5. 空间分配担保

在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的。
如果不成立的话虚拟机会查看 HandlePromotionFailure 的值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC;如果小于,或者 HandlePromotionFailure 的值不允许冒险,那么就要进行一次 Full GC。

  • 新生代使用标记-复制算法,Survivor From区和Eden区存活的对象都需要复制到Survivor To区,而Survivor To区的空间是比较小的。这需要老年代进行担保,将Survivor无法容纳的对象放到老年代。这要求老年代有足够的空间容纳这些对象,需要用某些方式估计晋升到老年代对象的大小。
  • 如果老年代可以容纳当前新生代的所有对象,即使遇到最极端的情况(新生代的对象全部存活),可以断定本次Minor GC肯定是安全的。
  • 如果上述条件不满足,则Minor GC有失败风险,需要确定是否冒险进行Minor GC。如果HandlePromotionFailure = true,说明可以冒险进行Minor GC,则使用另一种方式对晋升老年代的对象大小进行评估,即参考晋升老年代对象的平均大小。如果还是空间不足,则只能进行Full GC,让老年代腾出空间。

Full GC 的触发条件

对于 Minor GC,其触发条件非常简单,当 Eden 空间满时,就将触发一次 Minor GC。而 Full GC 则相对复杂,有以下条件:

1. 调用 System.gc()

只是建议虚拟机执行 Full GC,但是虚拟机不一定真正去执行。不建议使用这种方式,而是让虚拟机管理内存。

2. 老年代空间不足

老年代空间不足的常见场景为前文所讲的大对象直接进入老年代、长期存活的对象进入老年代等。

为了避免以上原因引起的 Full GC,应当尽量不要创建过大的对象以及数组。除此之外,可以通过 -Xmn 虚拟机参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。还可以通过 -XX:MaxTenuringThreshold 调大对象进入老年代的年龄,让对象在新生代多存活一段时间。

3. 空间分配担保失败

使用复制算法的 Minor GC 需要老年代的内存空间作担保,如果担保失败会执行一次 Full GC。具体内容请参考上面的第 5 小节。

4. JDK 1.7 及以前的永久代空间不足

在 JDK 1.7 及以前,HotSpot 虚拟机中的方法区是用永久代实现的,永久代中存放的为一些 Class 的信息、常量、静态变量等数据。

当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。如果经过 Full GC 仍然回收不了,那么虚拟机会抛出 java.lang.OutOfMemoryError。

为避免以上原因引起的 Full GC,可采用的方法为增大永久代空间或转为使用 CMS GC。

5. Concurrent Mode Failure

执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足(可能是 GC 过程中浮动垃圾过多导致暂时性的空间不足),便会报 Concurrent Mode Failure 错误,并触发 Full GC。

OutOfMemoryError

Java heap space

StackOverflowError

GC overhead limit exceeded

GC耗时过长且回收效果差(回收了很小一部分堆内存)。

DirectBufferMemory

  • NIO: 可以直接分配堆外内存(OS的本地内存,不属于GC的管辖范围,不需要内存拷贝(Java堆和Native堆之间)所以速度较快)
  • 错误表示堆外内存耗尽

    unable to create native thread

  • 一个进程中创建了太多线程。线程的上限和OS有关。

    Metaspace

    加载太多类。

Java内存泄漏

  • 内存溢出:内存溢出是指没有足够的内存空间可供程序使用,出现OutOfMemoryError。内存中加载的数据量过于庞大,静态集合类中对对象的引用使用完未清空等。
  • 内存泄漏:申请内存后无法及时释放内存空间,造成可达但没有用的对象,这些对象不会被GC,依然占用内存空间。内存泄漏最终会导致内存溢出。
    • 可能的场景:全局(静态)的集合(长生命周期对象持有短生命周期对象的强引用 -> WeakHashMap),Key使用强引用,不关闭数据库连接。
  • 如何判断分析:频繁出现Full GC。
    • 进行堆转储并分析堆内对象的大小。
      • JVM参数:-XX:+HeapDumpOnOutOfMemoryError
      • jmap -dump:format=b file=[文件名] [pid]
      • JMX: 使用Jconsole找到名为HotSpotDiagnostic的MBean,即可完成堆转储。

类文件结构(?)

关键点:

  • 当前类名
  • 父类名
  • 接口数量
  • 所有实现的接口
  • 有多少字段
  • 字段的具体信息(字段名/字段类型)
  • 方法数量
  • 方法信息

类的生命周期

类加载过程(跟对象创建过程进行一下区别和联系)

  • 加载、连接(验证、准备、解析)、初始化

    加载

    类加载过程的第一步,主要完成下面3件事情:
  1. 通过全类名获取定义此类的二进制字节流
  2. 将字节流所代表的静态存储结构转换为方法区的运行时数据结构
  3. 在内存中生成一个代表该类的 Class 对象,作为方法区这些数据的访问入口
    加载是类加载过程中的一个阶段,这个阶段会在内存中生成一个代表这个类的java.lang.Class 对象,作为方法区这个类的各种数据的入口。注意这里不一定非得要从一个Class 文件获取,这里既可以从ZIP 包中读取(比如从jar 包和war 包中读取),也可以在运行时计算生成(动态代理),也可以由其它文件生成(比如将JSP 文件转换成对应的Class 类)。
  • 一个非数组类的加载阶段(加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,这一步我们可以去完成还可以自定义类加载器去控制字节流的获取方式(重写一个类加载器的loadClass()方法)。数组类型不通过类加载器创建,它由 Java 虚拟机直接创建。

验证

确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。

  1. 这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在 Java 堆中。
  2. 这里所设置的初始值”通常情况”下是数据类型默认的零值(如0、0L、null、false等),比如我们定义了public static int value=111 ,那么 value 变量在准备阶段的初始值就是 0 而不是111(初始化阶段才会赋值)。特殊情况:比如给 value 变量加上了 fianl 关键字public static final int value=111 ,那么准备阶段 value 的值就被赋值为 111。

解析(?)

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符7类符号引用进行。

符号引用就是一组符号来描述目标,可以是任何字面量。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。在程序实际运行时,只有符号引用是不够的,举个例子:在程序执行方法时,系统需要明确知道这个方法所在的位置。Java 虚拟机为每个类都准备了一张方法表来存放类中所有的方法。当需要调用一个类的方法的时候,只要知道这个方法在方法表中的偏移量就可以直接调用该方法了。通过解析操作符号引用就可以直接转变为目标方法在类中方法表的位置,从而使得方法可以被调用。

综上,解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,也就是得到类或者字段、方法在内存中的指针或者偏移量。

初始化

初始化是类加载的最后一步,也是真正执行类中定义的 Java 程序代码(字节码),初始化阶段是执行初始化方法 <clinit> ()方法的过程。

对于<clinit>() 方法的调用,虚拟机会自己确保其在多线程环境中的安全性。因为 <clinit>() 方法是带锁线程安全,所以在多线程环境下进行类初始化的话可能会引起死锁,并且这种死锁很难被发现。

对于初始化阶段,虚拟机严格规范了必须对类进行初始化的情况(只有主动去使用类才会初始化类):

  1. 当遇到 newgetstaticputstaticinvokestatic 这4条直接码指令时,比如 new 一个类,读取一个静态字段(未被 final 修饰)、或调用一个类的静态方法时。
    • 当jvm执行new指令时会初始化类。即当程序创建一个类的实例对象。
    • 当jvm执行getstatic指令时会初始化类。即程序访问类的静态变量(不是静态常量,常量会被加载到运行时常量池)。
    • 当jvm执行putstatic指令时会初始化类。即程序给类的静态变量赋值。
    • 当jvm执行invokestatic指令时会初始化类。即程序调用类的静态方法。
  2. 使用 java.lang.reflect 包的方法对类进行反射调用时如Class.forname(“…”),newInstance()等等。 如果类没初始化,需要触发其初始化。
  3. 初始化一个类,如果其父类还未初始化,则先触发该父类的初始化。
  4. 当虚拟机启动时,用户需要定义一个要执行的主类 (包含 main 方法的那个类),虚拟机会先初始化这个类。
  5. MethodHandle和VarHandle可以看作是轻量级的反射调用机制,而要想使用这2个调用, 就必须先使用findStaticVarHandle来初始化要调用的类。
  6. 当一个接口中定义了JDK8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

卸载

卸载类即该类的Class对象被GC。
卸载类需要满足3个要求:

  1. 该类的所有的实例对象都已被GC,也就是说堆不存在该类的实例对象。
  2. 该类没有在其他任何地方被引用
  3. 该类的类加载器的实例已被GC
    所以,在JVM生命周期内,由jvm自带的类加载器加载的类是不会被卸载的。但是由我们自定义的类加载器加载的类是可能被卸载的。
    jdk自带的BootstrapClassLoader,ExtClassLoader,AppClassLoader负责加载jdk提供的类,所以它们(类加载器的实例)肯定不会被回收。而我们自定义的类加载器的实例是可以被回收的,所以使用我们自定义加载器加载的类是可以被卸载掉的。

类加载机制

所有的类都由类加载器加载,加载的作用就是将 .class文件加载到内存。

JVM内置ClassLoader

JVM 中内置了三个重要的 ClassLoader,除了 BootstrapClassLoader 其他类加载器均由 Java 实现且全部继承自java.lang.ClassLoader

  • BootstrapClassLoader(启动类加载器) :最顶层的加载类,由C++实现,负责加载 %JAVA_HOME%/lib目录下的jar包和类或者或被 -Xbootclasspath参数指定的路径中的所有类。(java.xxx.*java.util.*java.io…)
  • ExtensionClassLoader(扩展类加载器) :主要负责加载目录 %JRE_HOME%/lib/ext 目录下的jar包和类,或被 java.ext.dirs 系统变量所指定的路径下的jar包。(javax.*…)
  • AppClassLoader(应用程序类加载器) :面向我们用户的加载器,负责加载当前应用classpath下的所有jar包和类。

为什么要自定义ClassLoader?

  • Java中提供的默认ClassLoader,只加载指定目录下的jar和class,如果我们想加载其它位置的类或jar时,比如:我要加载网络上的一个class文件,通过动态加载到内存之后,要调用这个类中的方法实现我的业务逻辑。在这样的情况下,默认的ClassLoader就不能满足我们的需求了,所以需要定义自己的ClassLoader。
  • JVM运行时并不会一次性加载所需要的全部类,它是按需加载(懒加载、延迟加载)。比如你在调用某个类的静态方法时,首先这个类肯定是需要被加载的,但是并不会触及这个类的实例字段,那么实例字段的类别 Class 就可以暂时不必去加载,但是它可能会加载静态字段相关的类别,因为静态方法会访问静态字段。而实例字段的类别需要等到你实例化对象的时候才可能会加载。
  • 程序在运行过程中,遇到了一个未知的类,它会选择哪个 ClassLoader 来加载它呢?虚拟机的策略是使用调用者 Class 对象的 ClassLoader 来加载当前未知的类。何为调用者 Class 对象?就是在遇到这个未知的类时,虚拟机肯定正在运行一个方法调用(静态方法或者实例方法),这个方法挂在哪个类上面,那这个类就是调用者 Class 对象。前面我们提到每个 Class 对象里面都有一个 classLoader 属性记录了当前的类是由谁来加载的。

双亲委派模型

每一个类都有一个对应它的类加载器。系统中的ClassLoder在协同工作的时候会默认使用双亲委派模型。每一个ClassLoader的实例都有一个父类加载器的引用。在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。加载的时候,首先会把该请求委派该父类加载器的loadClass()处理,因此所有的请求最终都应该传送到顶层的启动类加载器 BootstrapClassLoader 中。当父类加载器无法处理时,才由自己来处理。当父类加载器为null时,会使用启动类加载器 BootstrapClassLoader 作为父类加载器。
AppClassLoader的父类加载器为ExtClassLoaderExtClassLoader的父类加载器为nullnull并不代表ExtClassLoader没有父类加载器,而是 BootstrapClassLoader

  • When loading a class, a class loader first “delegates” the search for the class to its parent class loader before attempting to find the class itself.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    private final ClassLoader parent; 
    protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
    {
    synchronized (getClassLoadingLock(name)) {
    // 首先,检查请求的类是否已经被加载过
    Class<?> c = findLoadedClass(name);
    if (c == null) {
    long t0 = System.nanoTime();
    try {
    if (parent != null) {//父加载器不为空,调用父加载器loadClass()方法处理
    c = parent.loadClass(name, false);
    } else {//父加载器为空,使用启动类加载器 BootstrapClassLoader 加载
    c = findBootstrapClassOrNull(name);
    }
    } catch (ClassNotFoundException e) {
    //抛出异常说明父类加载器无法完成加载请求
    }

    if (c == null) {
    long t1 = System.nanoTime();
    //自己尝试加载
    c = findClass(name);

    // this is the defining class loader; record the stats
    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
    sun.misc.PerfCounter.getFindClasses().increment();
    }
    }
    if (resolve) {
    resolveClass(c);
    }
    return c;
    }
    }

    loadClass() 方法是加载目标类的入口,它首先会查找当前 ClassLoader 以及它的双亲里面是否已经加载了目标类,如果没有找到就会让双亲尝试加载,如果双亲都加载不了,就会调用 findClass() 让自定义加载器自己来加载目标类。ClassLoaderfindClass() 方法是需要子类来覆盖的,不同的加载器将使用不同的逻辑来获取目标类的字节码。拿到这个字节码之后再调用 defineClass() 方法将字节码转换成 Class 对象。

为什么要使用双亲委派模型

  • 双亲委派模型保证了Java程序的稳定运行,可以避免类的重复加载(JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类),当父类加载器已经加载该类的时候,没有必要让子ClassLoader再加载一次。保证了 Java 的核心 API 不被篡改(?)。
  • 如果不使用这种委托模式,那我们就可以随时使用自定义的String来动态替代java核心api中定义的类型,这样会存在非常大的安全隐患,而双亲委托的方式,就可以避免这种情况,因为String已经在启动时就被引导类加载器(Bootstrcp ClassLoader)加载,所以用户自定义的ClassLoader永远也无法加载一个自己写的String,除非你改变JDK中ClassLoader搜索类的默认算法。
  • JVM在判定两个class是否相同时,不仅要判断两个类名是否相同,而且要判断是否由同一个类加载器实例加载的。只有两者同时满足的情况下,JVM才认为这两个class是相同的。

钻石依赖问题(为什么JVM不只根据类名来区分不同类?)

  • 钻石依赖问题:软件依赖导致同一个软件包的两个版本需要共存而不能冲突。
  • Maven怎么解决钻石依赖:扁平化依赖管理
    • 依赖于JVM的默认懒加载策略。
    • 从多个冲突的版本中选一个。如果不同版本之间的兼容性很糟糕,则程序无法正常编译运行。
  • ClassLoader的解决方案:使用不同的ClassLoader加载不同版本的软件包。位于不同的ClassLoader中名称一样的类实际上是不同的类。
    • 只能使用反射或者接口的形式进行动态调用。
  • ClassLoader:相当于命名空间,一定程度上起到类隔离的作用。

如何打破双亲委派机制

  • 自定义加载器需要继承ClassLoader(除了BootstrapClassLoader所有的类加载器都继承自ClassLoader)
  • 自定义加载器的话,需要继承 ClassLoader 。如果我们不想打破双亲委派模型,就重写 ClassLoader 类中的 findClass() 方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写 loadClass() 方法

Class.forName vs ClassLoader.loadClass

这两个方法都可以用来加载目标类,它们之间有一个小小的区别,那就是 Class.forName() 方法可以获取原生类型的 Class,而 ClassLoader.loadClass() 则会报错。

经典应用场景

  • Tomcat:
    • 保证同一个服务器的两个Web应用的Java类库互相隔离。
    • 保证同一个服务器的两个Web应用程序的Java类库又可以共享(???)
    • 保证服务器尽可能保证自身安全,不受到web应用的影响。
    • JSP的HotSwap?
  • OSGi?

JVM 命令

JVM的参数类型

标配参数

X参数

  • 解释执行
  • 第一次就编译成本地代码
  • 混合模式

    XX参数

    • -XX:+/-: 开启/关闭某个属性值
    • K-V设值类型:-XX:MetspaceSize=blabla
    • 堆内存大小
  • 初始: -Xms= 等价于 -XX:InitialHeapSiZE:
  • 最大: -Xmx= 等价于 -XX:MaxlHeapSiZE:
  • 最小: -Xmn
    • 栈内存大小
  • -Xss:单个线程栈的大小

    查看运行中的java程序

    • jps:查看进程号
    • jinfo :查看运行参数 jinfo -flag [设置] <进程号>

      查看JVM默认值

    • -XX:+PrintFlagsInitial

      查看被修改过的JVM参数

    • -XX:+PrintFlagFinal

      查看(默认)JVM命令

    • -XX:PrintCommandLineFlags

Todos:

  • [x] AQS
  • [x] volatile
  • [x] synchronized锁升级机制
  • [x] monitor对象
  • [x] ThreadLocal
  • [ ] 线程安全的懒汉式单例模式

虚假唤醒问题

  • await() / wait()需要放进循环中

线程池

  • 控制运行的线程数量,处理过程中将任务放入队列,然后在线程创建后启动这些任务,如果线程数量超过了最大数量,超出数量的线程排队等候,等其它线程执行完毕,再从队列中取出任务来执行。

    为什么要用

  • 线程复用,降低线程创建和销毁,降低资源消耗
  • 提高响应速度,任务到达时任务可以不需要等到线程创建就可以立即执行
  • 提高线程的可管理性,无限制地创建线程会消耗系统资源。使用线程池可以对线程进行统一分配和监控。

    7大参数

  • corePoollSize
  • maximumPoolSize
  • keepAliveTime: 多余线程的存活时间
  • TimeUnit
  • workQueue
  • threadFactory
  • handler: 拒绝策略
    ThreadPool

    工作顺序

    工作队列满才会创建非核心线程,工作队列不满时不会将新任务提交给非核心线程。线程在一定时间(keepAliveTime)没接收到任务后就会被停止,最终线程池会收缩到corePoolSize的大小。
    ThreadPoolWorking

4大拒绝策略

拒绝策略触发的时机:任务数大于corePoolSize时会将任务放入队列缓冲区,填满了缓冲区后会判断当前任务书是否大于maxPoolSize,如果小于会新建线程处理,大于时会触发拒绝策略。

  • CallerRunsPolicy(调用者运行):触发拒绝策略时只要线程池没有关闭就交给提交任务的当前线程处理。(不允许失败、并发量小的场景)
  • AbortPolicy(中止策略):触发拒绝策略时直接抛出拒绝执行的异常,打断当前的执行流程。
  • DiscardPolicy(丢弃策略):触发拒绝策略时悄悄丢弃最新提交的任务,不抛出任何异常。
  • DiscardOldestPolicy:抛弃队列中等待最久的任务,然后把当前任务加入队列中尝试再次提交当前任务。

synchronized关键字

锁的内存语义

  • 释放锁时,该线程对于的本地内存中的共享变量会刷新到主存中
  • 获取锁时,该线程的本地内存会被设置为无效,被monitor保护的临界区代码必须从主内存中读取共享变量。

synchronized的实现原理

什么是monitor

JVM基于进入和退出monitor对象来实现同步。同步代码块采用添加monitorentermonitorexit实现,同步方法使用ACC_SYNCHRONIZED标记符实现。monitor对象存在于每个对象的对象头中。
线程执行monitorenter时,会尝试获取monitor的所有权。如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者;如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1;如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。
线程执行monitorexit时,对monitor进行释放,此时线程必须是monitor的持有者。指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。

对象头

Java对象的内存布局:对象头、实例数据、对齐填充。对象头主要包括Mark Word标记字段和Class Pointer类型指针。

  • Class Pointer 是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
  • Mark Word 用于存储对象自身的运行时数据,比如哈希码、锁状态标识、GC年龄等信息。Mark Word里存储的数据会随着锁标志位的变化而变化
    • 无锁(01):hashcode、分代年龄、是否是偏向锁(0)
    • 偏向锁(01): 偏向线程ID、偏向时间戳、对象分代年龄、是否偏向锁(1)
    • 轻量级锁(00):指向栈中锁记录的指针
    • 重量级锁(10): 指向互斥量(重量级锁)的指针

锁的优化

  • 锁升级机制:无锁、偏向锁、轻量级锁、重量级锁
  • 锁的状态变化是单向的,不会降级,只会升级。

    自旋锁

    所谓自旋锁,就是指当一个线程尝试获取某个锁时,如果该锁已被其他线程占用,就一直循环检测锁是否被释放,而不是进入线程挂起或睡眠状态。
    自旋锁本身是有缺点的,它不能代替阻塞。自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源。所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用-XX:PreBlockSpin来更改)没有成功获得锁,就应当挂起线程。
    自旋锁的实现原理同样也是CAS,AtomicInteger中调用unsafe进行自增操作的源码中的do-while循环就是一个自旋操作,如果修改数值失败则通过循环来执行自旋,直至修改成功。

自适应自旋锁

自旋的次数不固定,由前一次在同一个锁上自旋的时间和锁的拥有者的状态来决定。线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。

锁清除

JVM检测不到共享数据竞争时就会对这些同步锁进行锁清除。

锁粗化

锁粗化就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。例如将循环内的加锁操作转移到循环外。

偏向锁

在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。其目标就是在只有一个线程执行同步代码块时能够提高性能。
偏向锁是在单线程执行同步代码块时使用的机制,如果在多线程并发的环境下(即线程A尚未执行完同步代码块,线程B发起了申请锁的申请),则一定会转化为轻量级锁或者重量级锁。
当一个线程访问同步块并获取锁时,会在对象头(Mark Word)和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程进入和退出同步块时不需要花费CAS操作来争夺锁资源,只需要检查是否为偏向锁、锁标识为以及ThreadID即可。

  1. 检测对象头的Mark Word字段判断是否为偏向锁状态
  2. 若为偏向锁状态,则判断线程ID是否为当前线程ID,如果是则执行同步代码块
  3. 如果线程ID不为当前线程ID,则通过CAS操作竞争锁(怎么竞争?),竞争成功,则将Mark Word的线程ID替换为当前线程ID
  4. 通过CAS竞争锁失败,证明当前存在多线程竞争情况。等待到达全局安全点(没有字节码正在执行)后,获得偏向锁的线程被挂起,偏向锁升级为轻量级锁
    偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。

轻量级锁

当多个线程竞争偏向锁时就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。

  1. 线程进入同步块时,如果同步对象锁状态为无锁状态(无锁和偏向锁都是01),虚拟机首先在当前线程的栈帧中建立一个名为Lock Record的空间,用于存储锁对象目前的Mark Word的拷贝。
  2. 将对象头中的Mark Word复制到Lock Record中。
  3. 复制成功后,虚拟机使用CAS操作尝试将对象Mark Word中的Lock Word(此时对象头Mark Word的内容)更新为指向当前当前线程Lock Record(线程栈帧中的空间)的指针,并将Lock Record里的owner指针指向对象Mark Word。
  4. 如果这个更新动作成功了,那么当前线程就拥有了该对象的锁。此时对象Mark Word的锁标志位被设置为00,表示对象处于轻量级锁定状态。
  5. 如果更新操作失败,JVM首先检查对象Mark Word中的Lock Word是否指向当前线程的栈帧,如果是,说明当前线程已经拥有此对象锁,可以进入同步块。否则进入自旋状态,如果自旋结束时仍未获得锁,轻量级锁就要升级为重量级锁,锁状态值变为10
    对于轻量级锁,其性能提升的依据是 “对于绝大部分的锁,在整个生命周期内都是不会存在竞争的”,如果打破这个依据则除了互斥的开销外,还有额外的CAS操作,因此在有多线程竞争的情况下,轻量级锁比重量级锁更慢。

重量级锁

使用监视器锁,其本质依赖操作系统提供的Mutex Lock,因此需要进行系统调用,即完成从用户态到内核态的转换,因此这个成本非常高。

Lock和Synchronized的区别

  • synchronized是关键字,由JVM提供支持,底层通过monitor对象来完成(monitorenter/monitorexit),wait/notify方法也依赖monitor对象,因此这两个方法也只能在synchronized代码块中才能正常调用。Lock是具体类,当然awaitsignal也需要持有锁的时候调用,否则会抛出IllegalMonitorStateException异常。
  • synchronized不需要手动释放锁,而Lock需要
  • synchronized不可中断,除非抛出异常或者正常运行完成
  • ReentrantLock可以被中断,synchronized在当前线程等待锁被阻塞时无法被中断。
    • 超时方法tryLock:可以立即返回,可以等待一个超时时间
    • lockInterruptibly(): 当前线程在等待过程中,其它线程对当前线程调用interrupt()可以中断当前线程,让当前线程优先响应中断异常InterruptedException
  • synchronized是非公平锁,Lock可以是公平的。
  • ReentrantLock可以绑定多个条件Condition,实现按照不同的需要分组唤醒线程,而synchronized只能随机唤醒一个线程或全部唤醒。

LockSupport

是什么?

  • 用于创建锁和其它同步类的基本线程阻塞原语
  • LockSupport每个使用它的线程提供一个permit(类似于Semaphore),调用park方法时如果permit可用则立即返回并消耗这个permit,否则可能阻塞(类似于上限为1的信号量,重复unpark不会累积,最初调用park会直接阻塞,猜测初值应该为0)。调用unpark则可以释放permit。要保证对LockSupport的可靠使用,需要依靠volatile或原子变量来控制何时调用parkunpark。提供了阻塞和唤醒线程的功能。
  • park会在调用线程被中断(interrupted)的时候返回,也支持超时机制,也可能在任何时候没有任何原因地返回。因此一般在循环中调用park以重新检测条件。这可以被视为一种优化的忙等待机制。
  • 一般不直接使用而是用于构造高阶的同步机制。
  • 唤醒(unpark)可以在park之前进行,此时park不会被阻塞。

    原理

  • 通过Unsafe类中的native方法实现
  • permit默认为0
  • 每次park都必须消耗一个permit(即使是连续调用),而每次调用unpark会将permit设置为1,但是上限为1,达到上限后不会累计。

AQS(AbstractQueuedSynchronizer)

是什么?

  • java.util.concurrent.locks.AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer
  • 用于构建锁和其它同步器组件。内置FIFO队列来完成获取资源的线程的排队工作,并通过(原子的)int类型的变量来表示持有锁的状态。
  • ReentrantLock, CountDownLatch, ReentrantRewriteLock, Semaphore等类的实现都需要使用。
  • 屏蔽(封装)了同步状态管理、阻塞线程、排队等操作:如果共享资源被占用,就需要一定的阻塞-等待-唤醒机制来保证锁的分配。将暂时获取不到锁的线程加入队列中,这个队列就是AQS的抽象表现。请求资源的线程 -> 队列的结点。
  • 支持两种同步方式:共享式、独占式

实现

  • 虚拟双向链表实现的队列
  • 使用volatileint类型成员变量state来表示同步状态。通过CAS完成对该状态的维护。
  • 基于模板方法模式,使用者继承该类需要重写指定的方法。然后将AQS组合在自定义同步组件的实现中,并调用其模板方法,模板方法会调用使用者重写的方法。

    • protected boolean tryAcquire(int arg): 独占式地获取同步状态(获取资源),成功返回true,否则返回false
    • protected boolean tryRelease(int arg): 独占式地释放同步状态(释放资源),等待中的其他线程此时有机会获取到同步状态
    • protected int tryAcquireShared(int arg): 共享式地获取同步状态,返回值大于等于0时成功,否则失败
    • protected boolean tryReleaseShared(int arg): 共享式地释放同步状态,成功true,失败false
    • protected boolean isHeldExclusively(): 是否在独占模式下被线程占用。
  • 核心方法: acquireQueued

    state

  • volatileint类型成员变量。0: 可用,>= 1: 占用中。

    AQS本身

  • 通过自旋等待
  • CLH队列的变种:双端队列
  • 对队列的原子操作:尾部入队,头部出队。当一个节点的前驱结点释放资源时,该节点会被通知。

    Node节点

  • static final Node SHARED/static final Node EXCLUSIVE: 线程等待锁的模式:共享/独占
  • volatile int waitState成员变量:Node的等待状态
    • 0:初始化时的默认值
    • CANCELLED/1: 已经取消了对锁的请求
    • CONDITION/-2: 在等待队列中,等待唤醒
    • PROPAGATE/-3: SHARED状态下才会使用
    • SIGNAL/-1: 线程已经准备好了
  • volatile Node prev/volatile Node next: 前驱/后继节点
  • volatile Thread thread: 与当前节点关联的排队中的节点

核心方法

acquire

1
2
3
4
5
6
7
8
9
10
public final void acquire(int arg) {
// 1. 首先调用使用者重写的tryAcquire方法。如果返回true,说明获取同步状态成功,后面的逻辑不再执行。
// 如果返回false,说明获取同步状态失败,需要继续执行
// 2. 构造独占式同步节点,通过addWaiter方法将此节点添加到同步队列的尾部。此时可能有多个线程节点试图加入同步队列尾部,
// 需要保证线程安全。
// 3. 该节点在队列中尝试获取同步状态(acquireQueued),如果获取不到则阻塞节点线程(selfInterrput),直到被前驱结点唤醒
// 或者被中断
if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}

addWaiter方法:构造一个Node节点并添加到队列尾部

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);//构造结点
//指向尾结点tail
Node pred = tail;
// 如果尾结点不为空,CAS快速尝试在尾部添加,若CAS设置成功,返回;否则进入enq方法。
// 尾节点为空也需要进入enq方法
if (pred != null) {
node.prev = pred;
// CAS操作
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
enq(node);
return node;
}

enq方法:如果队尾为空,通过CAS方法尝试创建一个节点作为头结点(和当前的为节点)。如果队列不为空,则通过CAS尝试将当前节点连接到尾节点。如果失败则一直自旋等待。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private Node enq(final Node node) {
for (;;) {
Node t = tail;
if (t == null) { //如果队列为空,创建结点,同时被head和tail引用
if (compareAndSetHead(new Node()))
tail = head;
} else {
node.prev = t;
if (compareAndSetTail(t, node)) {//cas设置尾结点,不成功就一直重试
t.next = node;
return t;
}
}
}
}

acquireQueued方法:
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
final boolean acquireQueued(final Node node, int arg) {
// 当前线程是否成功获得锁
boolean failed = true;
try {
// 当前线程是否被阻塞过
boolean interrupted = false;
// 不停自旋
for (;;) {
// 找到当前结点的前驱结点
final Node p = node.predecessor();
// 如果前驱结点是头结点,才tryAcquire,其他结点是没有机会tryAcquire的。
if (p == head && tryAcquire(arg)) {
// 获取同步状态成功,将当前结点设置为头结点。
setHead(node);
p.next = null; // 方便GC
failed = false;
return interrupted;
}
// 如果没有获取到同步状态,通过shouldParkAfterFailedAcquire判断是否应该阻塞,parkAndCheckInterrupt用来阻塞线程
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}

shouldParkAfterFailedAcquire: 判断当前节点线程是否应该被阻塞,主要根据前驱节点(node.predecessor())的状态进行判断。

  • 如果前驱节点的状态为SIGNAL,说明前驱节点准备好要获得锁,当前线程可以安全地被LockSupport.park()
  • 如果前驱节点的状态为CANCELLED,说明前驱节点已经取消了对锁的获取,前驱节点已经是无效节点。需要在链表中从后往前遍历,找到一个非CANCELLED状态的节点,将当前线程的节点设置为它的后继节点。
  • 如果前驱节点为其它状态(CONDITION, PROPAGATE),则将前驱节点的状态通过CAS设置为SIGNAL
  • parkAndCheckInterrupt:使用LockSupport.park(this)阻塞当前线程。并返回Thread.interrupted()

acquire方法的逻辑总结

  1. 首先调用用户实现的tryAcquire尝试获得同步状态,成功则直接返回,失败则继续
  2. 如果等待链表的头结点不为空,使用CAS方法构造一个节点加入同步队列中。如果头结点为空或CAS失败,则不停自旋重复这个过程直到成功。
  3. 加入队列后的节点线程进入自旋状态。如果前驱节点是头结点,则尝试获取同步状态。否则,判断当前节点是否可以阻塞。
  4. 如果前驱节点的状态为SIGNAL,则可以阻塞当前线程。如果前驱节点的状态为CANCELLED,找到链表最尾端的一个状态为SIGNAL的节点,将当前节点设为其后继节点。如果前驱节点的状态为其它状态,则将其状态设置为SIGNAL

release

线程释放同步状态的方法。

  1. 调用使用者重写的tryRelease方法,如果成功则调用unparkSuccessor唤醒其后继节点,如果失败则返回false
  2. unparkSuccessor首先会将当前节点的等待状态通过CAS设置为初始值0。如果当前节点的后继节点不为空且状态不为CANCELLED,调用LockSupport.unpark(node.next.thread)唤醒后继节点。否则,从尾向头寻找一个处于正常阻塞状态的节点(waitStatus <= 0),将其唤醒。

acquireShared

调用用户实现的tryAcquireShared,将判断条件从true/false改为剩余资源是否>=0即可

怎么用

  • Lock接口的实现类:聚合一个队列同步器的子类(Sync extends AbstractQueuedSynchronizer)完成线程访问控制

案例 ReentrantLock

  • 公平锁:先判断hasQueuedPredecessors()
  • 尝试加锁 -> 加锁失败进入阻塞队列 -> 线程阻塞
  • 加锁(lock()),对AQS的state进行CAS,如果成功,则调用setExclusiveOwnerThread(thread), 否则调用acquire(int)
  • tryAcquire(): 模板,由子类实现

volatile

  • 轻量级的同步机制

    保证可见性

  • volatile保证了不同线程对共享变量操作的可见性。一个线程修改了volatile修饰的变量,当修改写回主内存时,另一个线程会立即看到最新的值。
  • 主内存和工作内存的一致性问题:写数据时通知其它CPU将该变量的缓存行设为无效状态,其它CPU需要读取时发现缓存失效,则需要从内存中重新读取。
    • 如何发现数据失效:嗅探总线上传播的数据检查当前缓存值是否过期。
  • volatile写的内存语义:对一个volatile变量进行写操作时,该线程对应的本地内存中的共享变量值会刷新到主内存,然后通知其它CPU缓存失效。
  • volatile读的内存语义:对一个volatile变量进行读操作时,该线程对应的本地内存会被置为无效,线程将会从主存中读取共享变量。
  • 内存语义的实现:内存屏障策略。

    不保证原子性

  • 修改volatile变量需要将变量从主内存读取到线程的工作内存,在工作内存中修改变量值,然后将工作内存的变量值写回主内存。
  • 但是对单个volatile变量的读和赋值操作可以被视为有原子性。

    禁止指令重排序

  • JMM规则下的问题:编译器/JIT/微处理器允许对每个线程内的代码进行重排序。
    • 单线程下的执行结果不能被改变:遵守as-if-serial语义
  • 而在每个线程内不存在数据依赖性的指令在全局的指令执行顺序中可能是存在数据依赖性的。
    • 举个例子
      1
      2
      3
      4
      5
      6
      7
      8
      9
      A = 0, B = 0
      Thread1: r2 = A -> B = 1
      Thread2: r1 = B -> A = 2

      执行顺序可能被重排为
      Thread1: B = 1 -> r2 = A
      Thread2: A = 2 -> r1 = B

      执行结果就可能变成r2 = 2, r1 = 1
      • 数据争用(data race):
        • 一个线程里有对一个变量的写操作
        • 另一个线程里有对同一个变量的读操作
        • 读写操作没有通过同步机制进行排序
  • volatile关键字实现了禁止指令重排序的优化,避免多线程环境下出现乱序执行的情况
  • 内存屏障:任何指令不能和内存屏障指令重排序
  • volatile变量的写操作前插入StoreStore内存屏障,禁止该内存屏障前后的写操作进行重排序;对volatile变量的写操作后加入StoreLoad屏障,禁止改内存屏障之前的写操作和

线程安全的懒汉式单例模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Singleton {
// 如果不对instance字段加volatile修饰符会怎样?
// 对象构造并不是原子操作,它需要分配对象内存,调用构造器方法执行初始化,然后将对象引用赋值给变量。
// 但是上述三个步骤可能发生重排序,比如可以先将对象引用赋值给变量,然后调用构造器的构造方法进行初始化。
// 这样其它线程在检查instance是否为空时,能够判断instance是一个有指向的引用,因此判断instance != null
// 但是实际上对象并没有完成初始化,其它线程可能会拿到一个“半成品”
// volatile会制止该对象在初始化期间的指令重排序。
private volatile static Singleton instance = null;
private Singleton() {}
public static Singleton getInstance() {
// 第一次检查,如果此时instance已经非空了,那单例肯定已经被创建,可以直接返回单例
if (instance == null) {
synchronized (Singleton.class) {
// 别的线程可能已经进入过一次synchronized代码块并对单例进行初始化,为了避免这种情况,我们需要进行二次检查
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}

应用场景

  • 某个属性被多个线程共享,其中有一个线程修改了此属性,其他线程可以立即得到修改后的值,比如boolean flag;或者作为触发器,实现轻量级同步。
  • volatile的读写操作不需要加锁,因此成本低。但是只能用作于属性。
  • volatile提供了可见性,任何一个线程对其的修改将立马对其他线程可见,volatile属性不会被线程缓存,始终从主存中读取。
  • volatile提供了happens-before保证,对volatile变量v的写入happens-before所有其他线程后续对v的读操作。

CAS(Compare and Swap)

  • 线程将工作内存中的内容写回主内存前先比较其期望值和主内存的真实值(比如,比较线程在读入时和当前主内存中变量的值是否相同),如果真实值和期望值相同,将新值写回主内存,否则表明该变量在当前线程读入工作内存后曾经被其他内存修改,不写回主内存。

Unsafe

  • Java无法直接访问底层系统,sun.misc.Unsafe相当于一个后门,可以直接直接操作特定内存的数据。
  • CAS是一条并发原语。比较是否为预期值的并决定是否更改的过程是原子的。
  • AtomicInteger.getAndIncrement(): 1. 先从主内存中直接读出变量值,2. 然后使用CAS判断内存中变量的当前值是否等于期望值(刚从主内存读出来的值),如果是,则将修改后的值写回主内存。注意,1是原子的,2是原子的,1+2整个操作并不是原子的。如果在2.中发现内存中的当前值不等于期望值,则进行自旋等待(while一直循环)。(这是一种乐观锁)

缺点?

  • 循环时间长,开销大。如果CAS失败会一直进行自旋,可能会对CPU带来较大的开销。
  • 只能保证一个共享变量的原子操作。如果需要对多个共享变量进行原子操作,必须加锁。
  • ABA问题:
    • 问题描述:
      • 线程A从主内存读取变量到工作内存中,此时变量值为A。读取完毕后线程A因为某些原因被挂起
      • 线程B从主内存读取同一个变量到工作内存中,变量值为A,通过CAS将主内存中的变量值修改为B
      • 线程B再次通过CAS将主内存中的变量值修改为B
      • 线程A恢复运行,希望将对变量的修改写入主内存,并且此时比较能够通过,线程A会认为变量并未经过修改
      • 虽然变量的值看上去并没有变化,但是此时数据的语义很可能已经发生变化。例如本来应该只发生一次的更改发生了多次。
    • 原子引用AtomicReference<V>: 让任意自定义类型实现类似AtomicInteger的功能
    • 如何规避ABA问题:新增版本号机制
      • 可以直接使用时间戳作为版本号:AtomicStampedReference<V>

线程同步工具

CountDownLatch

  • 一个同步辅助类,允许一个或多个线程等待另外一组线程完成工作。
  • 提供一个计数器,需要等待的线程会等待该计数器的值减小到0。提供线程安全的方法供其它线程调用countDown方法减少计数器的值。

    实现原理

  • CountDownLatch内部包含了一个继承自AQS的Sync类实例。利用了AQS来实现共享锁。
  • 基本思路是在构造的时候将state设置为大于0的值count,相当于当前资源state被占用,此时调用await方法的线程尝试获取共享锁会失败。

    await方法

  • 调用该方法时会尝试获取共享锁,当state == 0的时候能够成功获取到共享锁。在其他情况下,需要加入队列进行排队。

    countDown方法

  • 通过CAS的方式对state进行赋值,让作为锁计数器的state变量减1。必须有count次方法调用才能让锁计数器减少到0。

CyclicBarrier

  • 一个同步辅助类,允许多个线程互相等待。
  • 主要借助ReentrantLockCondition来实现。

线程安全集合

List

CopyOnWriteArrayList(写时复制)

  • 数据使用volatile的数组进行保存
  • 写时先获得锁,然后进行复制,在复制后的数组上进行写(并扩容),最后将原数组的引用指向新数组。

锁的类型

公平锁与非公平锁

  • 公平锁:FIFO(获取锁的顺序就是申请锁的顺序);非公平锁:获取锁的顺序不一定是申请锁的顺序,可能会造成优先级反转或者饥饿现象。
  • ReentrantLock默认是非公平锁,可以通过在构造函数中对fair参数传入true让实例成为公平锁。
    • 公平:FIFO
    • 非公平:直接尝试获得锁,如果尝试失败则才在队列中进行排队。有点:吞吐量大。synchronized是一种非公平锁。

可重入锁(递归锁)

  • 同一线程外层函数获得锁之后,内层递归函数仍然能获取该锁。线程可以进入任何一个它已经拥有的锁同步着的代码块。
  • 例如:对于同一个对象内的synchronized方法,当前线程已经进入了一个synchronized方法内,说明该线程已经获得当前对象的对象锁,因此该线程在释放该锁之前同样可以调用该对象的其它synchronized方法。
  • 最大的作用是避免死锁。
  • 实现:计数 -> 加锁和解锁的操作需要配对

自旋锁

  • 尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁。好处是(在等待时间可能不长时)减少线程上下文切换的消耗,缺点是循环会消耗CPU。

互斥锁/排他锁 共享锁

  • 读读相容,独写不相容,写写不相容

阻塞队列

  • 接口:BlockingQueue
  • 常见实现:
    BlockingQueue
  • SynchronousQueue

实现原理

LinkedBlockingQueue

  • 数据结构:有头结点的单链表
  • 两把锁:使用两个ReentrantLock对象,分别保证出队操作和入队操作的线程安全,入队操作和出队操作不会互斥
  • 两个Condition对象:保证队列中有元素才能出队,保证队列中有空闲位置才能入队
  • 一个AtomicInteger:以线程安全的方式维护队列中的元素数量
  • put方法
    1. 获取入队锁
    2. 如果当前队列已满,则在notFull条件下等待
    3. 完成入队操作,并调用AtomicInteger的方法增加元素个数
    4. 判断插入后队列是否已满,如果未满则对notFull调用signal让其它入队线程插入元素(为什么不是signalAll?)
    5. 释放入队锁
    6. 通知出队线程队列非空
  • take方法
    1. 获取出队锁
    2. 如果队列空,在notEmpty条件下等待
    3. 完成出队操作,减小元素数
    4. 队列非空则通知其它出队线程
    5. 通知入队线程队列未满

ArrayBlockingQueue

  • 为什么入队列和出队列公用一把锁了?
    • 因为直接使用数组,入队列和出队列操作开销小。如果两个操作分别用一把锁反而加锁解锁的开销大?

Map

ConcurrentHashMap

JDK 1.7

  • Segment<K, V>: 将每个HashMap的Entry进行分组。记录需要插入HashMap需要先定位插入的Segment,再在Segment中定位插入的位置。相当于将原本的每个Entry分组。该类继承于ReentrantLock,因此具有获得可重入锁的功能。
  • 执行put操作的时候,先进行第一次hash来定位segment的位置,如果segment还没有初始化即通过CAS进行赋值。然后进行第二次hash操作,找到响应的HashEntry的位置。之后调用继承自ReentrantLock的可超时方法tryLock尝试获取该Segment的锁。如果成功则在Segment中进行插入或修改。如果不成功,则当前线程会以自旋的方式继续调用tryLock()尝试获得锁。当前线程自旋超过指定的次数就会被挂起,等待被唤醒。
  • 执行get操作不需要加锁。
  • 执行size操作时,由于可能存在并发的线程正在插入数据,因此得到的size可能不等于实际的size。
    • 先尝试不加锁多次计算ConcurrentHashMap的size,比较多次计算的结果,如果一致就认为当前没有并发的插入操作,计算结果是准确的。
    • 如果不一致,则为每个Segment加锁,然后计算size并返回。

JDK 1.8

  • 摒弃Segment的概念,直接使用Node数组,将Segment锁变为节点锁。
  • 执行put操作的时候,通过一次hash来定位Node的位置,如果该Node还没有初始化即通过CAS进行赋值。如果该Node已经初始化,则使用synchronized关键字来获得该Node的对象锁,成功获得锁后再在该Node中定位插入/修改的位置。

ConcurrentLinkedQueue

  • 非阻塞的线程安全队列
  • 使用链表作为底层数据结构,使用CAS来实现线程安全(?)

BlockingQueue

ArrayBlockingQueue

ArrayBlockingQueue 是 BlockingQueue 接口的有界队列实现类,底层采用数组来实现。ArrayBlockingQueue 一旦创建,容量不能改变。其并发控制采用可重入锁来控制,不管是插入操作还是读取操作,都需要获取到锁才能进行操作。当队列容量满时,尝试将元素放入队列将导致操作阻塞;尝试从一个空队列中取一个元素也会同样阻塞。
ArrayBlockingQueue 默认情况下不能保证线程访问队列的公平性,所谓公平性是指严格按照线程等待的绝对时间顺序,即最先等待的线程能够最先访问到 ArrayBlockingQueue。而非公平性则是指访问 ArrayBlockingQueue 的顺序不是遵守严格的时间顺序,有可能存在,当 ArrayBlockingQueue 可以被访问时,长时间阻塞的线程依然无法访问到 ArrayBlockingQueue。如果保证公平性,通常会降低吞吐量。如果需要获得公平性的 ArrayBlockingQueue,可采用如下代码:

LinkedBlockingQueue

LinkedBlockingQueue 底层基于单向链表实现的阻塞队列,可以当做无界队列也可以当做有界队列来使用,同样满足 FIFO 的特性,与 ArrayBlockingQueue 相比起来具有更高的吞吐量,为了防止 LinkedBlockingQueue 容量迅速增,损耗大量内存。通常在创建 LinkedBlockingQueue 对象时,会指定其大小,如果未指定,容量等于 Integer.MAX_VALUE。

PriorityBlockingQueue

PriorityBlockingQueue 是一个支持优先级的无界阻塞队列。默认情况下元素采用自然顺序进行排序,也可以通过自定义类实现 compareTo() 方法来指定元素排序规则,或者初始化时通过构造器参数 Comparator 来指定排序规则。

PriorityBlockingQueue 并发控制采用的是 ReentrantLock,队列为无界队列(ArrayBlockingQueue 是有界队列,LinkedBlockingQueue 也可以通过在构造函数中传入 capacity 指定队列最大的容量,但是 PriorityBlockingQueue 只能指定初始的队列大小,后面插入元素的时候,如果空间不够的话会自动扩容)。

简单地说,它就是 PriorityQueue 的线程安全版本。不可以插入 null 值,同时,插入队列的对象必须是可比较大小的(comparable),否则报 ClassCastException 异常。它的插入操作 put 方法不会 block,因为它是无界队列(take 方法在队列为空的时候会阻塞)。

ThreadLocal

用途

为每个线程提供一份独有的副本变量,多个线程互不干扰。

实现

Thread类有一个类型为ThreadLocal.ThreadLocalMap的实例变量threadLocals,即每个线程有一个自己的ThreadLocalMap
该Map的元素是Entry extends WeakReference<ThreadLocal<?>>,即继承了ThreadLocal<?>的一个弱引用。每个线程在向ThreadLocal里放值的时候,其实都在向自己的ThreadLocalMap里存值。读也是以ThreadLocal作为引用,在自己的map里找对应的key,从而实现线程隔离。ThreadLocalMap类似于HashMap的结构,同样以数组结构存储数据。
threadLocal
ThreadLocalset方法:用Thread.currentThread()通过getMap(Thread)方法取出当前线程对应的ThreadLocalMap。如果map为空则创建一个,如果不为空则将值加入map。

1
2
3
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}

createMap方法可以看出,ThreadLocalMap是线程对象(Thread)的一个成员变量。

ThreadLocalMap使用的Hash算法,如何解决冲突

  • ThreadLocalMap使用和HashMap类似的定位方式:
    1
    int i = key.threadLocalHashCode & (len - 1);
    threadLocalHashCode用于对键值的插入位置进行定位。因为ThreadLocalMap的Key都是同一个类型(泛型类ThreadLocal<?>的弱引用),因此可以在ThreadLocal类中定义静态属性来生成优化的HashCode。每个ThreadLocal对象在初始化时都会使用nextHashCode静态方法来确定自己的HashCode。而nextHashCode使用AtomicInteger来实现线程安全的HashCode生成。(斐波那契散列乘数?)
  • ThreadLocalMap不使用链表来解决哈希冲突,而是使用线性探测法。如果当前位置已经存在Entry、Entry的key与当前key不相等且非空,则向后寻找直到找到第一个Key与当前key相等或为空的Entry。

为什么key要用ThreadLocal对象的弱引用

回忆弱引用的定义:下一次GC时如果只有弱引用,则该对象一定会被回收。

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
// Entry的定义
/**
* The entries in this hash map extend WeakReference, using
* its main ref field as the key (which is always a
* ThreadLocal object). Note that null keys (i.e. entry.get()
* == null) mean that the key is no longer referenced, so the
* entry can be expunged from table. Such entries are referred to
* as "stale entries" in the code that follows.
*/
// 通过继承WeakRefernce<ThreadLocal>的方式实现"以ThreadLocal对象的弱引用作为Key"
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
// 此处完成WeakReference<ThreadLocal<?>>的初始化
super(k);
value = v;
}
}

// ThreadLocalMap内部使用Entry数组进行存储
//ThreadLocalMap构造方法
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
//内部成员数组,INITIAL_CAPACITY值为16的常量
table = new Entry[INITIAL_CAPACITY];
//位运算,结果与取模相同,计算出需要存放的位置
//threadLocalHashCode比较有趣
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
table[i] = new Entry(firstKey, firstValue);
size = 1;
setThreshold(INITIAL_CAPACITY);
}

当还有线程正在使用某个ThreadLocal对象时,这些线程的栈中会保留一个指向这一ThreadLocal对象的强引用。如果没有线程要使用这个ThreadLocal对象了(每个线程都将指向ThreadLocal对象的引用置为null),这个ThreadLocal对象就应该被垃圾回收。
如果某个线程没有完全结束,那么这个线程对应的Thread对象也不会被GC。而Thread对象里存在对ThreadLocalMap的引用(强引用)。如果ThreadLocalMap的key是对ThreadLocal的强引用,只要使用过这个ThreadLocal对象的线程没有都结束(还存在Thread对象未被GC),根据可达性分析,ThreadLocal对象会被视为可达,因为ThreadLocalMap持有对ThreadLocal的强引用,此时ThreadLocal对象不会被GC。如果ThreadLocalMap持有的是对ThreadLocal的弱引用,则此时ThreadLocal对象只存在弱引用,可以被GC。

  • 说人话:使用弱引用的目的是使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
class MyResource {
// 为每个线程提供独有副本变量
public ThreadLocal<String> myThreadLocal;
// ...
}

class Task implements Runnable {
public void run() {
MyResource threadLocalResource;
// 线程在通过ThreadLocal对象获取独占的副本变量的时候,会持有ThreadLocal的强引用
ThreadLocal<String> ref = threadLocalResource.myThreadLocal;
String str = myThreadLocal.get();
// 所有线程都使用完该资源之后,就不会有Task类的对象持有ThreadLocal对象的强引用
// 当所没有任何对象持有MyResource的强引用时,ThreadLocal对象应该随着MyResource对象一起被回收
// 但是每个Thread类中会持有一个ThreadLocalMap, 而ThreadLocalMap如果以ThreadLocal对象的强引用作为Key
// 那么因为此时仍有可达对象保留强引用指向ThreadLocal对象,该ThreadLocal对象直到所有线程都退出
// Thread对象被回收之前都不会被回收
// 所以应该使用弱引用,当指向ThreadLocal的引用只剩弱引用时,说明到ThreadLocal的引用只剩下ThreadLocalMap的Key
// 没有任何实际使用的线程,可以让其被回收
}

public static void createTask() {
Thread thread = new Thread(new Task());
thread.start();
}
}

Entry的内存泄漏问题

  • ThreadLocalMap中的Key在什么时候、哪个阶段被GC?
    Key是ThreadLocal对象的弱引用,当所有类都不存在对ThreadLocal对象的强引用时(所有线程都使用完了这个ThreadLocal对象),ThreadLocal对象会被回收。
  • 至于阶段,可能要看情况?如果ThreadLocal对象一直有其他的强引用(我把它挂到某个生命周期很长的对象上,甚至可以挂到静态属性上),它是不是可以撑过垃圾回收?(因为它不止有ThreadLocalMap中的弱引用)。
  • ThreadLocalMap中的Value在什么时候被GC?
    即使ThreadLocal被GC,Entry中对value的引用是强引用,因此key被GC时value不会被GC,但这个value永远不会被访问到了。并且除非所有使用过这个ThreadLocal的线程都退出(Thread对象被回收),GC算法会把整个ThreadLocalMapGC,此时ThreadLocal才会被GC。Java为了尽可能减小这种内存泄漏的影响,在ThreadLocalMap的get方法、set方法和remove方法中会清除ThreadLocalMap中key为null的value。
    • set方法:replaceStaleEntry方法,插入时发现key为空时调用该方法替换key已经为空的value值
    • get方法:?
    • remove方法:只有手动调用remove方法才是最为保险的做法。该方法将当前线程中ThreadLocalMap的对应Key和Value都删除。

Nginx学习笔记

是什么?

  • 一个TCP/UDP的通用服务器和反向代理服务器,可以支持HTTP、HTTPS、SMTP等协议。
  • 可以实现负载均衡和URL重写
  • nginx有一个master进程和多个worker进程。master进程的主要目的是读取并应用配置文件,并维护worker进程。worker进程会负责实际的请求处理。

为什么要反向代理

  1. 保护了真实的Web服务器,外网只能看到反向代理服务器,而反向代理服务器上没有真实数据。
  2. 可以减少Web服务器压力。
  3. 请求的统一控制,包括权限控制和过滤规则等。
  4. 区分动态和静态可缓存的内容。
  5. 负载均衡
  6. 解决跨域问题。反向代理不是单纯的请求转发,而是会代理用户向代理下的节点服务器重新发起请求。
  7. 作为真实服务器的缓冲。

配置文件的结构

nginx由多个模块组成,而这些模块由配置文件中的指令进行控制。指令被分为简单指令(单纯的一条指令,分号结尾)和块级指令(由花括号{}包裹)`。如果一个块级指令能够将其它指令包裹在它的花括号中,那么他被称为一个上下文(context)。

基本操作

安装

在CentOS 7上安装nginx

1
sudo yum install yum-utils

创建/etc/yum.repos.d/nginx.repo文件,文件内容为:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[nginx-stable]
name=nginx stable repo
baseurl=http://nginx.org/packages/centos/$releasever/$basearch/
gpgcheck=1
enabled=1
gpgkey=https://nginx.org/keys/nginx_signing.key
module_hotfixes=true

[nginx-mainline]
name=nginx mainline repo
baseurl=http://nginx.org/packages/mainline/centos/$releasever/$basearch/
gpgcheck=1
enabled=0
gpgkey=https://nginx.org/keys/nginx_signing.key
module_hotfixes=true

默认情况下,nginx packages使用nginx-stablerepository。如果要切换到mainline,则运行以下命令:
1
sudo yum-config-manager --enable nginx-mainline

随后,安装nginx
1
sudo yum install -y nginx

启动

1
nginx

使用-s参数控制nginx

1
2
3
4
5
6
7
8
9
10
ngingx -s SIGNAL
```
* `stop`: 快速关闭
* `quit`: graceful shutdown,会等待worker进程完成当前请求的处理。
* `reload`: 重新加载配置文件,不必重启就可以让配置生效
* 当master接收到这一信号时,它会检查新配置文件的语法正确性,并应用这一配置。如果这一步成功,master会启动新的worker进程并向旧的worker进程发送消息让他们关闭。否则,master会回滚更改并继续使用旧的配置来工作。旧的进程在接受到shutdown信息后会停止接受新请求,将当前的请求处理完后才退出。
* `reopen`: 重新打开日志文件

## 提供静态内容
在配置文件中的`http`块级指令中编写`server`块级指令。在`server`中加入`location`块级指令。

http {
server {

    # 定义URI匹配规则(最长匹配)
    location / {
        # 静态内容的根目录
        root /data/www;
    }
}

}

1
2
3

## 配置代理服务器
nginx的常见应用之一就是作为代理服务器,接受请求并将其转发给被代理的服务器,从被代理的服务器获取响应,然后将响应发送给客户端。配置方法是在`location`块级指令中加入`proxy_pass`指令。`location`指定的URI匹配可以编写正则表达式来表示更灵活的匹配规则,正则表达式以`~`开头

server {
location / {
proxy_pass http://localhost:8080;
}
location ~ .(gif|jpg|png)$ {
root /data/images;
}
}
1
2
3
4
5
6

## sendfile设置
`sendfile on`。高效文件传输模式(?),将tcp_nopush和tcp_nodelay设置为on防止网络阻塞(??)

## 配置负载均衡策略
* 用于从upstream模块定义的上游服务器列表中选取一台服务器接受用户的请求。

http {
upstream myServer {
server xxx.xxx.xxx.xxx:yyyy;
server yyy.yyy.yyy.yyy:zzzz;
server zzz.zzz.zzz.zzz:xxxx [upstream基本参数];
}

server {
    listen [端口];
    server_name [监听地址];
    location [正则] {
        root [根目录];
        index [默认页];
        proxy_pass myServer; # 将请求转发到myServer定义的服务器列表
        deny [拒绝的IP];
        allow [允许的IP];
    }
}

}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
### upstream基本参数
* 对每台服务器的一些配置
* fail_timeout, max_fails: 如果在fail_timeout时间内发往该服务器的请求失败了max_fail次,认为该服务器已经停机
* fail_time:服务器被认为停机的时间长度
* backup: 标记该服务器为备用服务器,出现停机时请求会发送到此处(hash、ip_hash和random负载均衡方法下该参数不可用)
* down: 标记服务器永久停机
* max_conns: 限制该服务器的最大同时活动连接数

### 轮询
上述的例子就是轮询负载均衡。轮询是upstream模块默认的负载均衡策略。每个请求按照时间顺序逐一分配到不同的服务器。
* Nginx默认就是使用一个加权的Round-Robin负载均衡方法。

### 权重
为每台服务器配置权重,在轮询时为权重高的后端服务器分配更多请求。
* a = 1, b = 2, c = 3
* 加权轮询的顺序可能是`{b, c, b, c, a, c}`。这个加权序列应该尽量让相同的节点尽量分散。

### ip_hash
每个请求按访问ip的hash结果分配,每个客户端固定访问一个后端服务器。可以使用该策略来实现粘性session。
如果对应的服务器不可用则会转发到其他服务器。

### hash
可以指定key进行hash,进而为请求分配指定的服务器。

upstream hashups {

# ip_hash;
hash [key] [consistent]; # 加入consistent参数使用一致性哈希
server ...;
server ...;

}
```

Nginx限流

控制单个IP的请求处理频率

  • ngx_http_limit_req_module
  • 定义内存区用于存储IP地址访问信息,设置rate=xxr/s可以设置每秒只处理每个IP地址的xx个请求
  • 控制突发流量: burst=xx
  • 控制单个IP的请求处理频率。

控制并发连接数

  • ngx_http_limit_conn_module

限流的原理

漏桶算法

桶的大小固定,请求依序进入桶中,但是进入桶中的请求只会以恒定的速率被处理(漏水的速率恒定)。如果进入桶中的请求溢出桶外,则这部分请求不会被处理。

令牌桶算法

桶的大小固定,按固定的速度往桶中丢令牌,桶满之后后续的令牌都无法添加。取令牌时,桶中有令牌,则处理取令牌操作并处理真正的请求。否则取令牌操作将被拒绝,请求也不会被处理。
请求在队列中进行排队取令牌,无法取得令牌时请求不会被处理,需要进行等待或直接拒绝请求。
令牌桶算法允许一定程度的突发请求。

原理

Nginx为什么快?

  • 多进程 + IO多路复用。单个进程处理请求遇到再次需要进行IO的情况时,会直接向多路复用器增加新的事件,然后继续处理别的请求。
  • Nginx也使用反应器模式,主事件循环等待操作系统发出IO事件准备完成的信号,这样数据就可以从Socket读取到缓冲区。

反向代理流程

  1. 用户通过域名发出访问Web服务器的请求,该域名被DNS服务器解析为反向代理服务器的IP地址。
  2. 反向代理服务器接收用户的请求。
  3. 反向代理服务器在本地缓存中查找请求的内容,找到后直接把内容发给用户。
  4. 如果本地缓存中没有用户所请求的内容,反向代理服务器代替用户向源服务器请求同样的信息内容,并把信息内容发给用户。如果信息内容是缓存的,还会把它存到缓存中。

Nginx请求处理

通过配置文件中配置的规则将本次请求映射到一个location block。Location中配置的各个指令启动不同的模块去完成工作。

多进程模型

  • 一个Master进程,多个Worker进程。
  • Master进程根据配置建立需要listen的网络socket文件描述符,然后fork出多个worker进程。同时Master还负责在不停止的情况下加载配置文件。
  • Worker进程会使用epoll监听同一个Socket fd。连接到来时对所有worker进程来说socket fd都可读。此时为保证只有一个进程处理该事件,worker进程需要抢占互斥锁accept_mutex,只有抢到互斥锁的进程才会调用accept接受链接。