重要先后顺序:
先:JVM,多线程并发,计算机网络,MySQL数据库。
后:其他所有
3)数据结构:数组、链表、栈、队列、树。
1)Java基础:JDK 常用类的原理、源码、List Set Map以及底层实现方式、ConcurrentHashMap以及HashMap底层实现、HashMap原理以及扩容、序列化等。
4)网络:五层结构、TCP、HTTP、 HTTPS、三次握手及四次挥手、拥塞控制策略算法。
10)多线程并发:线程池的原理以及参数、synchronize、volatile、lock、CAS以及悲观锁、锁升级、wait notify await signal sleep等信号量机制、创建线程的方式、ThreadLocal变量的原理、AQS、线程安全的HashMap等。
5)数据库(MySQL):索引原理、B+与B树、隔离级别、锁机制、分库分表、慢SQL定位及优化、数据库的事务、线上问题解决。
2)设计模式:常用几种的原理、使用场景,单例、动态代理、模板等。
6)操作系统:进程和线程及通信方式、系统调用、线程同步方式、调度算法、内存管理机制、快表、分页和分段、逻辑and物理地址、虚拟内存、置换算法。
8)框架:Spring loC 原理 Spring AOP原理和使用、Spring常用的扩展点、MyBatis的核心流程、bean生命周期。
9)Linux:基本命令的使用、快速定位和排查问题。
Java语言是面向对象的语言,其设计理念是“一切皆对象”。但8种基本数据类型却出现了例外,它们不具
备对象的特性。正是为了解决这个问题,Java为每个基本数据类型都定义了一个对应的引用类型,这就
是包装类。
扩展阅读
Java之所以提供8种基本数据类型,主要是为了照顾程序员的传统习惯。这8种基本数据类型的确带来了
一定的方便性,但在某些时候也会受到一些制约。比如,所有的引用类型的变量都继承于Object类,都
可以当做Object类型的变量使用,但基本数据类型却不可以。如果某个方法需要Object类型的参数,但
实际传入的值却是数字的话,就需要做特殊的处理了。有了包装类,这种问题就可以得以简化。
自动装箱:可以把一个基本类型的数据直接赋值给对应的包装类型;
自动拆箱:可以把一个包装类型的对象直接赋值给对应的基本类型;
通过自动装箱、自动拆箱功能,可以大大简化基本类型变量和包装类对象之间的转换过程。比如,某个方法的参数类型为包装类型,调用时我们所持有的数据却是基本类型的值,则可以不做任何特殊的处理,直接将这个基本类型的值传入给方法即可。
面向对象思想是一种更符合我们思考习惯的思想,并将我们从执行者变成了指挥者。面向对象是一种更优秀的程序设计方法,它的基本思想是使用类、对象、继承、封装、消息等基本概念进行程序设计。它从现实世界中客观存在的事物出发来构造软件系统,并在系统构造中尽可能运用人类的自然思维方式,强调直接以现实世界中的事物为中心来思考,认识问题,并根据这些事物的本质特点,把它们抽象地表示为系统中的类,作为系统的基本构成单元,这使得软件系统的组件可以直接映像到客观世界,并保持客观世界中事物及其相互关系的本来面貌。
扩展阅读
结构化程序设计方法主张按功能来分析系统需求,其主要原则可概括为自顶向下、逐步求精、模块化
等。结构化程序设计首先采用结构化分析方法对系统进行需求分析,然后使用结构化设计方法对系统进
行概要设计、详细设计,最后采用结构化编程方法来实现系统。
因为结构化程序设计方法主张按功能把软件系统逐步细分,因此这种方法也被称为面向功能的程序设计
方法;结构化程序设计的每个功能都负责对数据进行一次处理,每个功能都接受一些数据,处理完后输
出一些数据,这种处理方式也被称为面向数据流的处理方式。
结构化程序设计里最小的程序单元是函数,每个函数都负责完成一个功能,用以接收一些输入数据,函
数对这些输入数据进行处理,处理结束后输出一些数据。整个软件系统由一个个函数组成,其中作为程
序入口的函数被称为主函数,主函数依次调用其他普通函数,普通函数之间依次调用,从而完成整个软
件系统的功能。
每个函数都是具有输入、输出的子系统,函数的输入数据包括函数形参、全局变量和常量等,函数的输
出数据包括函数返回值以及传出参数等。结构化程序设计方式有如下两个局限性:
①设计不够直观,与人类习惯思维不一致。采用结构化程序分析、设计时,开发者需要将客观世界模
型分解成一个个功能,每个功能用以完成一定的数据处理。
②适应性差,可扩展性不强。由于结构化设计采用自顶向下的设计方式,所以当用户的需求发生改变,或需要修改现有的实现方式时,都需要自顶向下地修改模块结构,这种方式的维护成本相当高。
封装、继承、多态。
①封装:将对象的实现细节 隐藏起来,然后通过一些公用方法来暴露该对象的功能;
②继承:面向对象实现软件复用的重要手段,当 子类继承父类后,子类作为一种特殊的父类,将直接获得父类的属性和方法;
③多态:子类对象可以 直接赋给父类变量,但运行时依然表现出子类的行为特征,这意味着同一个类型的对象在执行同一个方法时,可能表现出多种行为特征。
扩展阅读
抽象也是面向对象的重要部分,抽象就是忽略一个主题中与当前目标无关的那些方面,以便更充分地注 意与当前目标有关的方面。抽象并不打算了解全部问题,而只
是考虑部分问题。例如,需要考察Person 对象时,不能在程序中把Person的所有细节都定义出来,通常只能定义Person的部分数据、部分行为特征,而这些数据、行为特征是软件系统所关心的部分。
封装是面向对象编程语言对客观世界的模拟,在客观世界里,对象的状态信息都被隐藏在对象内部,外 界无法直接操作和修改。对一个类或对象实现良好的封装,可以实现以下目的:
①隐藏类的实现细节;
②让使用者只能通过事先预定的方法来访问数据,从而可以在该方法里加入控制逻辑,限制对成员变 量的不合理访问;
③可进行数据检查,从而有利于保证对象信息的完整性;
④便于修改,提高代码的可维护性。
扩展阅读
为了实现良好的封装,需要从两个方面考虑:
①将对象的成员变量和实现细节隐藏起来,不允许外部直接访问;
②把方法暴露出来,让方法来控制对这些成员变量进行安全的访问和操作。
封装实际上有两个方面的含义:把该隐藏的隐藏起来,把该暴露的暴露出来。这两个方面都需要通过使 用Java提供的访问控制符来实现。
因为子类其实是一种特殊的父类,因此Java允许把一个子类对象直接赋给一个父类引用变量,无须任何 类型转换,或者被称为向上转型,向上转型由系统自动完成。当把一个子类对象直接赋给父类引用变量时,例如
BaseClass obj = new SubClass(); ,
这个obj引用变量的编译时类型是BaseClass,而运行时类型是SubClass,当运行时调用该引用变量的方法时,其 方法行为总是表现出子类方法的行为特征,而不是父
类方法的行为特征,这就可能出现:相同类型的变 量、调用同一个方法时呈现出多种不同的行为特征,这就是多态。
扩展阅读
多态可以提高程序的可扩展性,在设计程序时让代码更加简洁而优雅。例如我要设计一个司机类,他可以开轿车、巴士、卡车等等,示例代码如下:
在设计上述代码时,我已采用了重载机制,将方法名进行了统一。这样在进行调用时,无论要开什么交通工具,都是通过 这样的方式来调用,对调用者足够的友好。
但对于程序的开发者来说,这显得繁琐,因为实际上这个司机可以驾驶更多的交通工具。当系统需要为 这个司机增加车型时,开发者就需要相应的增加driver方法,类似的代码会堆积的越来越多,显得臃 肿。
采用多态的方式来设计上述程序,就会变得简洁很多。我们可以为所有的交通工具定义一个父类Vehicle,然后按照如下的方式设计drive方法。调用时,我们可以传入Vehicle类型的实例,也可以传入 任意的Vechile子类型的实例,对于调用者来说一样的方便,但对于开发者来说,代码却变得十分的简 洁了。
多态的实现离不开继承。
在设计程序时,我们可以将参数的类型定义为父类型。在调用程序时,则可以 根据实际情况,传入该父类型的某个子类型的实例,这样就实现了多态。对于父类型,可以有三种形 式,即普通的类、抽象类、接口。对于子类型,则要根据它自身的特征,重写父类的某些方法,或实现 抽象类/接口的某些抽象方法。
Object类提供了如下几个常用方法:
①Class> getClass():返回该对象的运行时类。
②boolean equals(Object obj):判断指定对象与该对象是否相等。
③int hashCode():返回该对象的hashCode值。在默认情况下,Object类的hashCode()方法根据该对象的地址来计算。但很多类都重写了Object类的hashCode()方法,不再根据地址来计算其hashCode()方法值。
④String toString():返回该对象的字符串表示,当程序使用System.out.println()方法输出一个对象,或者把某个对象和字符串进行连接运算时,系统会自动调用该对象的toString()方法返回该对 象的字符串表示。Object类的toString()方法返回 运行时类名@十六进制hashCode值 格式的字符串,但很多类都重写了Object类的toString()方法,用于返回可以表述该对象信息的字符串。
另外,Object类还提供了wait()、notify()、notifyAll()这几个方法,通过这几个方法可以控制线程的暂停和运行。Object类还提供了一个clone()方法,该方法用于帮助其他对象来实现“自我克隆”,所谓“自我克隆”就是得到一个当前对象的副本,而且二者之间完全隔离。由于该方法使用了protected修饰,因此它 只能被子类重写或调用。
hashCode()用于获取哈希码(散列码)。
eauqls()用于比较两个对象是否相等。
它们应遵守如下规定: 如果两个对象相等,则它们必须有相同的哈希码。如果两个对象有相同的哈希码,则它们未必相等。
Object类提供的equals()方法默认是用 == 来进行比较的,也就是说只有两个对象是 同一个对象时,才能返回相等的结果。
而实际中需求的是,若两个不同的对象它们的内容是相同的,就认为它们相等。
Object类中equals()方法的默认实现是没有实用价值的,所以通常都要重 写。由于hashCode()与equals()具有联动关系(参考“说一说hashCode()和equals()的关系”一题),所以equals()方法重写时,通常也要将hashCode()进行重写,使得这两个方法始终满足相关的约定。
==运算符:
①作用于基本数据类型时,是比较两个数值是否相等;
②作用于引用数据类型时,是比较两个对象的内存地址是否相同,即判断它们是否为同一个对象;
equals()方法:
①没有重写时,Object默认以 来实现,即比较两个对象的内存地址是否相同;
②进行重写后,一般会按照对象的内容来进行比较,若两个对象内容相同则认为对象相等,否则认为 对象不等。
String类是不可变类,即一旦一个String对象被创建以后,包含在这个对象中的字符序列是不可改变 的,直至这个对象被销毁。
StringBuffer对象则代表一个字符序列可变的字符串,当一个StringBuffer被创建以后,通过StringBuffer提供的append()、insert()、reverse()、setCharAt()、
setLength()等方法可以改变这个字符串对象的字符序列。一旦通过StringBuffer生成了最终想要的字符串,就可以调用它的toString()方法 将其转换为一个String对
象。
StringBuffer、StringBuilder都代表可变的字符串对象,它们有共同的父类AbstractStringBuilder ,并且两个类的构造方法和成员方法也基本相同。
不同的是StringBuffer 是线程安全的,而StringBuilder是非线程安全的,所以StringBuilder性能略高。一般情况下,要创建一 个内容可变的字符串,建议优先考虑StringBuilder类。
1.从设计目的上来说,二者有如下的区别:
接口体现的是一种规范。对于接口的实现者而言,接口规定了实现者必须向外提供哪些服务;对于接口 的调用者而言,接口规定了调用者可以调用哪些服务,以及如
何调用这些服务。当在一个程序中使用接 口时,接口是多个模块间的耦合标准;当在多个应用程序之间使用接口时,接口是多个程序之间的通信 标准。
抽象类体现的是一种模板式设计。抽象类作为多个子类的抽象父类,可以被当成系统实现过程中的中间 产品,这个中间产品已经实现了系统的部分功能,但这个产品依然不能当成最终产品,必须有更进一步 的完善,这种完善可能有几种不同方式。
2.从使用方式上来说,二者有如下的区别:
①接口里只能包含抽象方法、静态方法、默认方法和私有方法,不能为普通方法提供方法实现;抽象 类则完全可以包含普通方法。
②接口里只能定义静态常量,不能定义普通成员变量;抽象类里则既可以定义普通成员变量,也可以 定义静态常量。
③接口里不包含构造器;抽象类里可以包含构造器,抽象类里的构造器并不是用于创建对象,而是让 其子类调用这些构造器来完成属于抽象类的初始化操作。
④接口里不能包含初始化块;但抽象类则完全可以包含初始化块。
⑤一个类最多只能有一个直接父类,包括抽象类;但一个类可以直接实现多个接口,通过实现多个接 口可以弥补Java单继承的不足。
扩展阅读
接口和抽象类很像,它们都具有如下共同的特征:
①接口和抽象类都不能被实例化,它们都位于继承树的顶端,用于被其他类实现和继承。
②接口和抽象类都可以包含抽象方法,实现接口或继承抽象类的普通子类都必须实
现这些抽象方法。
在Java类里只能包含成员变量、方法、构造器、初始化块、内部类(包括接口、枚举)5种成员,而 static可以修饰成员变量、方法、初始化块、内部类(包括接口、
枚举),以static修饰的成员就是类成 员。类成员属于整个类,而不属于单个对象。
对static关键字而言,有一条非常重要的规则:类成员(包括成员变量、方法、初始化块、内部类和内 部枚举)不能访问实例成员(包括成员变量、方法、初始化块、内部类和内部枚举)。因为类成员是属 于类的,类成员的作用域比实例成员的 作用域更大,完全可能出现类成员已经初始化完成,但实例成员 还不曾初始化的情况,如果允许类成员访问实例成员将会引起大量错误。
static关键字可以修饰成员变量、成员方法、初始化块、内部类,被static修饰的成员是类的成员,它属 于类、不属于单个对象。以下是static修饰这4种成员时表现出
的特征:
①类变量:被static修饰的成员变量叫类变量(静态变量)。类变量属于类,它随类的信息存储在方 法区,并不随对象存储在堆中,类变量可以通过类名来访
问,也可以通过对象名来访问,但建议通 过类名访问它。
②类方法:被static修饰的成员方法叫类方法(静态方法)。类方法属于类,可以
通过类名访问,也 可以通过对象名访问,建议通过类名访问它。
③静态块:被static修饰的初始化块叫静态初始化块。静态块属于类,它在类加载的时候被隐式调用 一次,之后便不会被调用了。
④静态内部类:被static修饰的内部类叫静态内部类。静态内部类可以包含静态成员,也可以包含非 静态成员。静态内部类不能访问外部类的实例成员,只能访问外部类的静态成员。外部类的所有方 法、初始化块都能访问其内部定义的静态内部类。
final关键字可以修饰类、方法、变量,以下是final修饰这3种目标时
①表现出的特征: final类:final关键字修饰的类不可以被继承。
②final方法:final关键字修饰的方法不可以被重写。
③final变量:final关键字修饰的变量,一旦获得了初始值,就不可以被修改。
Java程序中的对象在运行时可以表现为两种类型,即编译时类型和运行时类型。例如。
person p=new student();
有时,程序在运行时接收到外部传入的一个对象,该对象的编译时类型是Object,
但程序又需要调用该 对象的运行时类型的方法。这就要求程序需要在运行时发现对
象和类的真实信息,而解决这个问题有以 下两种做法:
①第一种做法是假设在编译时和运行时都完全知道类型的具体信息,在这种情况
下,可以先使用instanceof运算符进行判断,再利用强制类型转换将其转换成其运行时类型的变量即可。
②第二种做法是编译时根本无法预知该对象和类可能属于哪些类,程序只依靠运行时信息来发现该对 象和类的真实信息,这就必须使用反射。
具体来说,通过反射机制,我们可以实现如下的操作:程序运行时,可以通过反射获得任意一个类的Class对象,并通过这个对象查看这个类的信息; 程序运行时,可以通过反射创建任意一个类的实例,并访问该实例的成员;
程序运行时,可以通过反射机制生成一个类的动态代理类或动态代理对象。
Java对象的四种引用方式分别是强引用、软引用、弱引用、虚引用,具体含义如下:
强引用:这是Java程序中最常见的引用方式,即程序创建一个对象,并把这个
对象赋给一个引用变 量,程序通过该引用变量来操作实际的对象。当一个对象
被一个或一个以上的引用变量所引用时, 它处于可达状态,不可能被系统垃
圾回收机制回收。
软引用:当一个对象只有软引用时,它有可能被垃圾回收机制回收。对于只有
软引用的对象而言, 当系统内存空间足够时,它不会被系统回收,程序也可使
用该对象。当系统内存空间不足时,系统 可能会回收它。软引用通常用于对
内存敏感的程序中。
弱引用:弱引用和软引用很像,但弱引用的引用级别更低。对于只有弱引用的
对象而言,当系统垃 圾回收机制运行时,不管系统内存是否足够,总会回收该
对象所占用的内存。当然,并不是说当一 个对象只有弱引用时,它就会立即被
回收,正如那些失去引用的对象一样,必须等到系统垃圾回收 机制运行时才会
被回收。
虚引用:虚引用完全类似于没有引用。虚引用对对象本身没有太大影响,对象
甚至感觉不到虚引用 的存在。如果一个对象只有一个虚引用时,那么它和没有
引用的效果大致相同。虚引用主要用于跟 踪对象被垃圾回收的状态,虚引用
不能单独使用,虚引用必须和引用队列联合使用。
JVM 主要由四大部分组成:ClassLoader(类加载器),Runtime Data Area(运行时数据区,内存分区),Execution Engine(执行引擎),Native Interface(本地库接口),下图可以大致描述 JVM 的结构。
JVM 是执行 Java 程序的虚拟计算机系统,那我们来看看执行过程:首先需要准备好编译好的 Java 字节码文件(即class文件),计算机要运行程序需要先通过一定方式(类加载器)将 class 文件加载到内存中(运行时数据区),但是字节码文件
是JVM定义的一套指令集规范,并不能直接交给底层操作系统去 执行,因此需要特定的命令解释器(执行引擎)将字节码翻译成特定的操作系统指令集交给 CPU 去执行,这个过程中会需要调用到一些不同语言为 Java 提供的接口(例如驱动、地
图制作等),这就用到了本地 Native 接口(本地库接口)。
①ClassLoader:负责加载字节码文件即 class 文件,class 文件在文件开头有特定的文件标示,并且ClassLoader 只负责class 文件的加载,至于它是否可以运行,则由 Execution Engine 决定。
②Runtime Data Area:是存放数据的,分为五部分:Stack(虚拟机栈),Heap(堆),Method Area(方法区),PCRegister(程序计数器),Native Method Stack(本地方法栈)。几乎所有
的关于 Java 内存方面的问题,都是集中在这块。
③Execution Engine:执行引擎,也叫 Interpreter。Class 文件被加载后,会把指令和数据信息放入内存中,Execution Engine 则负责把这些命令解释给操作系统,即将 JVM 指令集翻译为操作系统指令集。
④Native Interface:负责调用本地接口的。他的作用是调用不同语言的接口给JAVA 用,他会在Native Method Stack 中记录对应的本地方法,然后调用该方法时就通过 Execution Engine 加载对应的本地 lib。原本多用于一些专业领域,如JAVA驱动,地图制作引擎等,现在关于这种本地方法接口的调用已经被类似于Socket通信,WebService等方式取代。
JVM的启动过程分为如下四个步骤:
JVM的装入环境和配置
java.exe负责查找JRE,并且它会按照如下的顺序来选择JRE: 自己目录下的JRE;父级目录下的JRE;查注册中注册的JRE。
装载JVM
通过第一步找到JVM的路径后,Java.exe通过LoadJavaVM来装入JVM文件。LoadLibrary 装 载 JVM 动 态 连 接 库 , 然 后 把 JVM 中 的 到 处 函 数JNI_CreateJavaVM 和 JNI_GetDefaultJavaVMIntArgs 挂接到InvocationFunction 变量的CreateJavaVM和GetDafaultJavaVMInitArgs 函数指针变量上。JVM的装载工作完成。
初始化JVM
获得本地调用接口调用InvocationFunction -> CreateJavaVM,也就是JVM中JNI_CreateJavaVM方法获得JNIEnv结构的实例。
运行Java程序
JVM运行Java程序的方式有两种:jar包 与 class。运行jar 的时候,java.exe调用GetMainClassName函数,该函数先获得JNIEnv实例然后调用JarFileJNIEnv类中getManifest(),从其返回的Manifest对象中取getAttrebutes(“Main-Class”)的 值,即jar 包中文件:META-INF/MANIFEST.MF指定的Main-Class的主类名作为运行的主类。之后main函数会调用Java.c中LoadClass方法装载该主类(使用JNIEnv实例的FindClass)。运行Class的时候,main函数直接调用Java.c中的LoadClass方法装载该类。
Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据
区域。这些区域有各 自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程
的启动而一直存在,有些区域则是依赖用 户线程的启动和结束而建立和销毁。根据
《Java虚拟机规范》的规定,Java虚拟机所管理的内存将会包 括以下几个运行时数
据区域。
程序计数器
程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在Java虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计 数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异 常处理、线程恢复等基础功能都需要依赖这个计数器来完成。由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个 确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因 此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线 程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址; 如果正在执行的是本地(Native)方法,这个计数器值则应为空(Undefined)。此内存区域是唯 一一个在《Java虚拟机规范》中没有规定任何OutOfMemoryError情况的区域。
Java虚拟机栈
与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stack)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚 拟机都会同步创建一个栈帧[插图](Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈 到出栈的过程。在《Java虚拟机规范》中,对这个内存区域规定了两类异常状况:如果线程请求的栈深度大于虚拟 机所允许的深度,将抛出StackOverflowError异常;如果Java虚拟机栈容量可以动态扩展,当栈扩 展时无法申请到足够的内存会抛出OutOfMemoryError异常。
本地方法栈
本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。《Java虚拟机规范》对本地方法栈中方法使用的语言、使用方式与数据结构并没有任何强制规定, 因此具体的虚拟机可以根据需要自由实现它,甚至有的Java虚拟机(譬如Hot-Spot虚拟机)直接 就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展 失败时分别抛出StackOverflowError和OutOfMemoryError异常。
Java堆
对于Java应用程序来说,Java堆(Java Heap)是虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例, Java世界里“几乎”所有的对象实例都在这里分配内存。在《Java虚拟机规范》中对Java堆的描述是:“所有的对象实例以及数组都应当在堆上分配”,而这里笔者写的“几乎”是指从实现角度来看, 随着Java语言的发展,现在已经能看到些许迹象表明日后可能出现值类型的支持,即使只考虑现 在,由于即时编译技术的进步,尤其是逃逸分析技术的日渐强大,栈上分配、标量替换优化手段已 经导致一些微妙的变化悄然发生,所以说Java对象实例都分配在堆上也渐渐变得不是那么绝对了。根据《Java虚拟机规范》的规定,Java堆可以处于物理上不连续的内存空间中,但在逻辑上它应该 被视为连续的,这点就像我们用磁盘空间去存储文件一样,并不要求每个文件都连续存放。但对于 大对象(典型的如数组对象),多数虚拟机实现出于实现简单、存储高效的考虑,很可能会要求连 续的内存空间。Java堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的Java虚拟机都是按照可扩展来实现的(通过参数-Xmx和-Xms设定)。如果在Java堆中没有内存完成实例分配,并且堆也无 法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常。
方法区
方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。虽然《Java虚拟机规范》中 把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫作“非堆”(Non-Heap),目的是与 Java堆区分开来。根据《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出OutOfMemoryError异常。
运行时常量池
运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时 会抛OutOfMemoryError异常。
直接内存
直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现。显然,本机直接内存的分配不会受到Java堆大小的限制,但是,既然是内存,则肯定还是会受到本 机总内存(包括物理内存、SWAP分区或者分页文件)大小以及处理器寻址空间的限制,一般服务 器管理员配置虚拟机参数时,会根据实际内存去设置-Xmx等参数信息,但经常忽略掉直接内存, 使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展 时出现OutOfMemoryError异常。
一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备、解析三个部分统 称为连接(Linking)。这七个阶段的发生顺序如下图所示。
在上述七个阶段中,包括了类加载的全过程,即加载、验证、准备、解析和初始化这五个阶段。
一、加载
“加载”(Loading)阶段是整个“类加载”(Class Loading)过程中的一个阶段,在加载阶段,Java虚拟机需要完成以下三件事情:
二、验证
验证是连接阶段的第一步,这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规 范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。验证阶段大致上会 完成下面四个阶段的检验动作:文件格
式验证、元数据验证、字节码验证和符号引用验证。
三、准备
准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值 的阶段。从概念上讲,这些变量所使用的内存都应当在方法区中进行分配,但必须注意到方法区本身是 一个逻辑上的区域,在JDK7及之前,HotSpot使用永久代来实现方法区时,实现是完全符合这种逻辑概 念的。而在JDK8及之后,类变量则会随着Class对象一起存放在Java堆中,这时候“类变量在方法区”就完全是一种对逻辑概念的表述了。
四、解析
解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程,符号引用在Class文件中以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量出 现,那解析阶段中所说的直接引用与符号引用又有什么关联呢?符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目 标并不一定是已经加载到虚拟机内存当中的内容。各种虚拟机实现的内存布局可以各不相同,但是它们 能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在《Java虚拟机规范》的Class文件格式中。直接引用(Direct References):直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚拟机实 例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中 存在。
五、初始化
类的初始化阶段是类加载过程的最后一个步骤,之前介绍的几个类加载的动作里,除了在加载阶段用户 应用程序可以通过自定义类加载器的方式局部参与外,其余动作都完全由Java虚拟机来主导控制。直到 初始化阶段,Java虚拟机才真正开始执行
类中编写的Java程序代码,将主导权移交给应用程序。进行准备阶段时,变量已经赋过一次系统要求的初始零值,而在初始化阶段,则会根据程序员通过程序 编码制定的主观计划去初始化类变量和其他资源。我们也可以从另外一种更直接的形式来表达:初始化 阶段就是执行类构造器() 方法的过程。 () 并不是程序员在Java代码中直接编写的方法,它是Javac编译器的自动生成物。
一、哪些内存需要回收
在Java内存运行时区域的各个部分中,堆和方法区这两个区域则有着很显著的不确定性:一个接口的多 个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有 处于运行期间,我们才能知道程序究竟会
创建哪些对象,创建多少个对象,这部分内存的分配和回收是 动态的。垃圾收集器所关注的正是这部分内存该如何管理,我们平时所说的内存分配与回收也仅仅特指这一部分内存。
二、怎么定义
①垃圾引用计数算法:
在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值 就减一;任何时刻计数器为零的对象就是不可能再被使用的。但是,在Java领域,至少主流的Java虚拟机里面都没有选用引用计数算法来管理内
存,主要原因是,这 个看似简单的算法有很多例外情况要考虑,必须要配合大量额外处理才能保证正确地工作,譬如单纯的 引用计数就很难解决对象之间相互循环引用的问题。
举个简单的例子:对象objA和objB都有字段instance,赋值令objA.instance=objB及objB.instance=objA,除此之外,这两个对象再无任何引用,实际上这两个对象已经不可能再被访问, 但是它们因为互相引用着对方,导致它们的引用计数都不为零,引用计数算法也就无法回收它们。
②可达性分析算法:
当前主流的商用程序语言的内存管理子系统,都是通过可达性分析(Reachability Analysis)算法来判定对象是否存活的。这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain), 如果某个对象到GCRoots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。如下图所示,对象object 5、object 6、object 7虽然互有关联,但是它们到GCRoots是不可达的,因此它们将会被判定为可回收的对象。
在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:
1.在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参 数、局部变量、临时变量等。
2.在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
3.在方法区中常量引用的对象,譬如字符串常量池(StringTable)里的引用。在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
4.Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
5.所有被同步锁(synchronized关键字)持有的对象。
6.反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
回收方法区:
方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。回收废弃常量与回收Java堆中 的对象非常类似。举个常量池中字面量回收的例子,假如一个字符串“java”曾经进入常量池中,但是当 前系统又没有任何一个字符串对象的值是“java”,换句话说,已经没有任何字符串对象引用常量池中的“java”常量,且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收,而且垃圾收集器判断确有必要的话,这个“java”常量就将会被系统清理出常量池。常量池中其他类(接口)、方法、字 段的符号引用也与此类似。判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方 法。
三、怎么回收
①垃圾分代收集理论:
当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”(GenerationalCollection)的理论进行设 计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说 之上:
②标记-清除算法:
最早出现也是最基础的垃圾收集算法是“标记-清除”(Mark-Sweep)算法,如它的名字一样,算法分为 “标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。它的主要缺点有两个:第一个是执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要 被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增 长而降低;第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片 太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。标记-清除算法的执行过程如下图所示。
③标记-复制算法:
为了解决标记-清除算法面对大量可回收对象时执行效率低的问题,1969年Fenichel提出了一种称为“半 区复制”(Semispace Copying)的垃圾收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使 用过的内存空间一次清理掉。如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对 整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序 分配即可。这样实现简单,运行高效,不过其缺陷也显而易见,这种复制回收算法的代价是将可用内存 缩小为了原来的一半,空间浪费未免太多了一点。标记-复制算法的执行过程如下图所示。
在1989年,Andrew Appel针对具备“朝生夕灭”特点的对象,提出了一种更优化的半区复制分代策略, 现在称为“Appel式回收”。Appel式回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和
其中一块Survivor。发生垃圾搜集时,将Eden和Survivor中 仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor 空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,也即每次新生代中可用
内存空间为整个 新生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代 是会被“浪费”的。当然,98%的对象可被回收仅仅是“普通场景”下测得的数据,任何人都没有办法百分 百保证每次回收都只有不多于10%的对象存活,因此Appel式回收还有一个充当罕见情况的“逃生门”的 安全设计,当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion)。
④标记-整理算法:
标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是,如果不想 浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的 极端情况,所以在老年代一般不能直接选用这种算法。针对老年代对象的存亡特征,1974年Edward Lueders提出了另外一种有针对性的“标记-整理”(Mark- Compact)算法,其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行 清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存,“标记-整理”算 法的示意图如下图所示。
当 前 商 业 虚 拟 机 的 垃 圾 收 集 器 , 大 多 数 都 遵 循 了 “ 分代收集 ”(GenerationalCollection)[插图]的理论进 行设计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代 假说之上:
这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同 的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存 储。把分代收集理论具体放到现在的商用
Java虚拟机里,设计者一般至少会把Java堆划分为新生代(Young Generation)和老年代(Old Generation)两个区域。顾名思义,在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。分代收集并非只是简单划分一下内存区域那么容易,它至少存在一个明显的困难:对象不是孤立的,对 象之间会存在跨代引用。假如要现在进行一次只局限于新生代区域内的收集,但新生代中的对象是完全 有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的GC Roots之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反过来也是一样。遍历整个老年代所有对象的方案虽然理论上可行,但无疑会为内存回收带来很大的性能负担。为了解决这个问题,就需要对分代收 集理论添加第三条经验法则:
新创建的对象一般会被分配在新生代中,常用的新生代的垃圾回收器是 ParNew垃圾回收器,它按照8:1:1 将新生代分成 Eden 区,以及两个 Survivor 区。某一时刻,我们创建的对象将 Eden 区全部挤 满,这个对象就是挤满新生代的最后一个对象。此时,Minor GC 就触发了。在正式 Minor GC 前,JVM 会先检查新生代中对象,是比老年代中剩余空间大还是小。为什么要做这样的检查呢?原因很简单,假如 Minor GC 之后 Survivor 区放不下剩余对象,这些对象就要进入到老年代,所以要提前检查老年代是不是够用。这样就有两种情况:
老年代剩余空间大于新生代中的对象大小,那就直接Minor GC,GC完survivor不够放,老年代也
绝对够放;
老年代剩余空间小于新生代中的对象大小,这个时候就要查看是否启用了“老年代空间分配担保规则”,具体来说就是看-XX:-HandlePromotionFailure 参数是否设置了。老年代空间分配担保规则是这样的,如果老年代中剩余空间大小,大于历次
Minor GC 之后剩余对象的大小,那就允许进行 Minor GC。因为从概率上来说,以前的放的下,这次的也应该放的下。那就有两种情况:老年代中剩余空间大小,大于历次Minor GC之后剩余对象的大小,进行 MinorGC;老年代中剩余空间大小,小于历次Minor GC之后剩余对象的大小,进行Full GC,把老年代空出来再检查。开启老年代空间分配担保规则只能说是大概率上来说,Minor GC 剩余后的对象够放到老年代,所以当然也会有万一,Minor GC 后会有这样三种情况:
Minor GC 之后的对象足够放到 Survivor 区,皆大欢喜,GC 结束;
Minor GC 之后的对象不够放到 Survivor 区,接着进入到老年代,老年代能放下,那也可以,GC结束;
Minor GC 之后的对象不够放到 Survivor 区,老年代也放不下,那就只能 Full GC。
前面都是成功 GC 的例子,还有 3 中情况,会导致 GC 失败,报 OOM:
①引用计数算法:
在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值 就减一;任何时刻计数器为零的对象就是不可能再被使用的。 但是,在Java领域,至少主流的Java虚拟机里面都没有选用引用计数算法来管理内
存,主要原因是,这 个看似简单的算法有很多例外情况要考虑,必须要配合大量额外处理才能保证正确地工作,譬如单纯的 引用计数就很难解决对象之间相互循环引用的问题。举个简单的例子:对象objA和objB都有字段instance,赋值令objA.instance=objB及objB.instance=objA,除此之外,这两个对象再无任何引用,实际上这两个对象已经不可能再被访问, 但是它们因为互相引用着对方,导致它们的引用计数都不为零,引用计数算法也就无法回收它们。
②可达性分析算法:
当前主流的商用程序语言的内存管理子系统,都是通过可达性分析(Reachability Analysis)算法来判定对象是否存活的。这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain), 如果某个对象到GCRoots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。如下图所示,对象object 5、object 6、object 7虽然互有关联,但是它们到GCRoots是不可达的,因此它们将会被判定为可回收的对象。
在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:
1.在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参 数、局部变量、临时变量等。
2.在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
3.在方法区中常量引用的对象,譬如字符串常量池(StringTable)里的引用。在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
4.Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
5.所有被同步锁(synchronized关键字)持有的对象。
6.反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
内存泄漏的根本原因是长生命周期的对象持有短生命周期对象的引用,尽管短生命
周期的对象已经不再 需要,但由于长生命周期对象持有它的引用而导致不能被回收。
以发生的方式来分类,内存泄漏可以分 为4类:
避免内存泄漏的几点建议:
①尽早释放无用对象的引用。
②避免在循环中创建对象。
③使用字符串处理时避免使用String,应使用StringBuffer。
④尽量少使用静态变量,因为静态变量存放在永久代,基本不参与垃圾回收。
内存溢出(out of memory):简单地说内存溢出就是指程序运行过程中申请的内
存大于系统能够提供的内存,导致无法申请到足够的内存,于是就发生了内存溢
出。引起内存溢出的原因有很多种,常见的有以下几种:
内存溢出的解决方案:
第一步,修改JVM启动参数,直接增加内存。
第二步,检查错误日志,查看“OutOfMemory”错误前是否有其它异常或错误。
第三步,对代码进行走查和分析,找出可能发生内存溢出的位置。
第四步,使用内存查看工具动态查看内存使用情况。
Java中的集合类主要由Collection和Map这两个接口派生而出,其中Collection接口
又派生出三个子接 口,分别是Set、List、Queue。所有的Java集合类,都是Set、
List、Queue、Map这四个接口的实现 类,这四个接口将集合分成了四大类,其
中
Set代表无序的,元素不可重复的集合;
List代表有序的,元素可以重复的集合;
Queue代表先进先出(FIFO)的队列;
Map代表具有映射关系(key-value)的集合。
这些接口拥有众多的实现类,其中最常用的实现类有HashSet、TreeSet、ArrayList、LinkedList、ArrayDeque、HashMap、TreeMap等。
java.util包下的集合类大部分都是线程不安全的,例如我们常用的HashSet、
TreeSet、ArrayList、LinkedList、ArrayDeque、HashMap、TreeMap,这些都
是线程不安全的集合类,但是它们的优点是性能好。如果需要使用线程安全的集合
类,则可以使用Collections工具类提供的synchronizedXxx()方法,将这些集合类
包装成线程安全的集合类。
java.util包下也有线程安全的集合类,例如Vector、Hashtable。这些集合类都是
比较古老的API,虽然实现了线程安全,但是性能很差。所以即便是需要使用线程安
全的集合类,也建议将线程不安全的集合 类包装成线程安全集合类的方式,而不是
直接使用这些古老的API。
从Java5开始,Java在java.util.concurrent包下提供了大量支持高效并发访问的集
合类,它们既能包装良好的访问性能,有能包装线程安全。这些集合类可以分为两
部分,它们的特征如下:
①以Concurrent开头的集合类:
以Concurrent开头的集合类代表了支持并发访问的集合,它们可以支持多个
线程并发写入访问, 这些写入线程的所有操作都是线程安全的,但读取操作不
必锁定。以Concurrent开头的集合类采用了更复杂的算法来保证永远不会锁
住整个集合,因此在并发写入时有较好的性能。
②以CopyOnWrite开头的集合类:
以CopyOnWrite开头的集合类采用复制底层数组的方式来实现写操作。当线程
对此类集合执行读 取操作时,线程将会直接读取集合本身,无须加锁与阻塞。
当线程对此类集合执行写入操作时,集 合会在底层复制一份新的数组,接下来
对新的数组执行写入操作。由于对集合的写入操作都是对数 组的副本执行操
作,因此它是线程安全的。
它基于hash算法,通过put方法和get方法存储和获取对象。
存储对象时,我们将K/V传给put方法时,它调用K的hashCode计算hash从而得到
bucket位置,进一步 存储,HashMap会根据当前bucket的占用情况自动调整容量
(超过Load Facotr则resize为原来的2倍)。获取对象时,我们将K传给get,它调
用hashCode计算hash从而得到bucket位置,并进一步调用equals()方法确定键
值对。
如果发生碰撞的时候,HashMap通过链表将产生碰撞冲突的元素组织起来。在
Java 8中,如果一个bucket中碰撞冲突的元素超过某个限制(默认是8),则使
用红黑树来替换链表,从而提高速度。
HashMap在并发执行put操作时,可能会导致形成循环链表,从而引起死循环。
扩展阅读
从Hashtable的类名上就可以看出它是一个古老的类,它的命名甚至没有遵守Java
的命名规范:每个单 词的首字母都应该大写。也许当初开发Hashtable的工程师也
没有注意到这一点,后来大量Java程序中 使用了Hashtable类,所以这个类名也
就不能改为HashTable了,否则将导致大量程序需要改写。与Vector类似的是,尽量少用Hashtable实现类,即使需要创建线程安全的Map实现类,也无须使用Hashtable实现类,可以通过Collections工具类把HashMap变成线程安全的Map。
HashMap是非线程安全的,这意味着不应该在多线程中对这些Map进行修改操
作,否则会产生数据不一致的问题,甚至还会因为并发插入元素而导致链表成环,
这样在查找时就会发生死循环,影响到整个 应用程序。
Collections工具类可以将一个Map转换成线程安全的实现,其实也就是通过一个包
装类,然后把所有功 能都委托给传入的Map,而包装类是基于synchronized关键
字来保证线程安全的(Hashtable也是基于synchronized关键字),底层使用的
是互斥锁,性能与吞吐量比较低。
ConcurrentHashMap的实现细节远没有这么简单,因此性能也要高上许多。它没有
使用一个全局锁来 锁住自己,而是采用了减少锁粒度的方法,尽量减少因
为竞争锁而导致的阻塞与冲突,而且ConcurrentHashMap的检索操作是不需
要锁的。
JDK 1.7中的实现:
在 jdk 1.7 中,ConcurrentHashMap 是由 Segment 数据结构和 HashEntry 数组
结构构成,采取分段锁来保证安全性。Segment 是 ReentrantLock 重入锁,在
ConcurrentHashMap 中扮演锁的角色, HashEntry 则用于存储键值对数据。一
个 ConcurrentHashMap 里包含一个 Segment 数组,一个Segment 里包含一个
HashEntry 数组,Segment 的结构和 HashMap 类似,是一个数组和链表结构。
JDK 1.8中的实现:
JDK1.8 的实现已经摒弃了 Segment 的概念,而是直接用 Node 数组+链表+红黑树
的数据结构来实现,并发控制使用 Synchronized 和 CAS 来操作,整个看起来就
像是优化过且线程安全的 HashMap,虽然在 JDK1.8 中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本。
get操作:
Segment的get操作实现非常简单和高效,先经过一次再散列,然后使用这个散列值通过散列运算定位到 Segment,再通过散列算法定位到元素。get操作的高效之处在于整个get过程都不需要加锁,除非读到空的值才会加锁重读。原因就是将使用的共享变量定义成 volatile 类型。
put操作:
当执行put操作时,会经历两个步骤:
1.判断是否需要扩容;
2.定位到添加元素的位置,将其放入 HashEntry 数组中。
插入过程会进行第一次 key 的 hash 来定位 Segment 的位置,如果该 Segment 还没有初始化,即通过 CAS 操作进行赋值,然后进行第二次 hash 操作,找到相应的 HashEntry 的位置,这里会利用继承过来的锁的特性,在将数据插入指定的 HashEntry 位置时(尾插法),会通过继承 ReentrantLock 的 tryLock() 方法尝试去获取锁,如果获取成功就直接插入相应的位置,如果已经有线程获取该Segment的锁,那当前线程会以自旋的方式去继续的调用 tryLock() 方法去获取锁,超过指定次数就挂起,等待唤醒。
Set代表无序的,元素不可重复的集合;
Map代表具有映射关系(key-value)的集合,其所有的key是一个Set集合,即key无序且不能重复。
Set代表无序的,元素不可重复的集合;
List代表有序的,元素可以重复的集合。
Vector
Vector是比较古老的API,虽然保证了线程安全,但是由于效率低一般不建议使用。
Collections.SynchronizedList
SynchronizedList是Collections的内部类,Collections提供了synchronizedList
方法,可以将一个线程不安全的List包装成线程安全的List,即SynchronizedList。它比Vector有更好的扩展性和兼容性,但是它所有的方法都带有同步锁,也不是性能最优的List。
CopyOnWriteArrayList
CopyOnWriteArrayList是Java 1.5在java.util.concurrent包下增加的类,它采
用复制底层数组的方式来实现写操作。当线程对此类集合执行读取操作时,线
程将会直接读取集合本身,无须加锁与阻 塞。当线程对此类集合执行写入操作
时,集合会在底层复制一份新的数组,接下来对新的数组执行 写入操作。由于
对集合的写入操作都是对数组的副本执行操作,因此它是线程安全的。在所有
线程 安全的List中,它是性能最优的方案。
ArrayList的底层是用数组来实现的,默认第一次插入元素时创建大小为10的数组,超出限制时会增加(system.arraycopy())50%的容量,并且数据以值。复制到新的数组,因此最好能给出数组大小的预估按数组下标访问元素的性能很高,这是数组的基本优势。直接在数组末尾加入元素的性能也高,但如果(system.arraycopy())按下标插入、删除元素,则要用来移动部分受影响的元素,性能就变差了,这是基本劣势。
序列化机制可以将对象转换成字节序列,这些字节序列可以保存在磁盘上,也可以
在网络中传输,并允 许程序将这些字节序列再次恢复成原来的对象。其中,对象的
序列化(Serialize),是指将一个Java对 象写入IO流中,对象的反序列化
(Deserialize),则是指从IO流中恢复该Java对象。
若对象要支持序列化机制,则它的类需要实现Serializable接口,该接口是一个标记
接口,它没有提供任 何方法,只是标明该类是可以序列化的,Java的很多类已经实
现了Serializable接口,如包装类、String、Date等。
若要实现序列化,则需要使用对象流ObjectInputStream和ObjectOutputStream。其中,在序列化时 需要调用ObjectOutputStream对象的writeObject()方法,以输出对象序列。在反序列化时需要调用ObjectInputStream对象的readObject()方法,将对象序列恢复为对象。
答题思路, 以下5点:
1.从不同的角度看, 会有不同的答案。
2.典型答案是两种, 分别是实现Runnable接口和继承Thread类, 然后具体展开说;
3.但是, 我们看原理, 其实Thread类实现了Runnable接口, 并且看Thread类的run方法, 会发现其 实那两种本质都是一样的, run方法的代码如下:
public void run() {
if (target != null){
target.run ()
}
}
方法一和方法二,也就是 “实现Runnable接口并传入Thread类” 和 “继承Thread类然后重写run ()” 在实现多线程的本质上, 并没有区别, 都是最终调用了 start() 方法来新建线程。这两个方法的最主 要区别在于 run () 方法的内容来源:
方法一:最终调用target. run ();
方法二: run() 整个都被重写。
4.然后具体展开说其他方式: 还有其他的实现线程的方法, 例如线程池、定时器, 它们也能新建 线程, 但是细看源码, 从没有逃出过本质, 也就是实现Runnable接口和继承Thread类。
5结论:我们只能通过新建Thread类这一种方式来创建线程, 但是类里面的run方法有两种方式来 实现, 第一种是重写run方法, 第二种实现Runnable接口的run方法, 然后再把该runnable实例传给 Thread类。除此之外, 从表面上看线程池、定时器等工具类也可以创建线程, 但是它们的本质都逃不 出刚才所说的范围。
以上这种描述比直接回答一种、两种、多种都更准确。
1.Thread类常用构造方法:
Thread()
Thread(String name)
Thread(Runnable target)
Thread(Runnable target, String name)
其中name为线程名,target为包含线程体的目标对象。
2.Thread类常用静态方法:
currentThread():返回当前正在执行的线程;
interrupted():返回当前执行的线程是否已经被中断;
sleep(long millis):使当前执行的线程睡眠多少毫秒数;
yield():使当前执行的线程自愿暂时放弃对处理器的使用权并允许其他线程执行;
3.Thread类常用实例方法:
getId():返回该线程的id;
getName():返回该线程的名字;
getPriority():返回该线程的优先级;
interrupt():使该线程中断;
isInterrupted():返回该线程是否被中断;
isAlive():返回该线程是否处于活动状态;
isDaemon():返回该线程是否是守护线程;
setDaemon(boolean on):将该线程标记为守护线程或用户线程,如果不
标记默认是非守护线程;
setName(String name):设置该线程的名字;
setPriority(int newPriority):改变该线程的优先级;
join():等待该线程终止;
join(long millis):等待该线程终止,至多等待多少毫秒数。
run()方法被称为线程执行体,它的方法体代表了线程需要完成的任务,而start()方法才是真正启动线程。调用start()方法启动线程时,系统会把该run()方法当成线程执行体来处理。但如果直接调用线程对象的run()方法,则run()方法立即就会被执行,而且在run()方法返回之前其他线程无法并发执行。也就是 说,如果直接调用线程对象的run()方法,系统把线程对象当成一个普通对象,而run()方法也是一个普通方法,而不是线程执行体。
只能对处于新建状态的线程调用start()方法,否则将引发
IllegalThreadStateException异常。
扩展阅读:
当程序使用new关键字创建了一个线程之后,该线程就处于新建状态,此时它和其他的Java对象一样, 仅仅由Java虚拟机为其分配内存,并初始化其成员变量的值。此时的线程对象没有表现出任何线程的动 态特征,程序也不会执行线程的线程执行体。当线程对象调用了start()方法之后,该线程处于就绪状态,Java虚拟机会为其创建方法调用栈和程序计 数器,处于这个状态中的线程并没有开始运行,只是表示该线程可以运行了。至于该线程何时开始运 行,取决于JVM里线程调度器的调度。
①6 种状态:
NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED
②特殊情况
1 如果发生异常, 可以直接跳到终止TERMINATED状态, 不必再遵循路径, 比如可以从WAITING直接 到TERMINATED。
2 从Object. wait ()刚被唤醒时, 通常不能立刻抢到monitor锁, 那就会从WAITING先进入BLOCKED 状态, 抢到锁后再转换到RUNNABLE状态。
同步方法
即有synchronized关键字修饰的方法,由于java的每个对象都有一个内置锁,当用此关键字修饰 方法时, 内置锁会保护整个方法。在调用该方法前,需要获得内置锁,否则就处于阻塞状态。需要注意, synchronized关键字也可以修饰静态方法,此时如果调用该静态方法,将会锁住整个类。
同步代码块
即有synchronized关键字修饰的语句块,被该关键字修饰的语句块会自动被加上内置锁,从而实现同步。需值得注意的是,同步是一种高开销的操作,因此应该尽量减少同步的内容。通常没有必 要同步整个方法,使用synchronized代码块同步关键代码即可。
ReentrantLock
Java 5新增了一个java.util.concurrent包来支持同步,其中ReentrantLock类是可重入、互斥、实现了Lock接口的锁,它与使用synchronized方法和快具有相同的基本行为和语义,并且扩展了其 能力。需要注意的是,ReentrantLock还有一个可以创建公平锁的构造方法,但由于能大幅度降低 程序运行效率,因此不推荐使用。
volatile
volatile关键字为域变量的访问提供了一种免锁机制,使用volatile修饰域相当于告诉虚拟机该域可 能会被其他线程更新,因此每次使用该域就要重新计算,而不是使用寄存器中的值。需要注意的 是,volatile不会提供任何原子操作,它也不能用来修饰final类型的变量。
原子变量
在java的util.concurrent.atomic包中提供了创建了原子类型变量的工具类,使用该类可以简化线 程同步。例如AtomicInteger 表可以用原子方式更新int的值,可用在应用程序中(如以原子方式增加的计数器),但不能用于替换Integer。可扩展Number,允许那些处理机遇数字类的工具和 实用工具进行统一访问。
在Java中线程通信主要有以下三种方式:
①wait()、notify()、notifyAll()
如果线程之间采用synchronized来保证线程安全,则可以利用wait()、notify()、notifyAll()来实现 线程通信。这三个方法都不是Thread类中所声明的方法,而是Object类中声明的方法。原因是每 个对象都拥有锁,所以让当前线程等待某个对象的锁,当然应该通过这个对象来操作。并且因为当 前线程可能会等待多个线程的锁,如果通过线程来操作,就非常复杂了。另外,这三个方法都是本 地方法,并且被final修饰,无法被重写。
wait()方法可以让当前线程释放对象锁并进入阻塞状态。
notify()方法用于唤醒一个正在等待相应对象锁的线程,使其进入就绪队列,以便在当前线程释放锁后竞争锁,进而得到CPU的执行。
notifyAll()用于唤醒所有正在等待相应对象锁的线程,使它们进入就绪队列,以便在当前线程释放 锁后竞争锁,进而得到 CPU的执行。每个锁对象都有两个队列,一个是就绪队列,一个是阻塞队列。就绪队列存储了已就绪(将要竞争 锁)的线程,阻塞队列存储了被阻塞的线程。当一个阻塞线程被唤醒后,才会进入就绪队列,进而 等待CPU的调度。反之,当一个线程被wait后,就会进入阻塞队列,等待被唤醒。
② await()、signal()、signalAll()
如果线程之间采用Lock来保证线程安全,则可以利用await()、signal()、signalAll()来实现线程通 信。这三个方法都是Condition接口中的方法,该接口是在Java 1.5中出现的,它用来替代传统的wait+notify实现线程间的协作,它的使用依赖于 Lock。相比使用wait+notify,使用Condition的await+signal这种方式能够更加安全和高效地实现线程间协作。Condition依赖于Lock接口,生成一个Condition的基本代码是lock.newCondition() 。 必须要注意的是,Condition 的await()/signal()/signalAll() 使用都必须在lock保护之内,也就是说,必须在lock.lock()和lock.unlock之间才可以使用。事实上,await()/signal()/signalAll() 与wait()/notify()/notifyAll()有着天然的对应关系。即:Conditon中的await()对应Object的wait(), Condition中的signal()对应Object的notify(),Condition中的signalAll()对应Object的notifyAll()。
③BlockingQueue
Java 5提供了一个BlockingQueue接口,虽然BlockingQueue也是Queue的子接口,但它的主要用途并不是作为容器,而是作为线程通信的工具。BlockingQueue具有一个特征:当生产者线程试图向BlockingQueue中放入元素时,如果该队列已满,则该线程被阻塞;当消费者线程试图从BlockingQueue中取出元素时,如果该队列已空,则该线程被阻塞。程序的两个线程通过交替向BlockingQueue中放入元素、取出元素,即可很好地控制线程的通信。线程之间需要通信,最经典的场景就是生产者与消费者模型,而BlockingQueue就是针对该 模型提供的解决方案。
wait()、notify()、notifyAll()用来实现线程之间的通信,这三个方法都不是Thread类中所声明的方法, 而是Object类中声明的方法。原因是每个对象都拥有锁,所以让当前线程等待某个对象的锁,当然应该 通过这个对象来操作。并且因为当前线程可能会等待多个线程的锁,如果通过线程来操作,就非常复杂 了。另外,这三个方法都是本地方法,并且被final修饰,无法被重写,并且只有采用synchronized实现 线程同步时才能使用这三个方法。
①wait()方法可以让当前线程释放对象锁并进入阻塞状态。
②notify()方法用于唤醒一个正在等待相应对象锁 的线程,使其进入就绪队列,以便在当前线程释放锁后竞争锁,进而得到CPU的执行。
③notifyAll()方法 用于唤醒所有正在等待相应对象锁的线程,使它们进入就绪队列,以便在当前线程释放锁后竞争锁,进 而得到CPU的执行。
每个锁对象都有两个队列,一个是就绪队列,一个是阻塞队列。就绪队列存储了已 就绪(将要竞争锁) 的线程,阻塞队列存储了被阻塞的线程。当一个阻塞线程被唤醒后,才会进入就绪队列,进而等待CPU 的调度。反之,当一个线程被wait后,就会进入阻塞队列,等待被唤醒。
notify()用于唤醒一个正在等待相应对象锁的线程,使其进入就绪队列,以便在当前线程释放锁后竞争锁, 进而得到CPU的执行。
notifyAll()用于唤醒所有正在等待相应对象锁的线程,使它们进入就绪队列,以便在当前线程释放锁后竞争 锁,进而得到CPU的执行。
一、以下列代码为例,说明同步代码块的底层实现原理:
查看反编译后结果,如下图:
可见,synchronized作用在代码块时,它的底层是通过monitorenter、
monitorexit指令来实现的。
monitorenter:
每个对象都是一个监视器锁(monitor),当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor 的所有者。如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1。如果其 他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获 取monitor的所有权。
monitorexit:
执行monitorexit的线程必须是objectref所对应的monitor持有者。指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个monitor的所有权。
monitorexit指令出现了两次,第1次为同步正常退出释放锁,第2次为发生异步退出释放锁。
二、以下列代码为例,说明同步方法的底层实现原理:
查看反编译后结果,如下图:
从反编译的结果来看,方法的同步并没有通过 monitorenter 和monitorexit 指令来完成,不过相对于普通方法,其常量池中多了ACC_SYNCHRONIZED 标示符。JVM就是根据该标示符来实现方法的同步的:
当方法调用时,调用指令将会检查方法的ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了, 执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执 行期间,其他任何线程都无法再获得同一个monitor对象。
三、总结
两种同步方式本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。两 个指令的执行是JVM通过调用操作系统的互斥原语mutex来实现,被阻塞的线程会被挂起、等待重新调 度,会导致“用户态和内核态”两个态之间来回切换,对性能有较大影响。
ReentrantLock 是基于AQS 实现的, AQS 即 AbstractQueuedSynchronizer 的缩写,这个是个内部实现了两个队列的抽象类,分别是同步队列和条件队列。其中同步队列是一个双向链表,里面储存的是处 于等待状态的线程,正在排队等待唤醒去获取锁,而条件队列是一个单向链表,里面储存的也是处于等 待状态的线程,只不过这些线程唤醒的结果是加入到了同步队列的队尾, AQS 所做的就是管理这两个队列里面线程之间的等待状态-唤醒的工作。
在同步队列中,还存在 2 中模式,分别是独占模式和共享模式,这两种模式的区别就在于AQS 在唤醒线程节点的时候是不是传递唤醒,这两种模式分别对应独占锁和共享锁。
AQS 是一个抽象类,所以不能直接实例化,当我们需要实现一个自定义锁的时候可以去继承AQS 然后重写获取锁的方式和释放锁的方式还有管理state,而 ReentrantLock 就是通过重写了AQS 的tryAcquire和 tryRelease 方法实现的lock 和 unlock 。
ReentrantLock 结构如下图所示:
首先 ReentrantLock 实现了 接口,然后有 3 个内部类,其中 Sync 内部类继承自 AQS ,另外的两个内部类继承自Sync ,这两个类分别是用来公平锁和非公平锁的。通过Sync 重写的方法tryAcquire 、 tryRelease 可以知道, ReentrantLock 实现的是AQS 的独占模式,也就是独占锁,这个锁是悲观锁。
volatile
volatile关键字为域变量的访问提供了一种免锁机制,使用volatile修饰域相当于告诉虚拟机该域可 能会被其他线程更新,因此每次使用该域就要重新计算,而不是使用寄存器中的值。需要注意的 是,volatile不会提供任何原子操作,它也不能用来修饰final类型的变量。
原子变量
在java的util.concurrent.atomic包中提供了创建了原子类型变量的工具类,使用该类可以简化线 程同步。例如AtomicInteger 表可以用原子方式更新int的值,可用在应用程序中(如以原子方式增加的计数器),但不能用于替换Integer。可扩展Number,允许那些处理机遇数字类的工具和 实用工具进行统一访问。
本地存储
可以通过ThreadLocal类来实现线程本地存储的功能。每一个线程的Thread对象中都有一个ThreadLocalMap对象,这个对象存储了一组以ThreadLocal.threadLocalHashCode为键,以本地线程变量为值的K-V值对,ThreadLocal对象就是当前线程的ThreadLocalMap的访问入口,每 一个 ThreadLocal对象都包含了一个独一无二的threadLocalHashCode值,使用这个值就可以在线程K-V值对中找回对应的本地线程变量。
不可变的
只要一个不可变的对象被正确地构建出来,那其外部的可见状态永远都不会改变,永远都不会看到 它在多个线程之中处于不一致的状态,“不可变”带来的安全性是最直接、最纯粹的。Java语言中, 如果多线程共享的数据是一个基本数据类型,那么只要在定义时使用final关键字修饰它就可以保 证它是不可变的。如果共享数据是一个对象,由于Java语言目前暂时还没有提供值类型的支持,那 就需要对象自行保证其行为不会对其状态产生任何影响才行。String类是一个典型的不可变类,可 以参考它设计一个不可变类。
悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会 上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。Java中悲观锁是通过synchronized关键字或Lock 接口来实现的。
乐观锁:顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更 新的时候会判断一下在此期间别人有没有去更新这个数据。乐观锁适用于多读的应用类型,这样可以提 高吞吐量。在JDK1.5 中新增java.util.concurrent (J.U.C)就是建立在CAS之上的。相对于对于 synchronized 这种阻塞算法,CAS是非阻塞算法的一种常见实现。所以J.U.C在性能上有了很大的提升。
在Java中实现锁的方式有两种,一种是使用Java自带的关键字synchronized对相应的类或者方法以及代 码块进行加锁,另一种是ReentrantLock,前者只能是非公平锁,而后者是默认非公平但可实现公平的 一把锁。
ReentrantLock是基于其内部类FairSync(公平锁)和NonFairSync(非公平锁)实现的,并且它的实现依赖 于Java同步器框架AbstractQueuedSynchronizer(AQS),AQS使用一个整形的volatile变量state来维护同步状态,这个volatile变量是实现ReentrantLock的关键。我们来看一下ReentrantLock的类图:
ReentrantLock 的公平锁和非公平锁都委托了AbstractQueuedSynchronizer#acquire去请求获取。
tryAcquire 是一个抽象方法,是公平与非公平的实现原理所在。
addWaiter 是将当前线程结点加入等待队列之中。公平锁在锁释放后会严格按照等到队列去取后续值,而非公平锁在对于新晋线程有很大优势。
acquireQueued 在多次循环中尝试获取到锁或者将当前线程阻塞。
selfInterrupt 如果线程在阻塞期间发生了中断,调用Thread.currentThread().interrupt() 中断当前线程。
公平锁和非公平锁在说的获取上都使用到了 volatile 关键字修饰的state字段, 这是保证多线程环境下锁的获取与否的核心。但是当并发情况下多个线程都读取到state == 0 时,则必须用到CAS技术,一门CPU的原子锁技术,可通过CPU对共享变量加锁的形式,实现数据变更的原子操作。volatile 和 CAS 的结合是并发抢占的关键。
①公平锁FairSync
公平锁的实现机理在于每次有线程来抢占锁的时候,都会检查一遍有没有等待队列,如果有, 当前线程会执行如下步骤:
其中hasQueuedPredecessors是用于检查是否有等待队列的:
②非公平锁NonfairSync
非公平锁在实现的时候多次强调随机抢占:
与公平锁的区别在于新晋获取锁的进程会有多次机会去抢占锁,被加入了等待队列后则跟公平锁没 有区别。
当一个变量被定义成volatile之后,它将具备两项特性:
保证可见性
当写一个volatile变量时,JMM会把该线程本地内存中的变量强制刷新到主内存中去,这个写会操 作会导致其他线程中的volatile变量缓存无效。
禁止指令重排
使用volatile关键字修饰共享变量可以禁止指令重排序,volatile禁止指令重排序有一些规则:当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见,在其后面的操作肯定还没有进行;在进行指令优化时,不能将对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。即执行到volatile变量时,其前面的所有语句都执行完,后面所有语句都未执行。且前面语句的结 果对volatile变量及其后面语句可见。
注意,虽然volatile能够保证可见性,但它不能保证原子性。volatile变量在各个线程的工作内存中是不 存在一致性问题的,但是Java里面的运算操作符并非原子操作,这导致volatile变量的运算在并发下一样 是不安全的。
volatile可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在JVM底层volatile是采用 “内存屏障”来实现的。观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加 入volatile关键字时,会多出一个lock前缀指令,lock前缀指令实际上相当于一个内存屏障,内存屏障会 提供3个功能:
ThreadLocal顾名思义是线程私有的局部变量存储容器,可以理解成每个线程都有自己专属的存储容 器,它用来存储线程私有变量,其实它只是一个外壳,内部真正存取是一个Map。每个线程可以通过set() 和 get() 存取变量,多线程间无法访问各自的局部变量,相当于在每个线程间建立了一个隔
板。只要线程处于活动状态,它所对应的ThreadLocal实例就是可访问的,线程被终止后,它的所有实 例将被垃圾收集。总之记住一句话ThreadLocal存储的变量属于当前线程。
ThreadLocal经典的使用场景是为每个线程分配一个 JDBC 连接Connection,这样就可以保证每个线程的都在各自的 Connection 上进行数据库的操作,不会出现 A 线程关了 B线程正在使用的Connection。 另外ThreadLocal还经常用于管理Session会话,将Session保存在ThreadLocal中,使线程处理多次处理会话时始终是同一个Session。
Thread类中有个变量threadLocals,它的类型为ThreadLocal中的一个内部类ThreadLocalMap,这个类没有实现map接口,就是一个普通的Java类,但是实现的类似map的功能。每个线程都有自己的一个map,map是一个数组的数据结构存储数据,每个元素是一个Entry,entry的key是ThreadLocal的引用,也就是当前变量的副本,value就是set的值。代码如下所示:
ThreadLocalMap是ThreadLocal的内部类,每个数据用Entry保存,其中的Entry继承与WeakReference,用一个键值对存储,键为ThreadLocal的引用。为什么是WeakReference呢?如果是强引用,即使把ThreadLocal设置为null,GC也不会回收,因为ThreadLocalMap对它有强引用。代码如下所示:
ThreadLocal 中 的 set 方 法 的 实 现 逻 辑 , 先 获 取 当 前 线 程 , 取 出 当 前 线 程 的ThreadLocalMap,如果不存 在就会创建一个ThreadLocalMap,如果存在就会把当前的threadlocal的引用作为键,传入的参数作为 值存入map中。代码如下所示:
ThreadLocal中get方法的实现逻辑,获取当前线程,取出当前线程的
ThreadLocalMap,用当前的threadlocak作为key在ThreadLocalMap查找,如果存在不为空的Entry,就返回Entry中的value,否则就会执行初始化并返回默认的值。代码如下所示:
ThreadLocal 中 remove 方 法 的 实 现 逻 辑 , 还 是 先 获 取 当 前 线 程 的ThreadLocalMap 变量,如果存在就调用 ThreadLocalMap 的 remove 方法。ThreadLocalMap的存储就是数组的实现,因此需要确定元素的位置,找到Entry,把entry的键值对都设为null,最后也Entry也设置为null。其实这其中会有哈希冲突。
具体见下文。代码如下所示:
ThreadLocal中的hash code非常简单,就是调用AtomicInteger的getAndAdd方法,参数是个固定值0x61c88647 。上面说过ThreadLocalMap的结构非常简单只用一个数组存储,并没有链表结构,当出现Hash冲突时采用线性查找的方式,所谓线性查找,就是根据初始key的hashcode值确定元素在table 数组中的位置,如果发现这个位置上已经有其他key值的元素被占用,则利用固定的算法寻找一定步长 的下个位置,依次判断,直至找到能够存放的位置。如果产生多次hash冲突,处理起来就没有HashMap的效率高,为了避免哈希冲突,使用尽量少的threadlocal变量。
系统启动一个新线程的成本是比较高的,因为它涉及与操作系统交互。在这种情形下,使用线程池可以 很好地提高性能,尤其是当程序中需要创建大量生存期很短暂的线程时,更应该考虑使用线程池。
与数据库连接池类似的是,线程池在系统启动时即创建大量空闲的线程,程序将一个Runnable对象或Callable对象传给线程池,线程池就会启动一个空闲的线程来执行它们的run()或call()方法,当run()或call()方法执行结束后,该线程并不会死亡,而是再次返回线程池中成为空闲状态,等待执行下一个Runnable对象的run()或call()方法。
从Java 5开始,Java内建支持线程池。Java 5新增了一个Executors工厂类来产生线程池,该工厂类包含如下几个静态工厂方法来创建线程池。创建出来的线程池,都是通过ThreadPoolExecutor类来实现的。
①newCachedThreadPool():创建一个具有缓存功能的线程池,系统根据需要创建线程,这些线程 将会被缓存在线程池中。
②newFixedThreadPool(int nThreads):创建一个可重用的、具有固定线程数的线程池。newSingleThreadExecutor():创建一个只有单线程的线程池,它相当于调用newFixedThreadPool()方法时传入参数为1。
③newScheduledThreadPool(int corePoolSize):创建具有指定线程数的线程池,它可以在指定延迟后执行线程任务。corePoolSize指池中所保存的线程数,即使线程是空闲的也被保存在线程池 内。
④newSingleThreadScheduledExecutor():创建只有一个线程的线程池,它可以在指定延迟后执行 线程任务。
⑤ExecutorService newWorkStealingPool(int parallelism):创建持有足够的线程的线程池来支持给定的并行级别,该方法还会使用多个队列来减少竞争。
⑥ExecutorService newWorkStealingPool():该方法是前一个方法的简化版本。如果当前机器有4个CPU,则目标并行级别被设置为4,也就是相当于为前一个方法传入4作为参数。
线程池一共有五种状态, 分别是:
当线程池的任务缓存队列已满并且线程池中的线程数目达到maximumPoolSize,如果还有任务到来就 会采取任务拒绝策略,通常有以下四种策略:
CPU密集型任务
尽量使用较小的线程池,一般为CPU核心数+1。 因为CPU密集型任务使得CPU使用率很高,若开过多的线程数,会造成CPU过度切换。
IO密集型任务
可以使用稍大的线程池,一般为2*CPU核心数。 IO密集型任务CPU使用率并不高,因此可以让CPU在等待IO的时候有其他线程去处理别的任务,充分利用CPU时间。
混合型任务
可以将任务分成IO密集型和CPU密集型任务,然后分别用不同的线程池去处理。 只要分完之后两个任务的执行时间相差不大,那么就会比串行执行来的高效。因为如果划分之后两个任务执行时间 有数据级的差距,那么拆分没有意义。因为先执行完的任务就要等后执行完的任务,最终的时间仍 然取决于后执行完的任务,而且还要加上任务拆分与合并的开销,得不偿失。
线程池主要有如下6个参数:
corePoolSize(核心工作线程数):当向线程池提交一个任务时,若线程池已创建的线程数小于corePoolSize,即便此时存在空闲线程,也会通过创建一个新线程来执行该任务,直到已创建的线程数大于或等于corePoolSize时。
maximumPoolSize(最大线程数):线程池所允许的最大线程个数。当队列满了,且已创建的线 程数小于maximumPoolSize,则线程池会创建新的线程来执行任务。另外,对于无界队列,可忽 略该参数。
keepAliveTime(多余线程存活时间):当线程池中线程数大于核心线程数时,线程的空闲时间如 果超过线程存活时间,那么这个线程就会被销毁,直到线程池中的线程数小于等于核心线程数。
workQueue(队列):用于传输和保存等待执行任务的阻塞队列。
threadFactory(线程创建工厂):用于创建新线程。threadFactory创建的线程也是采用newThread()方式,threadFactory创建的线程名都具有统一的风格:pool-m-thread-n(m为线程池的编号,n为线程池内的线程编号)。
handler(拒绝策略):当线程池和队列都满了,再加入线程会执行此策略。
整个网络当中,最基本或者是最核心的就是数据的传输,整个网络的搭建,都是为数据传输服务的,我们类比一下快递物流的例子,就可以初步理解这七层都是干嘛的了:
应用层
应用层的作用是为应用程序提供服务并规定应用程序中通讯相关的细节,也就是为应用提供服务。常见的协议有 HTTP,FTP,TELNET、SMTP 等。翻译成“人话”:相当于收件员。当客户(应用)打电话(发起请求)给收件员(应用层)时,收件员可以根据客户的不同需求提供不同的服务(不同协议),比如隔天送达、指定时间送达等等。
表示层
表示层的作用是将应用处理的信息转换为适合网络传输的格式,或者将来自下一层的数据转换为上层能处理的格式。它主要负责数据格式的转换。具体来说,就是将设备固有的数据格式转换为网络标准格式。常见的协议有 ASCII、SSL/TLS 等。翻译成“人话”: 相当于打包员。如果快递(数据)太臃肿,他会在不破坏快递的情况下压扁(压缩)它。如果客户注重安全线,全能的快递公司还能用密码箱( SSL/TLS)打包快递再快送。当然,打包员会确定,目的地快递站的拆包员,能无损地拆开包裹,将快递交给用户。
3.会话层
会话层作用是负责建立和断开通信连接,以及数据分割等数据传输相关的管理。常见的协议有 ADSP、RPC 等。翻译成“人话”:相当于调度员。对快递运输进行调度指挥。例如这次客户要发100吨沙土(数据),到底是空运、陆运还是海运。而运完之后,相关信息(连接)也可以被销毁了,这些都是他的职责。
4.传输层
传输层起着可靠传输的作用。只在通信双方节点进行处理,而不需在路由器上处理。此层有两个具有代表性的协议:TCP 与 UDP。翻译成“人话”: 相当于跟单员。负责任的跟单员(使用 TCP 协议)会保证快递送到客户手上,如果送不到就让公司再发一次。不负责任的跟单员(使用 UDP 协议)只管将快递送到客户指定的地方,不管快递是否送到客户手上。
网络层
网络层负责将数据传输到目标地址。目标地址可以是多个网络通过路由器连接而成的某一个地址。因此这一层主要负责寻址和路由选择。主要由 IP、ICMP 两个协议组成。翻译成“人话”:相当于路线规划员。快递公司有很多集散中心(路由器),根据集散中心的情况(是否拥堵),找出一条最合适的路径将货物(数据)沿路运过去。
数据链路层
该层负责物理层面上互连的节点之间的通信传输。例如与1个以太网相连的两个节点间的通讯。常见的协议有 HDLC、PPP、SLIP 等。翻译成“人话”: 相当于驾驶员。他们驾驶着汽车,将打包好的快递(数据帧)从一个城市(物理节点)运输到另一个城市。
物理层
物理层负责0、1比特流(0、1序列)与电压高低、光的闪灭之间的互换。典型的协议有 RS 232C、RS 449/422/423、V.24 和 X.21、X.21bis 等。翻译成“人话”:相当于交通工具。例如公路、汽车和飞机等,承载货物(数据)的交通运输。如果以上一堆文字都懒得看的话,那直接看下面这个图也行,这可是我耗费了大半天时间消化理解的,拿着这张图,你就可以给别人去讲,网络七层结构是干嘛的啦
1至4层被认为是低层,这些层与数据移动密切相关。5至7层是高层,包含应用程序级的数据。每一层负责一项具体的工作,然后把数据传送到下一层。从上往下,每经过一层,协议就会在这个包裹上面做点手脚,加点东西,传送到接收端,再层层解套出来,如下示意图:
计算机网络体系结构5层模型是OSI和TCP/IP的综合,是市场生产出来的模型。(主要是因为官方的7层模型太过麻烦复杂)因此主要差别是去掉了会话层和表示层,而传输层改为了运输层,因为他们觉得运输名字更贴切。
TCP、HTTP、FTP分别属于传输层、应用层、应用层。
一、TCP
简介:是一种面向连接(连接导向)的、可靠的、 基于IP的传输层协议。
特点:
①TCP 是面向连接的运输层协议。应用程序在使用 TCP 协议之前,必须先建立 TCP 连接。在传送数据完毕后,必须释放已经建立的 TCP 连接。
②每一条 TCP 连接只能有两个端点,每一条 TCP 连接只能是点对点的(一对一)
③TCP 提供可靠交付的服务。通过 TCP 连接传送的数据,无差错、不丢失、不重复,并且按序到达。
④TCP 提供全双工通信。TCP 允许通信双方的应用进程在任何时候都能发送数据。TCP 连接的两端都设有发送缓存和接受缓存,用来临时存放双向通信的数据。
⑤面向字节流。TCP 中的“流”指的是流入到进程或从进程流出的字节序列。
二、HTTP
1 简介:HTTP是Hyper Text Transfer Protocol(超文本传输协议)的缩写。HTTP协议用于从WWW服务器传输超文本到本地浏览器的传送协议。它可以使浏览器更加高效,使网络传输减少。它不仅保证计算机正确快速地传输超文本文档,还确定传输文档中的哪一部分,以及哪部分内容首先显示(如文本先于图形)等。HTTP是一个应用层协议,由请求和响应构成,是一个标准的客户端服务器模型。HTTP是一个无状态的协议。
HTTP 协议一共有五大特点:
a. 支持客户/服务器模式;
b. 简单快速:客户向服务器请求服务时,只需传送请求方法和路径。请求方法常用的有GET、HEAD、POST。每种方法规定了客户与服务器联系的类型不同。由于HTTP协议简单,使得HTTP服务器的程序规模小,因而通信速度很快。
c. 灵活:HTTP允许传输任意类型的数据对象。正在传输的类型由Content-Type加以标记。 HTTP 0.9和1.0使用非持续连接:限制每次连接只处理一个请求,服务器处理完客户的请求,并收到客户的应答后,即断开连接。采用这种方式可以节省传输时间。HTTP 1.1使用持续连接:不必为每个web对象创建一个新的连接,一个连接可以传送多个对象。
d. 无连接:限制每次连接只处理一个请求。服务器处理完客户的请求,并收到客户的应答 后,即断开连接。采用这种方式可以节省传输时间。
e. 无状态:指协议对于事务处理没有记忆能力,服务器不知道客户端是什么状态。即我们给 服务器发送 HTTP 请求之后,服务器根据请求,会给我们发送数据过来,但是,发送完,不会记录任何信息。
三、FTP
FTP(文件传输协议)是一种多通道协议,意为FTP协议有多个端口与外界进行通信,工作模式有“FTP服务器和FTP客户端”。默认使用TCP端口的20和21端口,20端口用于数据传输,21端口用于控制连接。主要作用是为了用户上传和下载文件。
特点:
1.FTP采用C/S的工作方式
2.FTP基于TCP协议。使用TCP可靠的传输服务
3.一个FTP进程可同时为多个客户进程提供服务
4.由两部分组成:一个主进程(负责接收新的请求)、若干从属进程(负责处理单个请求)
5.FTP在工作时使用两个并行的TCP连接,一个是控制连接(端口号21),一个是数据连接(端口号20)
TCP/IP协议定义
TCP/IP(Transmission Control Protocol/Internet Protocol,传输控制协议/网际协议)是指 能够在多个不同网络间实现信息传输的协议簇TCP/IP协议不仅仅指的是TCP和IP两个协议,而是 指一个由FTP、SMTP、TCP、UDP、IP等协议构成的协议簇, 只是因为在TCP/IP协议中TCP协议和IP协议最具代表性,所以被称为TCP/IP协议。
TCP/IP协议组成
TCP/IP结构模型分为应用层、传输层、网络层、链路层(网络接口层)四层,以下是各层的详细介绍:
(1) 应用层
应用层是TCP/IP协议的第一层,是直接为应用进程提供服务的。
a. 对不同种类的应用程序它们会根据自己的需要来使用应用层的不同协议,邮件传输应用使用了SMTP协议、万维网应用使用了HTTP协议、远程登录服务应用使用了有TELNET协议。
b. 应用层还能加密、解密、格式化数据。
c. 应用层可以建立或解除与其他节点的联系,这样可以充分节省网络资源。
(2) 传输层
作为TCP/IP协议的第二层,运输层在整个TCP/IP协议中起到了中流砥柱的作用。且在运输层中,TCP和UDP也同样起到了中流砥柱的作用。
(3) 网络层
网络层在TCP/IP协议中的位于第三层。在TCP/IP协议中网络层可以进行网络连接的建立和终止 以及IP地址的寻找等功能。
(4) 链路层(网络接口层)
在TCP/IP协议中,网络接口层位于第四层。由于网络接口层兼并了物理层和数据链路层。所以,网络接口层既是传输数据的物理媒介,也可以为网络层提供一条准确无误的线路。
TCP/IP协议特点
TCP/IP协议能够迅速发展起来并成为事实上的标准,是它恰好适应了世界范围内数据通信的需 要。它有以下特点:
(1) 协议标准是完全开放的,可以供用户免费使用,并且独立于特定的计算机硬件与操作系统;
(2) 独立于网络硬件系统,可以运行在广域网,更适合于互联网;
(3) 网络地址统一分配,网络中每一设备和终端都具有一个唯一地址;
(4) 高层协议标准化,可以提供多种多样可靠网络服务。
首先,我让信使运输一份信件给对方,对方收到了,那么他知道了我的发件能力和他的收件能力是可以的。
于是他给我回信,我若收到了,我知道我的发件能力和他的收件能力是可以的,并且他的发件能力和我的收件能力是可以。
然而此时他还不知道他的发件能力和我的收件能力到底可不可以,于是我最后回馈一次,他若收到了,他便知道了他的发件能力和我的收件能力是可以的。
第一次挥手:当客户端的数据都传输完成后,客户端向服务端发出连接释放报文(当然数据没发完时也可以发送连接释放报文并停止发送数据),释放连接报文包含FIN标志位(FIN=1)、序列号seq=1101(100+1+1000,其中的1是建立连接时占的一个序列号)。需要注意的是客户端发出FIN报文段后只是不能发数据了,但是还可以正常收数据;另外FIN报文段即使不携带数据也要占据一个序列号。
第二次挥手:服务端收到客户端发的FIN报文后给客户端回复确认报文,确认报文包含ACK标志位(ACK=1)、确认号ack=1102(客户端FIN报文序列号1101+1)、序列号seq=2300(300+2000)。此时服务端处于关闭等待状态,而不是立马给客户端发FIN报文,这个状态还要持续一段时间,因为服务端可能还有数据没发完。
第三次挥手:服务端将最后数据(比如50个字节)发送完毕后就向客户端发出连接释放报文,报文包含FIN和ACK标志位(FIN=1,ACK=1)、确认号和第二次挥手一样ack=1102、序列号seq=2350(2300+50)。
第四次挥手:客户端收到服务端发的FIN报文后,向服务端发出确认报文,确认报文包含ACK标志位(ACK=1)、确认号ack=2351、序列号seq=1102。注意客户端发出确认报文后不是立马释放TCP连接,而是要经过2MSL(最长报文段寿命的2倍时长)后才释放TCP连接。而服务端一旦收到客户端发出的确认报文就会立马释放TCP连接,所以服务端结束TCP连接的时间要比客户端早一些。
通俗理解:a发完了告诉b。b知道a发完了并告诉a。b发完了并告诉a。a知道b发完了并告诉b。
TCP协议是一种面向连接的、可靠的、基于字节流的运输层通信协议。TCP是全双工模式,这就意味着。
当客户端发出FIN报文段时,只是表示客户端已经没有数据要发送了,客户端告诉服务器,它的数据已经全部发送完毕了;但是,这个时候客户端还是可以接受来自服务端的数据;
当服务端返回ACK报文段时,表示它已经知道客户端没有数据发送了,但是服务端还是可以发送数据到 客户端的;
当服务端也发送了FIN报文段时,这个时候就表示服务端也没有数据要发送了,就会告诉客户端,我也没有数据要发送了,之后彼此就会愉快的中断这次TCP连接。
简单地说,前 2 次挥手用于关闭一个方向的数据通道,后两次挥手用于关闭另外一个方向的数据通道。
客户端首先向服务器发送一个连接请求,但是可能这个连接请求走了远路,等了很长时间,服 务器都没有收到,那么客户端可能会再次发送,此时服务器端收到并且回复SYN、ACK;在这个时 候最先发送的那个连接请求到达服务器,那么服务器会回复一个SYN,ACK;但是客户端表示自己 已经收到确认了,并不搭理这个回复,那么服务器可能陷入等待,如果这种情况多了,那么会导致 服务器瘫痪,所以要发送第三个确认。
简单来说,确认通信双方收发数据的能力,还没有确认完全。
连接:TCP面向连接的传输层协议,即传输数据之前必须先建立好连接;UDP无连接。
服务对象:TCP点对点的两点间服务,即一条TCP连接只能有两个端点;UDP支持一对一,一对多,多对一,多对多的交互通信。
可靠性:TCP可靠交付:无差错,不丢失,不重复,按序到达;UDP尽最大努力交付,不保证可靠交付。
拥塞控制/流量控制:有拥塞控制和流量控制保证数据传输的安全性;UDP没有拥塞控制,网络拥塞不会影响源主机的发送效率。
报文长度:TCP动态报文长度,即TCP报文长度是根据接收方的窗口大小和当前网络拥塞情况决定的;UDP面向报文,不合并,不拆分,保留上面传下来报文的边界。
首部开销:TCP首部开销大,首部20个字节;UDP首部开销小,8字节(源端口,目的端口,数据长度,校验和)。
适用场景(由特性决定):数据完整性需让位于通信实时性,则应该选用TCP 协议(如文件传输、重要状态的更新等);反之,则使用 UDP 协议(如视频传输、实时通信等)。
TCP协议保证数据传输可靠性的方式主要有:校验和、序列号、确认应答、超时重传、连接管理、流量控制、拥塞控制。
① 校验和
计算方式:在数据传输的过程中,将发送的数据段都当做一个16位的整数。将这些整数加起来。 并且前面的进位不能丢弃,补在后面,最后取反,得到校验和。
发送方:在发送数据之前计算检验和,并进行校验和的填充。
接收方:收到数据后,对数据以同样的方式进行计算,求出校验和,与发送方的进行对比。
注意:如果接收方比对校验和与发送方不一致,那么数据一定传输有误。但是如果接收方比对校验和与发送方一致,数据不一定传输成功。
②序列号和确认应答
序列号:TCP传输时将每个字节的数据都进行了编号,这就是序列号。
确认应答:TCP传输的过程中,每次接收方收到数据后,都会对传输方进行确认应答。也就是发送ACK报文。这个ACK报文当中带有对应的确认序列号,告诉发送方,接收到了哪些数据,下一次的 数据从哪里发。
序列号的作用不仅仅是应答的作用,有了序列号能够将接收到的数据根据序列号排序,并且去 掉重复序列号的数据。这也是TCP传输可靠性的保证之一。
③超时重传
在进行TCP传输时,由于确认应答与序列号机制,也就是说发送方发送一部分数据后,都会等待 接收方发送的ACK报文,并解析ACK报文,判断数据是否传输成功。如果发送方发送完数据后,迟 迟没有等到接收方的ACK报文,这该怎么办呢?而没有收到ACK报文的原因可能是什么呢?
首先,发送方没有接收到响应的ACK报文原因可能有两点:
(1) 数据在传输过程中由于网络原因等直接全体丢包,接收方根本没有接收到。
(2) 接收方接收到了响应的数据,但是发送的ACK报文响应却由于网络原因丢包了。
TCP在解决这个问题的时候引入了一个新的机制,叫做超时重传机制。简单理解就是发送方在发 送完数据后等待一个时间,时间到达没有接收到ACK报文,那么对刚才发送的数据进行重新发送。 如果是刚才第一个原因,接收方收到二次重发的数据后,便进行ACK应答。如果是第二个原因,接 收方发现接收的数据已存在(判断存在的根据就是序列号,所以上面说序列号还有去除重复数据的 作用),那么直接丢弃,仍旧发送ACK应答。
④连接管理
连接管理就是三次握手与四次挥手的过程,保证可靠的连接,是保证可靠性的前提。
⑤流量控制
收端在接收到数据后,对其进行处理。如果发送端的发送速度太快,导致接收端的结束缓冲区 很快的填充满了。此时如果发送端仍旧发送数据,那么接下来发送的数据都会丢包,继而导致丢包 的一系列连锁反应,超时重传呀什么的。而TCP根据接收端对数据的处理能力,决定发送端的发送 速度,这个机制就是流量控制。
在TCP协议的报头信息当中,有一个16位字段的窗口大小。在介绍这个窗口大小时我们知道,窗 口大小的内容实际上是接收端接收数据缓冲区的剩余大小。这个数字越大,证明接收端接收缓冲区 的剩余空间越大,网络的吞吐量越大。接收端会在确认应答发送ACK报文时,将自己的即时窗口大 小填入,并跟随ACK报文一起发送过去。而发送方根据ACK报文里的窗口大小的值的改变进而改变自己的发送速度。如果接收到窗口大小的值为0,那么发送方将停止发送数据。并定期的向接收端 发送窗口探测数据段,让接收端把窗口大小告诉发送端。
⑥ 拥塞控制
TCP传输的过程中,发送端开始发送数据的时候,如果刚开始就发送大量的数据,那么就可能造 成一些问题。网络可能在开始的时候就很拥堵,如果给网络中在扔出大量数据,那么这个拥堵就会 加剧。拥堵的加剧就会产生大量的丢包,就对大量的超时重传,严重影响传输。
所以TCP引入了慢启动的机制,在开始发送数据时,先发送少量的数据探路。探清当前的网络状 态如何,再决定多大的速度进行传输。这时候就引入一个叫做拥塞窗口的概念。发送刚开始定义拥 塞窗口为 1,每次收到ACK应答,拥塞窗口加 1。在发送数据之前,首先将拥塞窗口与接收端反馈的窗口大小比对,取较小的值作为实际发送的窗口。
拥塞窗口的增长是指数级别的。慢启动的机制只是说明在开始的时候发送的少,发送的慢,但 是增长的速度是非常快的。为了控制拥塞窗口的增长,不能使拥塞窗口单纯的加倍,设置一个拥塞 窗口的阈值,当拥塞窗口大小超过阈值时,不能再按照指数来增长,而是线性的增长。在慢启动开 始的时候,慢启动的阈值等于窗口的最大值,一旦造成网络拥塞,发生超时重传时,慢启动的阈值 会为原来的一半(这里的原来指的是发生网络拥塞时拥塞窗口的大小),同时拥塞窗口重置为1。
拥塞控制是TCP在传输时尽可能快的将数据传输,并且避免拥塞造成的一系列问题。是可靠性的 保证,同时也是维护了传输的高效性。
前言:udp本身不是可靠的,不过效率很高。
实现方法:
(1) 将实现放到应用层,然后类似于TCP,实现确认机制、重传机制和窗口确认机制;
(2) 给数据包进行编号,按顺序接收并存储,接收端收到数据包后发送确认信息给发送端,发送 端接收到确认信息后继续发送,若接收端接收的数据不是期望的顺序编号,则要求重发;(主要解决丢包和包无序的问题)
已经实现的可靠UDP:
(1) RUDP 可靠数据报传输协议;
(2) RTP 实时传输协议:为数据提供了具有实时特征的端对端传送服务;例如:组播或单播网络服务下的交互式视频、 音频或模拟数据。
(3) UDT:基于UDP的数据传输协议,是一种互联网传输协议; 主要目的是支持高速广域网上的海量数据传输,引入了新的拥塞控制和数据可靠性控制机制(互联网上的标准数据传输协议TCP在高带宽长 距离的网络上性能很差);UDT是面向连接的双向的应用层协议,同时支持可靠的数据流传输和部分可靠的数据报服务; 应用:高速数据传输,点到点技术(P2P),防火墙穿透,多媒体数据传输;
HTTP是基于TCP的。
HTTP协议是建立在请求/响应模型上的。首先由客户建立一条与服务器的TCP链接,并发送一个请求 到服务器,请求中包含请求方法、URI、协议版本以及 相关的MIME样式的消息。服务器响应一个状态行,包含消息的协议版本、一个成功和失败码以及相关的MIME式样的消息。
HTTP/1.0为每一次HTTP的请求/响应建立一条新的TCP链接,因此一个包含HTML内容和图片的页面 将需要建立多次的短期的TCP链接。一次TCP链接的建立将需要3次握手。
另 外,为了获得适当的传输速度,则需要TCP花费额外的回路链接时间(RTT)。每一次链接的建立需要这种经常性的开销,而其并不带有实际有用的数据,只是 保证链接的可靠性,因此HTTP/1.1提出了可持续链接的实现方法。HTTP/1.1将只建立一次TCP的链接而重复地使用它传输一系列的请求/响应 消 息,因此减少了链接建立的次数和经常性的链接开销。
首先客户端位置是一台电脑或手机,在打开浏览器以后,比如输入http://www.zdns.cn的域名,它首先是由浏览器发起一个DNS解析请求,如果本地缓存服务器中找不到结果,则首先会向根服 务器查询,根服务器里面记录的都是各个顶级域所在的服务器的位置,当向根服务器请求http://www.zdns.cn的时候,根服务器就会返回.cn服务器的位置信息;
递归服务器拿到.cn的权威服务器地址以后,就会寻问.cn的权威服务器,知不知道http://www.zdns.cn的位置。这个时候.cn权威服务器查找并返回http://zdns.cn服务器的地址;
继续向http://zdns.cn的权威服务器去查询这个地址,由http://zdns.cn的服务器给出了地址:202.173.11.10;
最终进入http的链接,顺利访问网站;
补充说明:一旦递归服务器拿到解析记录以后,就会在本地进行缓存,如果下次客户端再请求本地的递 归域名服务器相同域名的时候,就不会再这样一层一层查了,因为本地服务器里面已经有缓存了,这个 时候就直接把http://www.zdns.cn的记录返回给客户端就可以了。
前言:属于四次挥手中的。
出现 TIME_WAIT的状态原因
TIME_WAIT状态之所以存在,是为了保证网络的可靠性。由于TCP连接是双向的,所以在关闭连 接的时候,两个方向各自都需要关闭。先发FIN包的一方执行的是主动关闭,后发送FIN包的一方 执行的是被动关闭。主动关闭的一方会进入TIME_WAIT状态,并且在此状态停留2MSL时长。如果Server端一直没有向client端发送FIN消息(调用close() API),那么这个CLOSE_WAIT会一直存在下去(相当于主线程等待子线程发送完成)。
MSL概念
其指的是报文段的最大生存时间。如果报文段在网络中活动了MSL时间,还没有被接收,那么 就会被丢弃。关于MSL的大小,RFC 793协议中给出的建议是2分钟,不过Linux中,通常是半分钟。
TIME_WAIT持续两个MSL的作用
首先,可靠安全地关闭TCP连接。比如网络拥塞,如果主动关闭方最后一个ACK没有被被动关闭方接收到,这时被动关闭方会对FIN进行超时重传,在这时尚未关闭的TIME_WAIT就会把这些尾巴 问题处理掉,不至于对新连接及其他服务产生影响。其次,防止由于没有持续TIME_WAIT时间导 致的新的TCP连接建立起来,延迟的FIN重传包会干扰新的连接。
TIME_WAIT占用的资源
少量内存(大概4K)和一个文件描述符fd。
关闭TIME_WAIT的危害
首先,当网络情况不好时,如果主动方无TIME_WAIT等待,关闭前个连接后,主动方与被动方 又建立起新的TCP连接,这时被动方重传或延时过来的FIN包到达后会直接影响新的TCP连接;其 次,当网络情况不好时,同时没有TIME_WAIT等待时,关闭连接后无新连接,那么当接收到被动 方重传或延迟的FIN包后,会给被动方回送一个RST包,可能会影响被动方其他的服务连接。
time-wait的作用
time-wait开始的时间为tcp四次挥手中主动关闭连接方发送完最后一次挥手,也就是ACK=1的信号结束后,主动关闭连接方所处的状态。
然后time-wait的的持续时间为2MSL. MSL是Maximum Segment Lifetime,译为“报文最大生存时间”,可为30s,1min或2min。2msl就是2倍的这个时间。工程上为2min,2msl就是4min。但一般根据实际的网络情况进行确定。
然后,为什么要持续这么长的时间呢?
原因1:为了保证客户端发送的最后一个ack报文段能够到达服务器。因为这最后一个ack确认包可能会丢失,然后服务器就会超时重传第三次挥手的fin信息报,然后客户端再重传一次第四次挥手的ack报文。如果没有这2msl,客户端发送完最后一个ack数据报后直接关闭连接,那么就接收不到服务器超时重传的fin信息报(此处应该是客户端收到一个非法的报文段,而返回一个RST的数据报,表明拒绝此次通信,然后双方就产生异常,而不是收不到。),那么服务器就不能按正常步骤进入close状态。那么就会耗费服务器的资源。当网络中存在大量的timewait状态,那么服务器的压力可想而知。
原因2:在第四次挥手后,经过2msl的时间足以让本次连接产生的所有报文段都从网络中消失,这样下一次新的连接中就肯定不会出现旧连接的报文段了。也就是防止我们上一篇文章 为什么tcp是三次握手而不是两次握手? 中说的:已经失效的连接请求报文段出现在本次连接中。如果没有的话就可能这样:这次连接一挥手完马上就结束了,没有timewait。这次连接中有个迷失在网络中的syn包,然后下次连接又马上开始,下个连接发送syn包,迷失的syn包忽然又到达了对面,所以对面可能同时收到或者不同时间收到请求连接的syn包,然后就出现问题了。
单工:数据传输只支持数据在一个方向上传输;在同一时间只有一方能接受或发送信息,不能实现双向通信。举例:电视,广播。
半双工:半双工数据传输允许数据在两个方向上传输,但是,在某一时刻,只允许数据在一个方向上传输,它实际上是一种切换方向的单工通信;在同一时间只可以有一方接受或发送信息,可以实现双向通信。举例:对讲机。
双工:全双工数据通信允许数据同时在两个方向上传输,因此,全双工通信是两个单工通信方式的结合,它要求发送设备和接收设备都有独立的接收和发送能力;在同一时间可以同时接受和发送信 息,实现双向通信。举例:电话通信。
扩展资料:
单工、半双工和全双工是电信计算机网络中的三种通信信道。这些通信信道可以提供信息传达的途 径。通信信道可以是物理传输介质或通过多路复用介质的逻辑连接。物理传输介质是指能够传播能量波 的材料物质,例如数据通信中的导线。并且逻辑连接通常指电路交换连接或分组模式虚拟电路连接,例 如无线电信通道。由于通信信道的帮助,信息可以无障碍地传输。单工模式一般用在只向一个方向传输数据的场合。例如计算机与打印机之间的通信是单工模式,因为 只有计算机向打印机传输数据,而没有相反方向的数据传输。还有在某些通信信道中,如单工无线发送 等。
前言:索引是帮助MySql高效获取数据的数据结构。在数据之外,数据库系统还维护着满足特定查找算法的数据结构,这些数据结构以某种方式引用数据,这样就可以在这些数据结构上实现高级查找算法,这种数据结构就是索引。
索引的好处能够提高数据检索的效率,降低数据库的IO成本。索引能够极大地提高数据检索效率,也能够改善排序分组操作的性能。但有不能忽略的一个问题就是索引是完全独立于基础数据之外的一部分数据。这样,最明显的资源消耗就是增加了更新所带来的 IO 量和调整索引所致的计算量。所以索引还会带来存储空间资源消耗的增加。
哪些情况需要创建索引
1.主键自动建立唯一索引
2.频繁作为查询条件的字段应该创建索引
3.查询中与其他表关联的字段,外键关系建立索引
4.单键/组合索引的选择问题 – 在高并发下倾向创建组合索引
5.查询中排序的字段 – 排序字段若通过索引去访问将大大提高排序速度
6.查询中统计或者分组字段
哪些情况不需要创建索引
1.频繁更新的字段不适合创建索引 – 因为每次更新不只更新记录还会更新索引。
2.Where里用不到的字段的不创建索引
3.表记录太少 – mysql300w左右就可以考虑建索引了
4.经常增删改的表 – 因为索引要跟着更新
5.数据重复且分布平均的表字段 – 例如性别、真假值;可以用(该字段不同的数据的数量)/(该字段总的数据量),值越接近1,说明不怎么重复,越有建索引的价值。
不一定。
比如,在使用组合索引的时候,如果没有遵从“最左前缀”的原则进行搜索,则索引是不起作用的。 举例,假设在id、name、age字段上已经成功建立了一个名为MultiIdx的组合索引。索引行中按id、name、age的顺序存放,索引可以搜索id、(id,name)、(id, name, age)字段组合。如果列不构成索引最左面的前缀,那么MySQL不能使用局部索引,如(age)或者(name,age)组合则不能使用该 索引查询。
生动例子:查字典的汉字时,知道某字的发音是在a b c 那个字母上。就去某个字母范围的页码内查询。
可以使用EXPLAIN语句查看索引是否正在使用。
举例,假设已经创建了book表,并已经在其year_publication字段上建立了普通索引。执行如下语句:EXPLAIN语句将为我们输出详细的SQL执行信息,其中:
possible_keys行给出了MySQL在搜索数据记录时可选用的各个索引。
key行是MySQL实际选用的索引。
如果possible_keys行和key行都包含year_publication字段,则说明在查询时使用了该索引。
前言:B+树是B树的一种变体,也属于平衡多路查找树,大体结构与B树相同,包含根节点、内部节点和叶子节点。
其实二者最主要的区别是: (1) B+树改进了B树, 让内结点只作索引使用, 去掉了其中指向data record的指针, 使得每个结点中能够存放更多的key, 因此能有更大的出度. 这有什么用? 这样就意味着存放同样多的key, 树的层高能进一步被压缩, 使得检索的时间更短. (2)当然了,由于底部的叶子结点是链表形式, 因此也可以实现更方便的顺序遍历, 但是这是比较次要的, 最主要的的还是第(1)点.
在MySQL中,索引是在存储引擎层实现的,不同存储引擎对索引的实现方式是不同的,下面我们探讨一 下MyISAM和InnoDB两个存储引擎的索引实现方式。
①MyISAM索引实现:
MyISAM引擎使用B+Tree作为索引结构,叶节点的data域存放的是数据记录的地址。
MyISAM索引的原理图如下。这里假设表一共有三列,假设我们以Col1为主键,则上图是一个MyISAM表的主索引(Primary key)示意。可以看出MyISAM的索引文件仅仅保存数据记录的地址。在MyISAM中,主索引和辅助索引(Secondary key)在结构上没有任何区别,只是主索引要求key是唯一的,而辅助索引的key可以重复。
如果我们在Col2上建立一个辅助索引,则此索引的结构如下图所示。同样也是一颗B+Tree,data域保存数据记录的地址。因此,MyISAM中索引检索的算法为首先按照B+Tree搜索算法搜索索引,如果指定 的Key存在,则取出其data域的值,然后以data域的值为地址,读取相应数据记录。
②InnoDB索引实现:
虽然InnoDB也使用B+Tree作为索引结构,但具体实现方式却与MyISAM截然不同。
第一个重大区别是InnoDB的数据文件本身就是索引文件。从上文知道,MyISAM索引文件和数据文件是 分离的,索引文件仅保存数据记录的地址。而在InnoDB中,表数据文件本身就是按B+Tree组织的一个 索引结构,这棵树的叶节点data域保存了完整的数据记录。这个索引的key是数据表的主键,因此InnoDB表数据文件本身就是主索引。
下图是InnoDB主索引(同时也是数据文件)的示意图,可以看到叶节点包含了完整的数据记录。这种 索引叫做聚集索引。因为InnoDB的数据文件本身要按主键聚集,所以InnoDB要求表必须有主键(MyISAM可以没有),如果没有显式指定,则MySQL系统会自动选择一个可以唯一标识数据记录的列 作为主键,如果不存在这种列,则MySQL自动为InnoDB表生成一个隐含字段作为主键,这个字段长度 为6个字节,类型为长整形。
第二个与MyISAM索引的不同是InnoDB的辅助索引data域存储相应记录主键的值而不是地址。换句话说,InnoDB的所有辅助索引都引用主键作为data域。
下图为定义在Col3上的一个辅助索引。这里以英 文字符的ASCII码作为比较准则。聚集索引这种实现方式使得按主键的搜索十分高效,但是辅助索引搜 索需要检索两遍索引:首先检索辅助索引获得主键,然后用主键到主索引中检索获得记录。
了解不同存储引擎的索引实现方式对于正确使用和优化索引都非常有帮助,例如知道了InnoDB的索引 实现后,就很容易明白为什么不建议使用过长的字段作为主键,因为所有辅助索引都引用主索引,过长 的主索引会令辅助索引变得过大。再例如,用非单调的字段作为主键在InnoDB中不是个好主意,因为InnoDB数据文件本身是一颗B+Tree,非单调的主键会造成在插入新记录时数据文件为了维持B+Tree的特性而频繁的分裂调整,十分低效,而使用自增字段作为主键则是一个很好的选择。
①什么时候需要重建索引呢?
②怎么判断索引是否应该重建?
③如何重建索引?
drop原索引,然后再创建索引。
上面这种方式相当耗时,一般不建议使用。直接重建索引:
此方法较快,建议使用。
rebuild是快速重建索引的一种有效的办法,因为它是一种使用现有索引项来重建新索引的方法。如果 重建索引时有其他用户在对这个表操作,尽量使用带online参数来最大限度的减少索引重建时将会出现 的任何加锁问题。由于新旧索引在建立时同时存在,因此,使用这种重建方法需要有额外的磁盘空间可 供临时使用,当索引建完后把老索引删除,如果没有成功,也不会影响原来的索引。利用这种办法可以用来将一个索引移到新的表空间。
④rebuild重建索引的过程
Rebuild以index fast full scan或table full scan方式(采用那种方式取决于cost)读取原索引中的数据来构建一个新的索引,重建过程中有排序操作,rebuild online执行表扫描获取数据,重建过程中有排序的操作;
Rebuild会阻塞DML操作,rebuild online不会阻塞DML操作;(DML是指数据操作语言,用来对数据库中表的记录进行更新。关键字:插入insert,删除delete,更新update等,是对数据进行操作。)
rebuild online时系统会产生一个SYS_JOURNAL_xxx的IOT类型的系统临时日志表,所有rebuild online时索引的变化都记录在这个表中,当新的索引创建完成后,把这个表的记录维护到新的索引中去,然后drop掉旧的索引,rebuild online就完成了。
⑤重建索引过程中的注意事项
前言:B+树由B树和索引顺序访问方法演化而来,它是为磁盘或其他直接存取辅助设备设计的一种平衡查找树,在B+树中,所有记录节点都是按键值的大小顺序存放在同一层的叶子节点,各叶子节点通过指针进 行链接。如下图:
答案:
为了保证数据安全性,一般都是把数据存储在磁盘里面。当我们需要查询数据的时候,需要读取磁盘,就产生了磁盘IO,相比较内存操作,磁盘IO读取速度是非常慢的。 由于所需数据可能在磁盘并不是连续的,一次数据查询就需要多次磁盘IO,所以就需要我们设计的索引数据结构尽可能的减少磁盘IO次数。
B+树索引在数据库中的一个特点就是高扇出性(高扇入是说这个类/方法…被很多其它类引用了。也就是利用率很高了),例如在InnoDB存储引擎中,每个页的大小为16KB。在 数据库中,B+树的高度一般都在2~4层,这意味着查找某一键值最多只需要2到4次IO操作,这还不错。因为现在一般的磁盘每秒至少可以做100次IO操作,2~4次的IO操作意味着查询时间只需0.02~0.04秒。
补充内容:
①什么是B树?
我们知道,树的高度越高,查找次数越多,也就是磁盘IO次数越多,耗时越长, 我们能不能想办法降低树的高度,把二叉树变成N叉树?于是B树就来了。
1.对于一个m阶的B树:根节点至少有2个子节点
2.每个中间节点都包含k-1个元素和k个子节点,其中 m/2 <= k <= m
3.每个叶子节点都包含k-1个元素,其中 m/2 <= k <= m
4.中间节点的元素按照升序排列
5.所有的叶子结点都位于同一层
B树优点:高度更低,每个节点含有多个元素,查找的时候一次可以把一个节点中的所有元素加载到内存中作比较,两种改进都大大减少了磁盘IO次数。
①什么是B+树?
相比较B树,B+树又做了如下约定:
1.有k个子节点的中间节点就有k个元素(B树中是k-1个元素),也就是子节点数量 = 元素数量。 每个元素不保存数据,只用来索引,所有数据都保存在叶子节点。
2.所有的中间节点元素都同时存在于子节点,在子节点元素中是最大(或最小)元素。
3.非叶子节点只保存索引,不保存数据。(B树中两者都保存)
4.叶子结点包含了全部元素的信息,并且叶子结点按照元素大小组成有序列表。
B+树优点:
1.每个节点存储的元素更多,看起来比B树更矮胖,导致磁盘IO次数更少。
2.非叶子节点不存储数据,只存储索引,叶子节点存储全部数据。 这样设计导致每次查找都会查到叶子节点,效率更稳定,便于做性能优化。(简单来说 大目录 中有小目录)
3.叶子节点之间使用有序链表连接。 这样设计方便范围查找,只需要遍历链表中相邻元素即可,不再需要二次遍历二叉树。很明显,B树和B+树就是为了文件检索系统设计的,更适合做索引结构。
hash索引底层就是hash表,进行查找时,调用一次hash函数就可以获取到相应的键值,之后进行回表查询获得实际数据。
B+树底层实现是多路平衡查找树,对于每一次的查询都是从根节点出发,查找到叶 子节点方可以获得所查键值,然后根据查询判断是否需要回表查询数据。
它们有以下的不同:
①hash索引进行等值查询更快(一般情况下),但是却无法进行范围查询。因为在hash索引中经过hash函数建立索引之后,索引的顺序与原顺序无法保持一致,不能支持范围查询。而B+树的的所 有节点皆遵循(左节点小于父节点,右节点大于父节点,多叉树也类似),天然支持范围。
②hash索引不支持使用索引进行排序,原理同上。
③hash索引不支持模糊查询以及多列索引的最左前缀匹配,原理也是因为hash函数的不可预测。 hash索引任何时候都避免不了回表查询数据,而B+树在符合某些条件(聚簇索引,覆盖索引等)的 时候可以只通过索引完成查询。
④hash索引虽然在等值查询上较快,但是不稳定,性能不可预测,当某个键值存在大量重复的时候,发生hash碰撞,此时效率可能极差。而B+树的查询效率比较稳定,对于所有的查询都是从根 节点到叶子节点,且树的高度较低。
因此,在大多数情况下,直接选择B+树索引可以获得稳定且较好的查询速度。而不需要使用hash索 引。
在InnoDB存储引擎中,可以将B+树索引分为聚簇索引和辅助索引(非聚簇索引)。无论是何种索引, 每个页的大小都为16KB,且不能更改。
聚簇索引是根据主键创建的一棵B+树,聚簇索引的叶子节点存放了表中的所有记录。
辅助索引是根据索 引键创建的一棵B+树,与聚簇索引不同的是,其叶子节点仅存放索引键值,以及该索引键值指向的主 键。也就是说,如果通过辅助索引来查找数据,那么当找到辅助索引的叶子节点后,很有可能还需要根 据主键值查找聚簇索引来得到数据,这种查找方式又被称为书签查找。因为辅助索引不包含行记录的所 有数据,这就意味着每页可以存放更多的键值,因此其高度一般都要小于聚簇索引。
前言:事务是工作的离散单位,可以是修改一个用户的账户余额,也可以是库存项的写操作。
事务可由一条非常简单的SQL语句组成,也可以由一组复杂的SQL语句组成。在事务中的操作,要么都 执行修改,要么都不执行,这就是事务的目的,也是事务模型区别于文件系统的重要特征之一。
事务需遵循ACID四个特性:
A(atomicity),原子性。原子性指整个数据库事务是不可分割的工作单位。只有使事务中所有的 数据库操作都执行成功,整个事务的执行才算成功。事务中任何一个SQL语句执行失败,那么已经 执行成功的SQL语句也必须撤销,数据库状态应该退回到执行事务前的状态。
C(consistency),一致性。一致性指事务将数据库从一种状态转变为另一种一致的状态。在事务开始之前和事务结束以后,数据库的完整性约束没有被破坏。
I(isolation),隔离性。事务的隔离性要求每个读写事务的对象与其他事务的操作对象能相互分 离,即该事务提交前对其他事务都不可见,这通常使用锁来实现。
D(durability) ,持久性。事务一旦提交,其结果就是永久性的,即使发生宕机等故障,数据库也能将数据恢复。持久性保证的是事务系统的高可靠性,而不是高可用性。
事务可以分为以下几种类型:
①扁平事务:是事务类型中最简单的一种,而在实际生产环境中,这可能是使用最为频繁的事务。在 扁平事务中,所有操作都处于同一层次,其由BEGIN WORK开始,由COMMIT WORK或ROLLBACK WORK结束。处于之间的操作是原子的,要么都执行,要么都回滚。
②带有保存点的扁平事务:除了支持扁平事务支持的操作外,允许在事务执行过程中回滚到同一事务 中较早的一个状态,这是因为可能某些事务在执行过程中出现的错误并不会对所有的操作都无效, 放弃整个事务不合乎要求,开销也太大。保存点(savepoint)用来通知系统应该记住事务当前的 状态,以便以后发生错误时,事务能回到该状态。
③链事务:可视为保存点模式的一个变种。链事务的思想是:在提交一个事务时,释放不需要的数据 对象,将必要的处理上下文隐式地传给下一个要开始的事务。注意,**提交事务操作和开始下一个事 务操作将合并为一个原子操作。**这意味着下一个事务将看到上一个事务的结果,就好像在一个事务 中进行的。
④嵌套事务:是一个层次结构框架。有一个顶层事务(top-level transaction)控制着各个层次的事 务。顶层事务之下嵌套的事务被称为子事务(subtransaction),其控制每一个局部的变换。
⑤分布式事务:通常是一个在分布式环境下运行的扁平事务,因此需要根据数据所在位置访问网络中 的不同节点。对于分布式事务,同样需要满足ACID特性,要么都发生,要么都失效。
对于MySQL的InnoDB存储引擎来说,它支持扁平事务、带有保存点的扁平事 务、链事务、分布式事务。对于嵌套事务,MySQL数据库并不是原生的,因此对于有并行事务需求的用户来说MySQL就无能 为力了,但是用户可以通过带有保存点的事务来模拟串行的嵌套事务。
【前言】
我们以从A账户转账50元到B账户为例进行说明一下ACID,四大特性。
①原子性
根据定义,原子性是指一个事务是一个不可分割的工作单位,其中的操作要么都做,要么都不做。即要么转账成功,要么转账失败,是不存在中间的状态!
如果无法保证原子性会怎么样?
就会出现数据不一致的情形,A账户减去50元,而B账户增加50元操作失败。系统将无故丢失50元
②隔离性
根据定义,隔离性是指多个事务并发执行的时候,事务内部的操作与其他事务是隔离的,并发执行的各个事务之间不能互相干扰。
如果无法保证隔离性会怎么样?
假设A账户有200元,B账户0元。A账户往B账户转账两次,金额为50元,分别在两个事务中执行。如果无法保证隔离性,会出现下面的情形。
如图所示,如果不保证隔离性,A扣款两次,而B只加款一次,凭空消失了50元,依然出现了数据不一致的情形!
ps:可能有细心的读者已经发现了,mysql中是依靠锁来解决隔离性问题。嗯,我们后面来说明。
③持久性
根据定义,持久性是指事务一旦提交,它对数据库的改变就应该是永久性的。接下来的其他操作或故障不应该对其有任何影响。
如果无法保证持久性会怎么样?
在Mysql中,为了解决CPU和磁盘速度不一致问题,Mysql是将磁盘上的数据加载到内存,对内存进行操作,然后再回写磁盘。好,假设此时宕机了,在内存中修改的数据全部丢失了,持久性就无法保证。设想一下,系统提示你转账成功。但是你发现金额没有发生任何改变,此时数据出现了不合法的数据状态,我们将这种状态认为是数据不一致的情形。
④一致性
根据定义,一致性是指事务执行前后,数据处于一种合法的状态,这种状态是语义上的而不是语法上的。 那什么是合法的数据状态呢? oK,这个状态是满足预定的约束就叫做合法的状态,再通俗一点,这状态是由你自己来定义的。满足这个状态,数据就是一致的,不满足这个状态,数据就是不一致的!
如果无法保证一致性会怎么样?
例一:A账户有200元,转账300元出去,此时A账户余额为-100元。你自然就发现了此时数据是不一致的,为什么呢?因为你定义了一个状态,余额这列必须大于0。
例二:A账户200元,转账50元给B账户,A账户的钱扣了,但是B账户因为各种意外,余额并没有增加。你也知道此时数据是不一致的,为什么呢?因为你定义了一个状态,要求A+B的余额必须不变。
【答案】
问题一:Mysql怎么保证一致性的?
分为两个层面来说。 从数据库层面,数据库通过原子性、隔离性、持久性来保证一致性。也就是说ACID四大特性之中,C(一致性)是目的,A(原子性)、I(隔离性)、D(持久性)是手段,是为了保证一致性,数据库提供的手段。数据库必须要实现AID三大特性,才有可能实现一致性。例如,原子性无法保证,显然一致性也无法保证。但是,如果你在事务里故意写出违反约束的代码,一致性还是无法保证的。例如,你在转账的例子中,你的代码里故意不给B账户加钱,那一致性还是无法保证。因此,还必须从应用层角度考虑。从应用层面,通过代码判断数据库数据是否有效,然后决定回滚还是提交数据!
问题二: Mysql怎么保证原子性的?
利用Innodb的undo log。 undo log名为回滚日志,是实现原子性的关键,当事务回滚时能够撤销所有已经成功执行的sql语句,他需要记录你要回滚的相应日志信息。 例如
(1)当你delete一条数据的时候,就需要记录这条数据的信息,回滚的时候,insert这条旧数据 。
(2)当你update一条数据的时候,就需要记录之前的旧值,回滚的时候,根据旧值执行update操作 。
(3)当年insert一条数据的时候,就需要这条记录的主键,回滚的时候,根据主键执行delete操作。
undo log记录了这些回滚需要的信息,当事务执行失败或调用了rollback,导致事务需要回滚,便可以利用undo log中的信息将数据回滚到修改之前的样子。
问题三: Mysql怎么保证持久性的?
利用Innodb的redo log。 正如之前说的,Mysql是先把磁盘上的数据加载到内存中,在内存中对数据进行修改,再刷回磁盘上。如果此时突然宕机,内存中的数据就会丢失。 怎么解决这个问题? 简单啊,事务提交前直接把数据写入磁盘就行啊。 这么做有什么问题?
①只修改一个页面里的一个字节,就要将整个页面刷入磁盘,太浪费资源了。毕竟一个页面16kb大小,你只改其中一点点东西,就要将16kb的内容刷入磁盘,听着也不合理。
②毕竟一个事务里的SQL可能牵涉到多个数据页的修改,而这些数据页可能不是相邻的,也就是属于随机IO。显然操作随机IO,速度会比较慢。
于是,决定采用redo log解决上面的问题。当做数据修改的时候,不仅在内存中操作,还会在redo log中记录这次操作。当事务提交的时候,会将redo log日志进行刷盘(redo log一部分在内存中,一部分在磁盘上)。当数据库宕机重启的时候,会将redo log中的内容恢复到数据库中,再根据undo log和binlog内容决定回滚数据还是提交数据。
采用redo log的好处?
其实好处就是将redo log进行刷盘比对数据页刷盘效率高,具体表现如下 :redo log体积小,毕竟只记录了哪一页修改了啥,因此体积小,刷盘快。 redo log是一直往末尾进行追加,属于顺序IO。效率显然比随机IO来的快。
问题四: Mysql怎么保证隔离性的?
OK,利用的是锁和MVCC机制。还是拿转账例子来说明,有一个账户表如下 表名t_balance
其中id是主键,user_id为账户名,balance为余额。还是以转账两次为例,如下图所示
MVCC:多版本并发控制(Multi Version Concurrency Control),一个行记录数据有多个版本对快照数据,这些快照数据在undo log中。 如果一个事务读取的行正在做DELELE或者UPDATE操作,读取操作不会等行上的锁释放,而是读取该行的快照版本。 由于MVCC机制在可重复读(Repeateable Read)和读已提交(Read Commited)的MVCC表现形式不同,就不赘述了。
但是有一点说明一下,在事务隔离级别为读已提交(Read Commited)时,一个事务能够读到另一个事务已经提交的数据,是不满足隔离性的。但是当事务隔离级别为可重复读(Repeateable Read)中,是满足隔离性的。
SQL 标准定义了四种隔离级别,这四种隔离级别分别是:
读未提交(READ UNCOMMITTED);
读提交(READ COMMITTED);
可重复读 (REPEATABLE READ);
串行化(SERIALIZABLE)。
事务隔离是为了解决脏读、不可重复读、幻读问题,下表展示了 4 种隔离级别对这三个问题的解决程度:
上述4种隔离级别MySQL都支持,并且InnoDB存储引擎默认的支持隔离级别是REPEATABLE READ,但是与标准SQL不同的是,InnoDB存储引擎在REPEATABLE READ事务隔离级别下,使用Next-Key Lock 的锁算法,因此避免了幻读的产生。
所以,InnoDB存储引擎在默认的事务隔离级别下已经能完全保证 事务的隔离性要求,即达到SQL标准的SERIALIZABLE隔离级别。
扩展阅读
并发情况下,读操作可能存在的三类问题:
前言:
脏读:当前事务(A)中可以读到其他事务(B)未提交的数据(脏数据),这种现象是脏读。
不可重复读:在事务A中先后两次读取同一个数据,两次读取的结果不一样,这种现象称为不可重 复读。脏读与不可重复读的区别在于:前者读到的是其他事务未提交的数据,后者读到的是其他事 务已提交的数据。 对比可重复读,不可重复读指的是在同一事务内,不同的时刻读到的同一批数据可能是不一样的,可能会受到其他事务的影响,比如其他事务改了这批数据并提交了。通常针对数据更新(UPDATE)操作。
可重复读:指的是在一个事务内,最开始读到的数据和事务结束前的任意时刻读到的同一批数据都是一致的。通常针对数据更新(UPDATE)操作。
幻读:是针对数据插入(INSERT)操作来说的。假设事务A对某些行的内容作了更改,但是还未提交,此时事务B插入了与事务A更改前的记录相同的记录行,并且在事务A提交之前先提交了,而这时,在事务A中查询,会发现好像刚刚的更改对于某些数据未起作用,但其实是事务B刚插入进来的,让用户感觉很魔幻,感觉出现了幻觉,这就叫幻读。不可重复读与幻读的区别可以通俗的理解为:前者是数据变了,后者是数据的行数变了。
答案:
①读未提交
它是性能最好,也可以说它是最野蛮的方式,因为它压根儿就不加锁,所以根本谈不上什么隔离效果,可以理解为没有隔离。
②串行化
读的时候加共享锁,也就是其他事务可以并发读,但是不能写。写的时候加排它锁,其他事务不能并发写也不能并发读。
最后说读提交和可重复读。这两种隔离级别是比较复杂的,既要允许一定的并发,又想要兼顾的解决问题。
MySQL 采用了 MVVC (多版本并发控制) 的方式。
我们在数据库表中看到的一行记录可能实际上有多个版本,每个版本的记录除了有数据本身外,还要有一个表示版本的字段,记为 row trx_id,而这个字段就是使其产生的事务的 id,事务 ID 记为 transaction id,它在事务开始的时候向事务系统申请,按时间先后顺序递增。
按照上面这张图理解,一行记录现在有 3 个版本,每一个版本都记录这使其产生的事务 ID,比如事务A的transaction id 是100,那么版本1的row trx_id 就是 100,同理版本2和版本3。
在上面介绍读提交和可重复读的时候都提到了一个词,叫做快照,学名叫做一致性视图,这也是可重复读和不可重复读的关键,可重复读是在事务开始的时候生成一个当前事务全局性的快照,而读提交则是每次执行语句的时候都重新生成一次快照。
对于一个快照来说,它能够读到哪些版本数据,要遵循以下规则:
1.当前事务内的更新,可以读到;
2.版本未提交,不能读到;
3.版本已提交,但是却在快照创建后提交的,不能读到;
4.版本已提交,且是在快照创建前提交的,可以读到;
利用上面的规则,再返回去套用到读提交和可重复读的那两张图上就很清晰了。还是要强调,两者主要的区别就是在快照的创建上,可重复读仅在事务开始是创建一次,而读提交每次执行语句的时候都要重新创建一次。
并发写问题
存在这种情况,两个事务对同一条数据做修改。最后结果肯定要是时间靠后的那个对不对。并且更新之前要先读数据,这里所说的读和上面说到的读不一样,更新之前的读叫做“当前读”,总是当前版本的数据,也就是多版本中最新一次提交的那版。
假设事务A执行 update 操作, update 的时候要对所修改的行加行锁,这个行锁会在提交之后才释放。而在事务A提交之前,事务B也想 update 这行数据,于是申请行锁,但是由于已经被事务A占有,事务B是申请不到的,此时,事务B就会一直处于等待状态,直到事务A提交,事务B才能继续执行,如果事务A的时间太长,那么事务B很有可能出现超时异常。如下图所示。
加锁的过程要分有索引和无索引两种情况,比如下面这条语句
update user set age=11 where id = 1
id 是这张表的主键,是有索引的情况,那么 MySQL 直接就在索引数中找到了这行数据,然后干净利落的加上行锁就可以了。而下面这条语句
update user set age=11 where age=10
表中并没有为 age 字段设置索引,所以, MySQL 无法直接定位到这行数据。那怎么办呢,当然也不是加表锁了。MySQL 会为这张表中所有行加行锁,没错,是所有行。但是呢,在加上行锁后,MySQL 会进行一遍过滤,发现不满足的行就释放锁,最终只留下符合条件的行。虽然最终只为符合条件的行加了锁,但是这一锁一释放的过程对性能也是影响极大的。所以,如果是大表的话,建议合理设计索引,如果真的出现这种情况,那很难保证并发度。
解决幻读
上面介绍可重复读的时候,那张图里标示着出现幻读的地方实际上在 MySQL 中并不会出现,MySQL 已经在可重复读隔离级别下解决了幻读的问题。
前面刚说了并发写问题的解决方式就是行锁,而解决幻读用的也是锁,叫做间隙锁,MySQL 把行锁和间隙锁合并在一起,解决了并发写和幻读的问题,这个锁叫做 Next-Key锁。
假设现在表中有两条记录,并且 age 字段已经添加了索引,两条记录 age 的值分别为 10 和 30。
此时,在数据库中会为索引维护一套B+树,用来快速定位行记录。B+索引树是有序的,所以会把这张表的索引分割成几个区间。
如图所示,分成了3 个区间,(负无穷,10]、(10,30]、(30,正无穷],在这3个区间是可以加间隙锁的。之后,我用下面的两个事务演示一下加锁过程。
在事务A提交之前,事务B的插入操作只能等待,这就是间隙锁起得作用。当事务A执行update user set name='风筝2号’ where age = 10;
的时候,由于条件 where age = 10
,数据库不仅在 age =10
的行上添加了行锁,而且在这条记录的两边,也就是(负无穷,10]、(10,30]这两个区间加了间隙锁,从而导致事务B插入操作无法完成,只能等待事务A提交。不仅插入 age = 10
的记录需要等待事务A提交,age<10、10
这是有索引的情况,如果 age 不是索引列,那么数据库会为整个表加上间隙锁。所以,如果是没有索引的话,不管 age 是否大于等于30,都要等待事务A提交才可以成功插入。
总结
MySQL 的 InnoDB 引擎才支持事务,其中可重复读是默认的隔离级别。读未提交和串行化基本上是不需要考虑的隔离级别,前者不加锁限制,后者相当于单线程执行,效率太差。读提交解决了脏读问题,行锁解决了并发更新的问题。并且 MySQL 在可重复读级别解决了幻读问题,是通过行锁和间隙锁的组合 Next-Key 锁实现的。
2.5有
2.5有
在MySQL默认的配置下,事务都是自动提交和回滚的。当显示地开启一个事务时,可以使用ROLLBACK 语句进行回滚。该语句有两种用法:
ROLLBACK:要使用这个语句的最简形式,只需发出ROLLBACK。
同样地,也可以写为ROLLBACK WORK,但是二者几乎是等价的。回滚会结束用户的事务,并撤销正在进行的所有未提交的修改。ROLLBACK TO [SAVEPOINT] identifier :
这个语句与SAVEPOINT命令一起使用。可以把事务回滚到标记点,而不回滚在此标记点之前的任何工作。
①使用索引
如果查询时没有使用索引,查询语句将扫描表中的所有记录。在数据量大的情况下,这样查询的速度会 很慢。如果使用索引进行查询,查询语句可以根据索引快速定位到待查询记录,从而减少查询的记录 数,达到提高查询速度的目的。
索引可以提高查询的速度,但并不是使用带有索引的字段查询时索引都会起作用。有几种特殊情况有可能使用带有索引的字段查询时索引并没有起作用。
使用LIKE关键字的查询语句
在使用LIKE关键字进行查询的查询语句中,如果匹配字符串的第一个字符为“%”,索引不会起作用。只有“%”不在第一个位置,索引才会起作用。
使用多列索引的查询语句
MySQL可以为多个字段创建索引。一个索引可以包括16个字段。对于多列索引,只有查询条件中 使用了这些字段中的第1个字段时索引才会被使用。
使用OR关键字的查询语句
查询语句的查询条件中只有OR关键字,且OR前后的两个条件中的列都是索引时,查询中才使用索 引。否则,查询将不使用索引。
②优化子查询
使用子查询可以进行SELECT语句的嵌套查询,即一个SELECT查询的结果作为另一个SELECT语句的条 件。子查询可以一次性完成很多逻辑上需要多个步骤才能完成的SQL操作。
子查询虽然可以使查询语句很灵活,但执行效率不高。执行子查询时,MySQL需要为内层查询语句的查询结果建立一个临时表。然后外层查询语句从临时表中查询记录。查询完毕后,再撤销这些临时表。因 此,子查询的速度会受到一定的影响。如果查询的数据量比较大,这种影响就会随之增大。
在MySQL中,可以使用连接(JOIN)查询来替代子查询。连接查询不需要建立临时表,其速度比子查询要快,如果查询中使用索引,性能会更好。
MySQL中提供了EXPLAIN语句和DESCRIBE语句,用来分析查询语句,EXPLAIN语句的。基本语法如下:
使用EXTENED关键字,EXPLAIN语句将产生附加信息。执行该语句,可以分析EXPLAIN后面SELECT语句的执行情况,并且能够分析出所查询表的一些特征。
下面对查询结果进行解释:
id:SELECT识别符。这是SELECT的查询序列号。
select_type:表示SELECT语句的类型。
table:表示查询的表。
type:表示表的连接类型。
possible_keys:给出了MySQL在搜索数据记录时可选用的各个索引。
key:是MySQL实际选用的索引。
key_len:给出索引按字节计算的长度,key_len数值越小,表示越快。
ref:给出了关联关系中另一个数据表里的数据列名。
rows:是MySQL在执行这个查询时预计会从这个数据表里读出的数据行的个数。
Extra:提供了与关联操作有关的信息。
扩展阅读
DESCRIBE语句的使用方法与EXPLAIN语句是一样的,分析结果也是一样的,并且可以缩写成DESC。。DESCRIBE语句的语法形式如下:
grep 命令。强大的文本搜索命令,grep(Global Regular Expression Print) 全局正则表达式搜索。
grep 的工作方式是这样的,它在一个或多个文件中搜索字符串模板。如果模板包括空格,则必须被引用,模板后的所有字符串被看作文件名。搜索的结果被送到标准输出,不影响原文件内容。
查看内存使用情况的指令:free命令。
“free -m”,命令查看内存使用情况。
查看进程运行状态、查看内存使用情况的指令均可使用top指令。
①free命令
Linux free命令用于显示内存状态。
free指令会显示内存的使用情况,包括实体内存,虚拟的交换文件内存,共享内存区段,以及系统核心使用的缓冲区等。
①top命令
top命令。显示当前系统正在执行的进程的相关信息,包括进程 ID、内存占用率、CPU 占用率等。
前五行是当前系统情况整体的统计信息区。
第一行:任务队列信息,同 uptime 命令的执行结果,具体参数说明情况如下:
00:12:54 — 当前系统时间。
up ?days, 4:49 — 系统已经运行了?天4小时49分钟(在这期间系统没有重启过)。
21users — 当前有1个用户登录系统。
load average: 0.06, 0.02, 0.00 — load average后面的三个数分别是1分钟、5分钟、15分钟的负载情况。load average数据是每隔5秒钟检查一次活跃的进程数,然后按特定算法计算出的数值。如果这个数除以逻辑CPU的数量,结果高于5的时候就表明系统在超负荷运转了。
第二行:Tasks — 任务(进程),具体信息说明如下:
系统现在共有256个进程,其中处于运行中的有1个,177个在休眠(sleep),stoped状态的 有0个,zombie状态(僵尸)的有0个。
第三行:cpu状态信息,具体属性说明如下:
0.2%us — 用户空间占用CPU的百分比。
0.2%sy — 内核空间占用CPU的百分比。
0.0% ni — 改变过优先级的进程占用CPU的百分比
99.5% id — 空闲CPU百分比
0.0% wa — IO等待占用CPU的百分比
0.0% hi — 硬中断(Hardware IRQ)占用CPU 的百分比
0.0% si — 软中断(Software Interrupts)占用CPU的百分比
第四行,内存状态,具体信息如下:
2017552 total — 物理内存总量
720188 used — 使用中的内存总量
197916 free — 空闲内存总量
1099448 cached — 缓存的总量
第五行,swap交换分区信息,具体信息说明如下:
998396 total — 交换区总量
989936 free — 空闲交换区总量
8460 used — 使用的交换区总量
1044136 cached — 缓冲的交换区总量
2里面有
2里面有
六种:管道,消息队列,共享内存,信号量,信号,套接字。
1 .管道
学习软件工程规范的时候,我们知道瀑布模型,在整个项目开发过程分为多个阶段,上一阶段的输出作为下一阶段的输入。各个阶段的具体内容如下图所示。
我们想知道如何查看进程或者端口是否在使用,会使用下面的这条命令
这里的"|“实际上就是管道的意思。”|“前面部分作为”|“后面的输入,很明显是单向的传输,这样的管道我们叫做"匿名管道”,自行创建和销毁。既然有匿名管道,应该就有带名字的管道"命名管道"。如果你想双向传输,可以考虑使用两个管道拼接即可。
创建命名管道的方式
test即为管道的名称,在Linux中一切皆文件,管道也是以文件的方式存在,咱们可以使用ls -l 查看下文件的属性,它会"p"标识。
下面我们向管道写入内容:
> echo "666" > test
此时按道理来说咋们已经将内容写入了test,没有直接输出是因为我们需要开启另一个终端进行输出(可以理解为暂存管道)
cat < test
我们发现管道内容被读出来,同时echo退出。那么管道这种通信方式有什么缺点?我们知道瀑布模型的软件开发模式是非常低下的,同理采用管道进行通信的效率也很低,因为假设现在有AB两个进程,A进程将数据写入管道,B进程需要等待A进程将信息写完以后才能读出来,所以这种方案不适合频繁的通信。那优点是什么?
最明显的优点就是简单,我们平时经常使用以致于都不知道这是管道。鉴于上面的缺点,我们怎么去弥补呢?接着往下看。
2 .消息队列
管道通信属于一股脑的输入,能不能稍微温柔点有规矩点的发送消息?
答:可以的。消息队列在发送数据的时候,按照一个个独立单元(消息体)进行发送,其中每个消息体规定大小块,同时发送方和接收方约定好消息类型或者正文的格式。
在管道中,其大小受限且只能承载无格式字节流的方式,而消息队列允许不同进程以消息队列的形式发送给任意的进程。
缺点:但是当发送到消息队列的数据太大,需要拷贝的时间也就越多。
所以还有其他的方式?继续看。
3 .共享内存
使用消息队列可以达到不错的效果,但是如果我们两个部门需要交换比较大的数据的时候,一发一收还是不能及时的感知数据。能不能更好的办法,**双方能很快的分享内容数据,**答:有的,共享内存。
我们知道每个进程都有自己的虚拟内存空间,不同的进程映射到不同的物理内存空间。那么我们可不可以申请一块虚拟地址空间,不同进程通过这块虚拟地址空间映射到相同的物理地址空间呢?这样不同进程就可以及时的感知进程都干了啥,就不需要再拷贝来拷贝去。
我们可以通过shmget创建一份共享内存,并可以通过ipcs命令查看我们创建的共享内存。此时如果一个进程需要访问这段内存,需要将这个内存加载到自己虚拟地址空间的一个位置,让内核给它一个合法地址。使用完毕接触板顶并删除内存对象。
缺点:这么多进程都共享这块内存,如果同时都往里面写内容,难免会出现冲突的现象,比如A进程写了数字5,B进程同样的地址写了6就直接给覆盖了,这样就不友好了,怎么办?继续往下看。
4 .信号量
为了防止共享内存冲突,我们得有个约束或者说一种保护机制。使得同一份共享的资源只能一个进程使用,这里就出现了信号量机制。
信号量实际上是一个计数器,这里需要注意下,信号量主要实现进程之间的同步和互斥,而不是存储通信内容。
信号量定义了两种操作,p操作和v操作,p操作为申请资源,会将数值减去M,表示这部分被他使用了,其他进程暂时不能用。v操作是归还资源操作,告知归还了资源可以用这部分。
5 .信号
从管道----消息队列-共享内存/信号量,有需要等待的管道机制,共享内存空间的进程通信方式,还有一种特殊的方式–信号
我们或许听说过运维或者部分开发需要7 * 24小时值守(项目需要上线的时候),当然也有各种监管,告警系统,一旦出现系统资源紧张等问题就会告知开发或运维人员,对应到操作系统中,这就是信号。
在操作系统中,不同信号用不同的值表示,每个信号设置相应的函数,一旦进程发送某一个信号给另一个进程,另一进程将执行相应的函数进行处理。也就是说先把可能出现的异常等问题准备好,一旦信号产生就执行相应的逻辑即可。
6 .套接字
上面的几种方式都是单机情况下多个进程的通信方式,如果我想和相隔几千里的小姐姐通信怎么办?
这就需要套接字socket了。其实这玩意随处可见,我们平时的聊天,我们天天请求浏览器给予的响应等,都是这老铁。
锁机制:包括互斥锁、条件变量、读写锁互斥锁提供了以排他方式防止数据结构被并发修改的方 法。读写锁允许多个线程同时读共享数据,而对写操作是互斥的。条件变量可以以原子的方式阻塞进程,直到某个特定条件为真为止。对条件的测试是在互斥锁的保护下进行的。条件变量始终与互斥锁一起使用。
信号量机制(Semaphore):包括无名线程信号量和命名线程信号量。
信号机制(Signal):类似进程间的信号处理线程间的通信目的主要是用于线程同步,所以线程没有像进程通信中的用于数据交换的通信机制。
内核态其实从本质上说就是内核,它是一种特殊的软件程序,控制计算机的硬件资源,例如协调CPU资源,分配内存资源,并且提供稳定的环境供应用程序运行。
用户态就是提供应用程序运行的空间,为了使应用程序访问到内核管理的资源例如CPU,内存,I/O。 内核必须提供一组通用的访问接口,这些接口就叫系统调用。
①系统调用是操作系统的最小功能单位。根据不同的应用场景,不同的Linux发行版本提供的系统调用数量也不尽相同,大致在240-350之间。这些系统调用组成了用户态跟内核态交互的基本接口。
② 从用户态到内核态切换可以通过三种方式:
从本质上来说,Spring Boot就是Spring,它做了那些没有它你自己也会去做的Spring Bean配置。Spring Boot使用“习惯优于配置”的理念让你的项目快速地运行起来,使用Spring Boot很容易创建一个能独立运行、准生产级别、基于Spring框架的项目,使用Spring Boot你可以不用或者只需要很少的Spring配置。
简而言之,**Spring Boot本身并不提供Spring的核心功能,而是作为Spring的脚手架框架,以达到快速构建项目、预置三方配置、开箱即用的目的。**Spring Boot有如下的优点:
1.可以快速构建项目;
2.可以对主流开发框架的无配置集成;
3.项目可独立运行,无需外部依赖Servlet容器;
4.提供运行时的应用监控;
5.可以极大地提高开发、部署效率;
6.可以与云计算天然集成。
使用Spring Boot时,我们只需引入对应的Starters,Spring Boot启动时便会自动加载相关依赖,配置相应的初始化参数,以最快捷、简单的形式对第三方软件进行集成,这便是Spring Boot的自动配置功能。Spring Boot实现该运作机制锁涉及的核心部分如下图所示:
整个自动装配的过程是:
Spring Boot通过@EnableAutoConfiguration注解开启自动配置,加载spring.factories中注册的各种AutoConfiguration类。
当某个AutoConfiguration类满足其注解@Conditional指定的生效条件(Starters提供的依赖、配置或Spring容器中是否存在某个Bean等)时,实例化该AutoConfiguration类中定义的Bean(组件等),并注入Spring容器,就可以完成依赖框架的自动配置。
Spring框架包含众多模块,如Core、Testing、Data Access、Web Servlet等,其中Core是整个Spring框架的核心模块。
Core模块提供了IoC容器、AOP功能、数据绑定、类型转换等一系列的基础功能,而 这些功能以及其他模块的功能都是建立在IoC和AOP之上的,所以IoC和AOP是Spring框架的核心。
IoC(Inversion of Control)是控制反转的意思,这是一种面向对象编程的设计思想。在不采用这种思想的情况下,我们需要自己维护对象与对象之间的依赖关系,很容易造成对象之间的耦合度过高,在一 个大型的项目中这十分的不利于代码的维护。IoC则可以解决这种问题,它可以帮我们维护对象与对象 之间的依赖关系,降低对象之间的耦合度。
说到IoC就不得不说DI(Dependency Injection),DI是依赖注入的意思,它是IoC实现的实现方式,就是说IoC是通过DI来实现的。由于IoC这个词汇比较抽象而DI却更直观,所以很多时候我们就用DI来代替 它,在很多时候我们简单地将IoC和DI划等号,这是一种习惯。而实现依赖注入的关键是IoC容器,它的 本质就是一个工厂。
AOP(Aspect Oriented Programing)是面向切面编程思想,这种思想是对OOP的补充,它可以在OOP的基础上进一步提高编程的效率。简单来说,它可以统一解决一批组件的共性需求(如权限检查、 记录日志、事务管理等)。在AOP思想下,我们可以将解决共性需求的代码独立出来,然后通过配置的 方式,声明这些代码在什么地方、什么时机调用。当满足调用条件时,AOP会将该业务代码织入到我们 指定的位置,从而统一解决了问题,又不需要修改这一批组件的代码。
Spring主要提供了两种类型的容器:BeanFactory和ApplicationContext。
①BeanFactory:是基础类型的IoC容器,提供完整的IoC服务支持。如果没有特殊指定,默认采用延迟初始化策略。只有当客户端对象需要访问容器中的某个受管对象的时候,才对该受管对象进行初始化以及依赖注入操作。所以,相对来说,容器启动初期速度较快,所需要的资源有限。对于资源 有限,并且功能要求不是很严格的场景,BeanFactory是比较合适的IoC容器选择。
②ApplicationContext:它是在BeanFactory的基础上构建的,是相对比较高级的容器实现,除了拥 有BeanFactory的所有支持,ApplicationContext还提供了其他高级特性,比如事件发布、国际化 信息支持等。ApplicationContext所管理的对象,在该类型容器启动之后,默认全部初始化并绑定 完成。所以,相对于BeanFactory来说,ApplicationContext要求更多的系统资源,同时,因为在 启动时就完成所有初始化,容器启动时间较之BeanFactory也会长一些。在那些系统资源充足,并且要求更多功能的场景中,ApplicationContext类型的容器是比较合适的选择。
IoC(Inversion of Control)是控制反转的意思,这是一种面向对象编程的设计思想。在不采用这种思想的情况下,我们需要自己维护对象与对象之间的依赖关系,很容易造成对象之间的耦合度过高,在一 个大型的项目中这十分的不利于代码的维护。IoC则可以解决这种问题,它可以帮我们维护对象与对象 之间的依赖关系,降低对象之间的耦合度。
说到IoC就不得不说DI(Dependency Injection),DI是依赖注入的意思,它是IoC实现的实现方式,就是说IoC是通过DI来实现的。由于IoC这个词汇比较抽象而DI却更直观,所以很多时候我们就用DI来代替 它,在很多时候我们简单地将IoC和DI划等号,这是一种习惯。而实现依赖注入的关键是IoC容器,它的本质就是一个工厂。
在具体的实现中,主要由三种注入方式:
构造方法注入
就是被注入对象可以在它的构造方法中声明依赖对象的参数列表,让外部知道它需要哪些依赖对 象。然后,IoC Service Provider会检查被注入的对象的构造方法,取得它所需要的依赖对象列表,进而为其注入相应的对象。构造方法注入方式比较直观,对象被构造完成后,即进入就绪状 态,可以马上使用。
setter方法注入
通过setter方法,可以更改相应的对象属性。所以,当前对象只要为其依赖对象所对应的属性添加setter方法,就可以通过setter方法将相应的依赖对象设置到被注入对象中。setter方法注入虽不 像构造方法注入那样,让对象构造完成后即可使用,但相对来说更宽松一些,可以在对象构造完成后再注入。
接口注入
相对于前两种注入方式来说,接口注入没有那么简单明了。被注入对象如果想要IoC Service Provider为其注入依赖对象,就必须实现某个接口。这个接口提供一个方法,用来为其注入依赖对 象。IoC Service Provider最终通过这些接口来了解应该为被注入对象注入什么依赖对象。相对于前两种依赖注入方式,接口注入比较死板和烦琐。
总体来说,构造方法注入和setter方法注入因为其侵入性较弱,且易于理解和使用,所以是现在使用最 多的注入方式。而接口注入因为侵入性较强,近年来已经不流行了。
前言:bean是一个由Spring IoC容器实例化、组装和管理的对象。
Spring通过IoC容器来管理Bean,我们可以通过XML配置或者注解配置,来指导IoC容器对Bean的管理。因为注解配置比XML配置方便很多,所以现在大多时候会使用注解配置的方式。
以下是管理Bean时常用的一些注解:
@ComponentScan用于声明扫描策略,通过它的声明,容器就知道要扫描哪些包下带有声明的类,也可以知道哪些特定的类是被排除在外的。
@Component、@Repository、@Service、@Controller用于声明Bean,它们的作用一样,但是语义不同。@Component用于声明通用的Bean,@Repository用于声明DAO层的Bean,@Service用于声明业务层的Bean,@Controller用于声明视图层的控制器Bean,被这些注解声明的类就可以被容器扫描并创建。
@Autowired、@Qualifier用于注入Bean,即告诉容器应该为当前属性注入哪个Bean。其中,@Autowired是按照Bean的类型进行匹配的,如果这个属性的类型具有多个Bean,就可以通过@Qualifier指定Bean的名称,以消除歧义。
@Scope用于声明Bean的作用域,默认情况下Bean是单例的,即在整个容器中这个类型只有一个 实例。可以通过@Scope注解指定prototype值将其声明为多例的,也可以将Bean声明为session 级作用域、request级作用域等等,但最常用的还是默认的单例模式。
@PostConstruct、@PreDestroy用于声明Bean的生命周期。其中,被@PostConstruct修饰的方法将在Bean实例化后被调用,@PreDestroy修饰的方法将在容器销毁前被调用。
默认情况下,Bean在Spring容器中是单例的,我们可以通过@Scope注解修改Bean的作用域。该注解有如下5个取值,它们代表了Bean的5种不同类型的作用域:
Spring容器管理Bean,涉及对Bean的创建、初始化、调用、销毁等一系列的流程,这个流程就是Bean的生命周期。
在传统的Java应用中,bean的生命周期很简单,使用Java关键字 new 进行Bean 的实例化,然后该Bean 就能够使用了。一旦bean不再被使用,则由Java自动进行垃圾回收。
相比之下,Spring管理Bean的生命周期就复杂多了,正确理解Bean 的生命周期非常重要,因为Spring对Bean的管理可扩展性非常强,下面展示了一个Bean的构造过程:
如上图所示,Bean 的生命周期还是比较复杂的,下面来对上图每一个步骤做文字描述:
①Spring启动,查找并加载需要被Spring管理的bean,进行Bean的实例化。
②Bean实例化后对将Bean的引入和值注入到Bean的属性中。
③如果Bean实现了BeanNameAware接口的话,Spring将Bean的Id传递给setBeanName()方法。
④如果Bean实现了BeanFactoryAware接口的话,Spring将调用setBeanFactory()方法,将BeanFactory容器实例传入。
⑤如果Bean实现了ApplicationContextAware接口的话,Spring将调用Bean的setApplicationContext()方法,将bean所在应用上下文引用传入进来。
⑥如果Bean实现了BeanPostProcessor接口,Spring就将调用他们的postProcessBeforeInitialization()方法。
⑦如果Bean 实现了InitializingBean接口,Spring将调用他们的afterPropertiesSet()方法。类似的,如果bean使用init-method声明了初始化方法,该方法也会被调用。
⑧如果Bean 实现了BeanPostProcessor接口,Spring就将调用他们的postProcessAfterInitialization()方法。
⑨此时,Bean已经准备就绪,可以被应用程序使用了。他们将一直驻留在应用上下文中,直到应用上下文被销毁。
⑩如果bean实现了DisposableBean接口,Spring将调用它的destory()接口方法,同样,如果bean使用了destory-method 声明销毁方法,该方法也会被调用。
上面是Spring 中Bean的核心接口和生命周期,面试回答上述过程已经足够了。
这个过程是由Spring容器自动管理的,其中有两个环节我们可以进行干预。
我们可以自定义初始化方法,并在该方法前增加@PostConstruct注解,届时Spring容器将在调用SetBeanFactory方法之后调用该方法。
我们可以自定义销毁方法,并在该方法前增加@PreDestroy注解,届时Spring容器将在自身销毁前,调用这个方法。
前言:AOP实质上就是把共同的功能向上抽象。还能实现比如:日志统计、异常的处理、事务管理等,基于此思路可以做很多的功能。比如,一个大门有很多小门,每个小门都要检查,不如直接在大门口检查
AOP(Aspect Oriented Programming)是面向切面编程,它是一种编程思想,是面向对象编程(OOP)的一种补充。面向对象编程将程序抽象成各个层次的对象,而面向切面编程是将程序抽象成各 个切面。如下图,可以很形象地看出,所谓切面,相当于应用对象间的横切点,我们可以将其单独抽象 为单独的模块。
AOP的术语:
①连接点(join point):对应的是具体被拦截的对象,因为Spring只能支持方法,所以被拦截的对象往往就是指特定的方法,AOP将通过动态代理技术把它织入对应的流程中。
②切点(point cut):有时候,我们的切面不单单应用于单个方法,也可能是多个类的不同方法, 这时,可以通过正则式和指示器的规则去定义,从而适配连接点。切点就是提供这样一个功能的概 念。
③通知(advice):就是按照约定的流程下的方法,分为前置通知、后置通知、环绕通知、事后返回 通知和异常通知,它会根据约定织入流程中。
④目标对象(target):即被代理对象。
⑤引入(introduction):是指引入新的类和其方法,增强现有Bean的功能。
⑥织入(weaving):它是一个通过动态代理技术,为原有服务对象生成代理对象,然后将与切点定 义匹配的连接点拦截,并按约定将各类通知织入约定流程的过程。
⑦切面(aspect):是一个可以定义切点、各类通知和引入的内容,SpringAOP将通过它的信息来 增强Bean的功能或者将对应的方法织入流程。
Spring AOP:
AOP可以有多种实现方式,而Spring AOP支持如下两种实现方式。
①JDK动态代理:这是Java提供的动态代理技术,可以在运行时创建接口的代理实例。Spring AOP默认采用这种方式,在接口的代理实例中织入代码。
②CGLib动态代理:采用底层的字节码技术,在运行时创建子类代理的实例。当目标对象不存在接口 时,Spring AOP就会采用这种方式,在子类实例中织入代码。
前言:Spring MVC是一个基于Java实现的MVC设计模式的请求驱动类型的轻量级Web框架,通过把模型-视图-控制器分离,将Web层进行职责解耦,把复杂的Web应用分成逻辑清晰的几个部分,简化开发工作,减少出错,方便组内开发人员之间的配合。
整个过程开始于客户端发出的一个HTTP请求,Web应用服务器接收到这个请求。如果匹配DispatcherServlet的请求映射路径,则Web容器将该请求转交给DispatcherServlet处理。
DispatcherServlet接收到这个请求后,将根据请求的信息(包括URL、HTTP方法、请求报文头、 请求参数、Cookie等)及HandlerMapping的配置找到处理请求的处理器(Handler)。可将HandlerMapping看做路由控制器,将Handler看做目标主机。值得注意的是,在Spring MVC中并没有定义一个Handler接口,实际上任何一个Object都可以成为请求处理器。
当DispatcherServlet根据HandlerMapping得到对应当前请求的Handler后,通过HandlerAdapter对Handler进行封装,再以统一的适配器接口调用Handler。HandlerAdapter是Spring MVC框架级接口,顾名思义,HandlerAdapter是一个适配器,它用统一的接口对各种Handler方法进行调用。
处理器完成业务逻辑的处理后,将返回一个ModelAndView给DispatcherServlet,ModelAndView包含了视图逻辑名和模型数据信息。
ModelAndView中包含的是“逻辑视图名”而非真正的视图对象,DispatcherServlet借由ViewResolver完成逻辑视图名到真实视图对象的解析工作。
当得到真实的视图对象View后,DispatcherServlet就使用这个View对象对ModelAndView中的模型数据进行视图渲染。
最终客户端得到的响应消息可能是一个普通的HTML页面,也可能是一个XML或JSON串,甚至是一张图片或一个PDF文档等不同的媒体形式。
使用#设置参数时,MyBatis会创建预编译的SQL语句,然后在执行SQL时MyBatis会为预编译SQL中的占位符(?)赋值。预编译的SQL语句执行效率高, 并且可以防止注入攻击。
使用$设置参数时,MyBatis只是创建普通的SQL语句,然后在执行SQL语句时MyBatis将参数直接拼入到SQL里。这种方式在效率、安全性上均不如前者,但是可以解决一些特殊情况下的问题。例如,在一 些动态表格(根据不同的条件产生不同的动态列)中,我们要传递SQL的列名,根据某些列进行排序, 或者传递列名给SQL都是比较常见的场景,这就无法使用预编译的方式了。
①GET在浏览器回退时是无害的,而POST会再次提交请求。
②GET产生的URL地址可以被Bookmark,而POST不可以。
③GET请求会被浏览器主动cache,而POST不会,除非手动设置。
④GET请求只能进行url编码,而POST支持多种编码方式。
⑤GET请求参数会被完整保留在浏览器历史记录里,而POST中的参
数不会被保留。
⑥GET请求在URL中传送的参数是有长度限制的,而POST没有。
⑦对参数的数据类型,GET只接受ASCII字符,而POST没有限制。
⑧GET比POST更不安全,因为参数直接暴露在URL上,所以不能用来
传递敏感信息。
⑨GET参数通过URL传递,POST放在Request body中。
前言:IO 多路复用是一种同步IO模型,实现一个线程可以监视多个文件句柄;一旦某个文件句柄就绪,就能够通知应用程序进行相应的读写操作;没有文件句柄就绪就会阻塞应用程序,交出CPU。多路是指网络连接,复用指的是同一个线程。
实现Redis的高可用,主要有哨兵和集群。
①哨兵:
Redis Sentinel(哨兵)是一个分布式架构,它包含若干个哨兵节点和数据节点。每个哨兵节点会对数据节点和其余的哨兵节点进行监控,当发现节点不可达时,会对节点做下线标识。如果被标识的是主节 点,它就会与其他的哨兵节点进行协商,当多数哨兵节点都认为主节点不可达时,它们便会选举出一个 哨兵节点来完成自动故障转移的工作,同时还会将这个变化实时地通知给应用方。整个过程是自动的,不需要人工介入,有效地解决了Redis的高可用问题!
一组哨兵可以监控一个主节点,也可以同时监控多个主节点,两种情况的拓扑结构如下图:
哨兵节点包含如下的特征:
②集群:
Redis集群采用虚拟槽分区来实现数据分片,它把所有的键根据哈希函数映射到 0-16383 整数槽内,计算公式为slot=CRC16(key)&16383 ,每一个节点负责维护一部分槽以及槽所映射的键值数据。虚拟槽分区具有如下特点:
①缓存穿透:
问题描述:
客户端查询根本不存在的数据,使得请求直达存储层,导致其负载过大,甚至宕
机。出现这种情况的原 因,可能是业务层误将缓存和库中的数据删除了,也可能是有人恶意攻击,专门访问库中不存在的数 据。
解决方案:
②缓存击穿:
问题描述:
一份热点数据,它的访问量非常大。在其缓存失效的瞬间,大量请求直达存储层,导致服务崩溃。
解决方案:
③缓存雪崩:
问题描述:
在某一时刻,缓存层无法继续提供服务,导致所有的请求直达存储层,造成数据库宕机。可能是缓存中 有大量数据同时过期,也可能是Redis节点发生故障,导致大量请求无法得到处理。
解决方案:
四种同步策略:
想要保证缓存与数据库的双写一致,一共有4种方式,即4种同步策略:
从这4种同步策略中,我们需要作出比较的是:
① 更新缓存与删除缓存哪种方式更合适?
② 应该先操作数据库还是先操作缓存?
更新缓存还是删除缓存:
下面,我们来分析一下,应该采用更新缓存还是删除缓存的方式。
更新缓存
优点:每次数据变化都及时更新缓存,所以查询时不容易出现未命中的情况。
缺点:更新缓存的消耗比较大。如果数据需要经过复杂的计算再写入缓存,那
么频繁的更新缓存, 就会影响服务器的性能。如果是写入数据频繁的业务景,
那么可能频繁的更新缓存时,却没有业 务读取该数据。
删除缓存
优点:操作简单,无论更新操作是否复杂,都是将缓存中的数据直接删除。
缺点:删除缓存后,下一次查询缓存会出现未命中,这时需要重新读取一次数据库。
从上面的比较来看,一般情况下,删除缓存是更优的方案。
先操作数据库还是缓存:
下面,我们再来分析一下,应该先操作数据库还是先操作缓存。
一、首先,我们将先删除缓存与先更新数据库,在出现失败时进行一个对比:
如上图,是先删除缓存再更新数据库,在出现失败时可能出现的问题:
最终,缓存和数据库的数据是一致的,但仍然是旧的数据。而我们的期望是二者数据一致,并且是新的 数据。
如上图,是先更新数据库再删除缓存,在出现失败时可能出现的问题:
最终,缓存和数据库的数据是不一致的。
经过上面的比较,我们发现在出现失败的时候,是无法明确分辨出先删缓存和先更新数据库哪个方式更 好,因为它们都存在问题。后面我们会进一步对这两种方式进行比较,但是在这里我们先探讨一下,上 述场景出现的问题,应该如何解决呢?
实际上,无论上面我们采用哪种方式去同步缓存与数据库,在第二步出现失败的时候,都建议采用重试 机制解决,因为最终我们是要解决掉这个错误的。而为了避免重试机制影响主要业务的执行,一般建议 重试机制采用异步的方式执行,如下图:
这里我们按照先更新数据库,再删除缓存的方式,来说明重试机制的主要步骤:
二、再将先删缓存与先更新数据库,在没有出现失败时进行对比:
如上图,是先删除缓存再更新数据库,在没有出现失败时可能出现的问题:
①. 进程A删除缓存成功;
②. 进程B读取缓存失败;
③. 进程B读取数据库成功,得到旧的数据;
④. 进程B将旧的数据成功地更新到了缓存;
⑤. 进程A将新的数据成功地更新到数据库。
可见,进程A的两步操作均成功,但由于存在并发,在这两步之间,进程B访问了缓存。最终结果是,缓 存中存储了旧的数据,而数据库中存储了新的数据,二者数据不一致。
如上图,是先更新数据库再删除缓存,再没有出现失败时可能出现的问题:
可见,最终缓存与数据库的数据是一致的,并且都是最新的数据。但进程B在这个过程里读到了旧的数 据,可能还有其他进程也像进程B一样,在这两步之间读到了缓存中旧的数据,但因为这两步的执行速 度会比较快,所以影响不大。对于这两步之后,其他进程再读取缓存数据的时候,就不会出现类似于进 程B的问题了。
最终结论:
经过对比你会发现,先更新数据库、再删除缓存是影响更小的方案。如果第二步出现失败的情况,则可 以采用重试机制解决问题。
扩展阅读
延时双删
上面我们提到,如果是先删缓存、再更新数据库,在没有出现失败时可能会导致数据的不一致。如果在 实际的应用中,出于某些考虑我们需要选择这种方式,那有办法解决这个问题吗?答案是有的,那就是 采用延时双删的策略,延时双删的基本思路如下:
阻塞一段时间之后,再次删除缓存,就可以把这个过程中缓存中不一致的数据删除掉。而具体的时间, 要评估你这项业务的大致时间,按照这个时间来设定即可。
采用读写分离的架构怎么办?
如果数据库采用的是读写分离的架构,那么又会出现新的问题,如下图:
进程A先删除缓存,再更新主数据库,然后主库将数据同步到从库。而在主从数据库同步之前,可能会 有进程B访问了缓存,发现数据不存在,进而它去访问从库获取到旧的数据,然后同步到缓存。这样, 最终也会导致缓存与数据库的数据不一致。这个问题的解决方案,依然是采用延时双删的策略,但是在 评估延长时间的时候,要考虑到主从数据库同步的时间。
第二次删除失败了怎么办?
如果第二次删除依然失败,则可以增加重试的次数,但是这个次数要有限制,当超出一定的次数时,要 采取报错、记日志、发邮件提醒等措施。
何时需要分布式锁?
在分布式的环境下,当多个server并发修改同一个资源时,为了避免竞争就需要使用分布式锁。那为什 么不能使用Java自带的锁呢?因为Java中的锁是面向多线程设计的,它只局限于当前的JRE环境。而多个server实际上是多进程,是不同的JRE环境,所以Java自带的锁机制在这个场景下是无效的。
如何实现分布式锁?
采用Redis实现分布式锁,就是在Redis里存一份代表锁的数据,通常用字符串即可。实现分布式锁的思 路,以及优化的过程如下:
1. 加锁:
第一版,这种方式的缺点是容易产生死锁,因为客户端有可能忘记解锁,或者解锁失败。
第二版,给锁增加了过期时间,避免出现死锁。但这两个命令不是原子的,第
二步可能会失败,依 然无法避免死锁问题。
第三版,通过“set…nx…”命令,将加锁、过期命令编排到一起,它们是原子操作了,可以避免死锁。
2. 解锁:
解锁就是删除代表锁的那份数据。
3. 问题:
看起来已经很完美了,但实际上还有隐患,如下图。进程A在任务没有执行完毕时,锁已经到期被 释放了。等进程A的任务执行结束后,它依然会尝试释放锁,因为它的代码逻辑就是任务结束后释 放锁。但是,它的锁早已自动释放过了,它此时释放的可能是其他线程的锁。
解决这个问题,我们需要解决两件事情:
在加锁时就要给锁设置一个标识,进程要记住这个标识。当进程解锁的时候,要进行判断,是自己持有的锁才能释放,否则不能释放。可以为key赋一个随机值,来充当进程的标识。
解锁时要先判断、再释放,这两步需要保证原子性,否则第二步失败的话,就会出现死锁。而获取和删除命令不是原子的,这就需要采用Lua脚本,通过Lua脚本将两个命令编排在一起,而整个Lua脚本的执行是原子的。
按照以上思路,优化后的命令如下:
基于RedLock算法的分布式锁:
上述分布式锁的实现方案,是建立在单个主节点之上的。它的潜在问题如下图所示,如果进程A在主节 点上加锁成功,然后这个主节点宕机了,则从节点将会晋升为主节点。若此时进程B在新的主节点上加 锁成功,之后原主节点重启,成为了从节点,系统中将同时出现两把锁,这是违背锁的唯一性原则的。
总之,就是在单个主节点的架构上实现分布式锁,是无法保证高可用的。若要保证分布式锁的高可用, 则可以采用多个节点的实现方案。这种方案有很多,而Redis的官方给出的建议是采用RedLock算法的 实现方案。该算法基于多个Redis节点,它的基本逻辑如下:
①这些节点相互独立,不存在主从复制或者集群协调机制;
②加锁:以相同的KEY向N个实例加锁,只要超过一半节点成功,则认定加锁成功;
③解锁:向所有的实例发送DEL命令,进行解锁;
RedLock算法的示意图如下,我们可以自己实现该算法,也可以直接使用Redisson框架。
消息队列有很多使用场景,比较常见的有3个:解耦、异步、削峰。
解耦:传统的软件开发模式,各个模块之间相互调用,数据共享,每个模块都要时刻关注其他模块的是否更改或者是否挂掉等等,使用消息队列,可以避免模块之间直接调用,将所需共享的数据放在消息队列中,对于新增业务模块,只要对该类消息感兴趣,即可订阅该类消息,对原有系统和业务没有任何影响,降低了系统各个模块的耦合度,提高了系统的可扩展性。
异步:消息队列提供了异步处理机制,在很多时候应用不想也不需要立即处理消息,允许应用把一些消息放入消息中间件中,并不立即处理它,在之后需要的时候再慢慢处理。
削峰:在访问量骤增的场景下,需要保证应用系统的平稳性,但是这样突发流量并不常见,如果以这类峰值的标准而投放资源的话,那无疑是巨大的浪费。使用消息队列能够使关键组件支撑突发访问压力,不会因为突发的超负荷请求而完全崩溃。消息队列的容量可以配置的很大,如果采用磁盘存储消息,则几乎等于“无限”容量,这样一来,高峰期的消息可以被积压起来,在随后的时间内进行平滑的处理完成,而不至于让系统短时间内无法承载而导致崩溃。在电商网站的秒杀抢购这种突发性流量很强的业务场景中,消息队列的强大缓冲能力可以很好的起到削峰作用。
所谓生产者-消费者问题,实际上主要是包含了两类线程。一种是生产者线程用于生产数据,另一种是 消费者线程用于消费数据,为了解耦生产者和消费者的关系,通常会采用共享的数据区域,就像是一个 仓库。
生产者生产数据之后直接放置在共享数据区中,并不需要关心消费者的行为。而消费者只需要从 共享数据区中去获取数据,就不再需要关心生产者的行为。但是,这个共享数据区域中应该具备这样的线程间并发协作的功能:
在Java语言中,实现生产者消费者问题时,可以采用三种方式:
①使用 Object 的 wait/notify 的消息通知机制;
② 使用 Lock 的 Condition 的 await/signal 的消息通知机制;
③ 使用 BlockingQueue 实现。
在生产中经常会有一些类似报表系统这样的系统,需要做 MySQL 的 binlog 同步。比如订单系统要同步订单表的数据到大数据部门的 MySQL 库中用于报表统计分析,通常的做法是基于 Canal 这样的中间件去监听订单数据库的 binlog,然后把这些 binlog 发送到 MQ 中,再由消费者从 MQ 中获取 binlog 落地到大数据部门的 MySQL 中。
在这个过程中,可能会有对某个订单的增删改操作,比如有三条 binlog 执行顺序是增加、修改、删除。消费者愣是换了顺序给执行成删除、修改、增加,这样能行吗?肯定是不行的。不同的消息队列产 品,产生消息错乱的原因,以及解决方案是不同的。下面我们以RabbitMQ、Kafka、RocketMQ为例,来说明保证顺序消费的办法。
RabbitMQ:
对于 RabbitMQ 来说,导致上面顺序错乱的原因通常是消费者是集群部署,不同的消费者消费到了同一订单的不同的消息。如消费者A执行了增加,消费者B执行了修改,消费者C执行了删除,但是消费者C 执行比消费者B快,消费者B又比消费者A快,就会导致消费 binlog 执行到数据库的时候顺序错乱,本该顺序是增加、修改、删除,变成了删除、修改、增加。如下图:
RabbitMQ 的问题是由于不同的消息都发送到了同一个 queue 中,多个消费者都消费同一个 queue 的消息。解决这个问题,我们可以给 RabbitMQ 创建多个queue,每个消费者固定消费一个 queue 的消息,生产者发送消息的时候,同一个订单号的消息发送到同一个 queue 中,由于同一个 queue 的消息是一定会保证有序的,那么同一个订单号的消息就只会被一个消费者顺序消费,从而保证了消息的顺序 性。如下图:
Kafka
对于 Kafka 来说,一个 topic 下同一个 partition 中的消息肯定是有序的,生产者在写的时候可以指定一个 key,通过我们会用订单号作为 key,这个 key 对应的消息都会发送到同一个 partition 中,所以消费者消费到的消息也一定是有序的。
那么为什么 Kafka 还会存在消息错乱的问题呢?问题就出在消费者身上。通常我们消费到同一个 key 的多条消息后,会使用多线程技术去并发处理来提高消息处理速度,否则一条消息的处理需要耗时几十 毫秒,1 秒也就只能处理几十条消息,吞吐量就太低了。而多线程并发处理的话,binlog 执行到数据库的时候就不一定还是原来的顺序了。如下图:
Kafka 从生产者到消费者消费消息这一整个过程其实都是可以保证有序的,导致最终乱序是由于消费者端需要使用多线程并发处理消息来提高吞吐量,比如消费者消费到了消息以后,开启 32 个线程处理消息,每个线程线程处理消息的快慢是不一致的,所以才会导致最终消息有可能不一致。
所以对于 Kafka 的消息顺序性保证,其实我们只需要保证同一个订单号的消息只被同一个线程处理的就可以了。由此我们可以在线程处理前增加个内存队列,每个线程只负责处理其中一个内存队列的消息, 同一个订单号的消息发送到同一个内存队列中即可。如下图:
RocketMQ
对于 RocketMQ 来说,每个 Topic 可以指定多个 MessageQueue,当我们写入消息的时候,会把消息均匀地分发到不同的 MessageQueue 中,比如同一个订单号的消息,增加 binlog 写入到MessageQueue1 中,修改 binlog 写入到
MessageQueue2 中,删除 binlog 写入到 MessageQueue3中。
但是当消费者有多台机器的时候,会组成一个 Consumer Group,ConsumerGroup 中的每台机器都会负责消费一部分MessageQueue 的消息,所以可能消费 者 A 消 费 了 MessageQueue1 的 消 息 执 行 增 加 操 作 , 消 费 者 B 消费了MessageQueue2 的消息执行修改操作,消费者C消费了 MessageQueue3 的消息执行删除操作,但是此时消费 binlog 执行到数据库的时候就不一定是消费者A先执行了,有可能消费者C先执行删除操作,因为几台消费者是并行执行,是不能够保证他们之间的执行顺序的。如下图:
RocketMQ 的消息乱序是由于同一个订单号的 binlog 进入了不同的MessageQueue,进而导致一个订单的 binlog 被不同机器上的 Consumer 处理。要解决 RocketMQ 的乱序问题,我们只需要想办法让同一个订单的 binlog 进入到同一个MessageQueue 中就可以了。因为同一个 MessageQueue 内的消息是一定有序的,一个MessageQueue 中的消息只能交给一个 Consumer 来进行处理,所以 Consumer 消费的时候就一定会是有序的。
丢数据一般分为两种,一种是mq把消息丢了,一种就是消费时将消息丢了。下面从rabbitmq和kafka 分别说一下,丢失数据的场景。
RabbitMQ:
RabbitMQ丢失消息分为如下几种情况:
针对上述三种情况,RabbitMQ可以采用如下方式避免消息丢失:
生产者丢消息:
①可以选择使用RabbitMQ提供是事务功能,就是生产者在发送数据之前开启事务,然后发送消 息,如果消息没有成功被RabbitMQ接收到,那么生产者会受到异常报错,这时就可以回滚事 务,然后尝试重新发送。如果收到了消息,那么就可以提交事务。这种方式有明显的缺点, 即RabbitMQ事务开启后,就会变为同步阻塞操作,生产者会阻塞等待是否发送成功,太耗性能会造成吞吐量的下降。
②可以开启confirm模式。在生产者那里设置开启了confirm模式之后,每次写的消息都会分配 一个唯一的id,然后如何写入了RabbitMQ之中,RabbitMQ会给你回传一个ack消息,告诉你这个消息发送OK了。如果RabbitMQ没能处理这个消息,会回调你一个nack接口,告诉你 这个消息replication.factor失败了,你可以进行重试。而且你可以结合这个机制知道自己在内存里维护每个消 息的id,如果超过一定时间还没接收到这个消息的回调,那么你可以进行重发。事务机制是同步的,你提交了一个事物之后会阻塞住,但是confirm机制是异步的,发送消息之后 可以接着发送下一个消息,然后RabbitMQ会回调告知成功与
否。 一般在生产者这块避免丢失,都是用confirm机制。
RabbitMQ自己丢消息:
设置消息持久化到磁盘,设置持久化有两个步骤:
①创建queue的时候将其设置为持久化的,这样就可以保证RabbitMQ持久化queue的元数据,但是不会持久化queue里面的数据。
②发送消息的时候讲消息的deliveryMode设置为2,这样消息就会被设为持久化方式,此时RabbitMQ就会将消息持久化到磁盘上。 必须要同时开启这两个才可以。而且持久化可以跟生产的confirm机制配合起来,只有消息持久化到了磁盘之后,才会通知生产者ack,这样就算是在持久化之前RabbitMQ挂了,数据丢了,生产者收不到ack回调也会进行消息重 发。
消费端丢消息:
使用RabbitMQ提供的ack机制,首先关闭RabbitMQ的自动ack,然后每次在确保处理完这个消息之后,在代码里手动调用ack。这样就可以避免消息还没有处理完就ack。
Kafka
Kafka丢失消息分为如下几种情况:
针对上述三种情况,Kafka可以采用如下方式避免消息丢失:
先大概说一说可能会有哪些重复消费的问题。首先就是比如rabbitmq、rocketmq、kafka,都有可能会 出现消费重复消费的问题,正常。因为这问题通常不是mq自己保证的,是给你保证的。然后我们挑一 个kafka来举个例子,说说怎么重复消费吧。
kafka实际上有个offset的概念,就是每个消息写进去,都有一个offset,代表他的序号,然后consumer消费了数据之后,每隔一段时间,会把自己消费过的消息的offset提交一下,代表我已经消 费过了,下次我要是重启啥的,你就让我继续从上次消费到的offset来继续消费吧。
但是凡事总有意外,比如我们之前生产经常遇到的,就是你有时候重启系统,看你怎么重启了,如果碰 到点着急的,直接kill进程了,再重启。这会导致consumer有些消息处理了,但是没来得及提交offset,尴尬了。重启之后,少数消息会再次消费一次。
其实重复消费不可怕,可怕的是你没考虑到重复消费之后,怎么保证幂等性。举个例子,假设你有个系 统,消费一条往数据库里插入一条,要是你一个消息重复两次,你不就插入了两条,这数据不就错了? 但是你要是消费到第二次的时候,自己判断一下已经消费过了,直接扔了,不就保留了一条数据?
一条数据重复出现两次,数据库里就只有一条数据,这就保证了系统的幂等性幂等性。通俗点说,就一 个数据,或者一个请求,给你重复来多次,你得确保对应的数据是不会改变的,不能出错。
想要保证不重复消费,其实还要结合业务来思考,这里给几个思路:
还有比如基于数据库的唯一键来保证重复数据不会重复插入多条,我们之前线上系统就有这个问题,就 是拿到数据的时候,每次重启可能会有重复,因为kafka消费者还没来得及提交offset,重复数据拿到了 以后我们插入的时候,因为有唯一键约束了,所以重复数据只会插入报错,不会导致数据库中出现脏数 据。
一般生产环境中,都会在使用MQ的时候设计两个队列:一个是核心业务队列,一个是死信队列。核心 业务队列,就是比如专门用来让订单系统发送订单消息的,然后另外一个死信队列就是用来处理异常情 况的。
比如说要是第三方物流系统故障了,此时无法请求,那么仓储系统每次消费到一条订单消息,尝试通知 发货和配送,都会遇到对方的接口报错。此时仓储系统就可以把这条消息拒绝访问,或者标志位处理失 败!注意,这个步骤很重要。
一旦标志这条消息处理失败了之后,MQ就会把这条消息转入提前设置好的一个死信队列中。然后你会 看到的就是,在第三方物流系统故障期间,所有订单消息全部处理失败,全部会转入死信队列。然后你 的仓储系统得专门有一个后台线程,监控第三方物流系统是否正常,能否请求的,不停的监视。一旦发 现对方恢复正常,这个后台线程就从死信队列消费出来处理失败的订单,重新执行发货和配送的通知逻 辑。死信队列的使用,其实就是MQ在生产实践中非常重要的一环,也就是架构设计必须要考虑的。
Kafka的消息是保存或缓存在磁盘上的,一般认为在磁盘上读写数据是会降低性能的,因为寻址会比较 消耗时间,但是实际上,Kafka的特性之一就是高吞吐率。即使是普通的服务器,Kafka也可以轻松支持 每秒百万级的写入请求,超过了大部分的消息中间件,这种特性也使得Kafka在日志处理等海量数据场 景广泛应用。
下面从数据写入和读取两方面分析,为什么Kafka速度这么快:
写入数据:
Kafka会把收到的消息都写入到硬盘中,它绝对不会丢失数据。为了优化写入速度Kafka采用了两个技 术,顺序写入和MMFile 。
一、顺序写入
磁盘读写的快慢取决于你怎么使用它,也就是顺序读写或者随机读写。在顺序读写的情况下,磁盘的顺 序读写速度和内存持平。因为硬盘是机械结构,每次读写都会寻址->写入,其中寻址是一个“机械动作”,它是最耗时的。所以硬盘最讨厌随机I/O,最喜欢顺序I/O。为了提高读写硬盘的速度,Kafka就是使用顺序I/O。
而且Linux对于磁盘的读写优化也比较多,包括read-ahead和write-behind,磁盘缓存等。如果在内存 做这些操作的时候,一个是JAVA对象的内存开销很大,另一个是随着堆内存数据的增多,JAVA的GC时 间会变得很长,使用磁盘操作有以下几个好处:
下图就展示了Kafka是如何写入数据的, 每一个Partition其实都是一个文件 ,收到消息后Kafka会把数据插入到文件末尾(虚框部分):
这种方法有一个缺陷——没有办法删除数据 ,所以Kafka是不会删除数据的,它会把所有的数据都保留下来,每个消费者(Consumer)对每个Topic都有一个 offset用来表示读取到了第几条数据 。
二、Memory Mapped Files
即便是顺序写入硬盘,硬盘的访问速度还是不可能追上内存。所以Kafka的数据并不
是实时的写入硬盘,它充分利用了现代操作系统分页存储来利用内存提高I/O效率。Memory Mapped Files(后面简称mmap)也被翻译成 内存映射文件,在64位操作系统中一般可以表示20G的数据文件,它的工作原理是直接利用操作系统的Page来实现文件到物理内存的直接映射。完成映射之后你对物理内存的操作会被同 步到硬盘上(操作系统在适当的时候)。
通过mmap,进程像读写硬盘一样读写内存(当然是虚拟机内存),也不必关心内存的大小有虚拟内存 为我们兜底。使用这种方式可以获取很大的I/O提升,省去了用户空间到内核空间复制的开销(调用文 件的read会把数据先放到内核空间的内存中,然后再复制到用户空间的内存中。)
但也有一个很明显的缺陷——不可靠,写到mmap中的数据并没有被真正的写到硬盘,操作系统会在程 序主动调用flush的时候才把数据真正的写到硬盘。Kafka提供了一个参数——producer.type来控制是不是主动flush,如果Kafka写入到mmap之后就立即flush然后再返回Producer叫 同步(sync);写入mmap之后立即返回Producer不调用flush叫异步 (async)。
读取数据:
一、基于sendfile实现Zero Copy
传统模式下,当需要对一个文件进行传输的时候,其具体流程细节如下:
①调用read函数,文件数据被copy到内核缓冲区;
②read函数返回,文件数据从内核缓冲区copy到用户缓冲区;
③write函数调用,将文件数据从用户缓冲区copy到内核与socket相关的缓冲区;
④数据从socket缓冲区copy到相关协议引擎。
以上细节是传统read/write方式进行网络文件传输的方式,我们可以看到,在这个过程当中,文件数据 实际上是经过了四次copy操作:硬盘->内核buf->用户buf->socket相关缓冲区->协议引擎。而sendfile 系统调用则提供了一种减少以上多次copy,提升文件传输性能的方法。
在内核版本2.1中,引入了sendfile系统调用,以简化网络上和两个本地文件之间的数据传输。sendfile 的引入不仅减少了数据复制,还减少了上下文切换。运行流程如下:
①sendfile系统调用,文件数据被copy至内核缓冲区;
②再从内核缓冲区copy至内核中socket相关的缓冲区;
③最后再socket相关的缓冲区copy到协议引擎。
相较传统read/write方式,2.1版本内核引进的sendfile已经减少了内核缓冲区到user缓冲区,再由user 缓冲区到socket相关缓冲区的文件copy,而在内核版本2.4之后,文件描述符结果被改变,sendfile实现 了更简单的方式,再次减少了一次copy操作。
在Apache、Nginx、lighttpd等web服务器当中,都有一项sendfile相关的配置,使用sendfile可以大幅提升文件传输性能。Kafka把所有的消息都存放在一个一个的文件中,当消费者需要数据的时候Kafka直 接把文件发送给消费者,配合mmap作为文件读写方式,直接把它传给sendfile。
二、批量压缩
在很多情况下,系统的瓶颈不是CPU或磁盘,而是网络IO,对于需要在广域网上的数据中心之间发送消 息的数据流水线尤其如此。进行数据压缩会消耗少量的CPU资源,不过对于kafka而言,网络IO更应该需要 考虑。
①如果每个消息都压缩,但是压缩率相对很低,所以Kafka使用了批量压缩,即将多个消息一起压缩 而不是单个消息压缩;
②Kafka允许使用递归的消息集合,批量的消息可以通过压缩的形式传输并且在日志中也可以保持压 缩格式,直到被消费者解压缩;
③Kafka支持多种压缩协议,包括Gzip和Snappy压缩协议。
总结:
Kafka速度的秘诀在于,它把所有的消息都变成一个批量的文件,并且进行合理的批量压缩,减少网络 IO损耗,通过mmap提高I/O速度,写入数据的时候由于单个Partion是末尾添加所以速度最优。读取数 据的时候配合sendfile直接暴力输出。
put过程主要分为三个阶段:
在写操作时,默认情况下,只需要 primary shard 处于活跃状态即可进行操作。在索引设置时可以设置这个属性: index.write.wait_for_active_shards 。默认是 1,即primary shard 写 入 成 功 即 可 返 回 。 如 果 设 置 为 all 则相当于number_of_replicas+1 就是 primary shard 数量 + replica shard 数量。就是需要等待 primary shard 和 replica shard 都写入成功才算成功。可以通过索引设置动态覆盖此默认设置。
CAP定理又称CAP原则,指的是在一个分布式系统中,Consistency(一致性)、 Availability(可用性)、Partition tolerance(分区容错性),最多只能同时三个特性中的两个,三者不可兼得。
①Consistency (一致性):
“all nodes see the same data at the same time”,即更新操作成功并返回客户端后,所有节点在同一时间的数据完全一致,这就是分布式的一致性。一致性的问题在并发系统中不可避免,对于客 户端来说,一致性指的是并发访问时更新过的数据如何获取的问题。从服务端来看,则是更新如何 复制分布到整个系统,以保证数据最终一致。
②Availability (可用性):
可用性指“Reads and writes always succeed”,即服务一直可用,而且是正常响应时间。好的可用性主要是指系统能够很好的为用户服务,不出现用户操作失败或者访问超时等用户体验不好的情 况。
③Partition Tolerance (分区容错性):
即分布式系统在遇到某节点或网络分区故障的时候,仍然能够对外提供满足一致性和可用性的服 务。分区容错性要求能够使应用虽然是一个分布式系统,而看上去却好像是在一个可以运转正常的 整体。比如现在的分布式系统中有某一个或者几个机器宕掉了,其他剩下的机器还能够正常运转满 足系统需求,对于用户而言并没有什么体验上的影响。
分布式事务就是指事务的参与者、支持事务的服务器、资源服务器以及事务管理器分别位于不同的分布 式系统的不同节点之上。简单的说,就是一次大的操作由不同的小操作组成,这些小的操作分布在不同 的服务器上,且属于不同的应用,分布式事务需要保证这些小操作要么全部成功,要么全部失败。本质 上来说,分布式事务就是为了保证不同数据库的数据一致性。
要实现分布式事务,有如下几种常见的解决方案:
一、2PC
说到2PC就不得不聊数据库分布式事务中的 XA Transactions。
如上图,在XA协议中分为两阶段:
第一阶段:事务管理器要求每个涉及到事务的数据库预提交(precommit)此操作,并反映是否可以提 交。
第二阶段:事务协调器要求每个数据库提交数据,或者回滚数据。
优点:
尽量保证了数据的强一致,实现成本较低,在各大主流数据库都有自己实现,对于MySQL是从5.5 开始支持。
缺点:
①单点问题:事务管理器在整个流程中扮演的角色很关键,如果其宕机,比如在第一阶段已经完成, 在第二阶段正准备提交的时候事务管理器宕机,资源管理器就会一直阻塞,导致数据库无法使用。
②同步阻塞:在准备就绪之后,资源管理器中的资源一直处于阻塞,直到提交完成,释放资源。
③数据不一致:两阶段提交协议虽然为分布式数据强一致性所设计,但仍然存在数据不一致性的可 能,比如在第二阶段中,假设协调者发出了事务commit的通知,但是因为网络问题该通知仅被一 部分参与者所收到并执行了commit操作,其余的参与者则因为没有收到通知一直处于阻塞状态, 这时候就产生了数据的不一致性。
总的来说,XA协议比较简单,成本较低,但是其单点问题,以及不能支持高并发依然是其最大的弱点。
二、TCC
关于TCC(Try-Confirm-Cancel)的概念,最早是由Pat Helland于2007年发表的一篇名为《Life beyond Distributed Transactions:an Apostate’s Opinion》的论文提出。 TCC事务机制相比于上面介绍的XA,解决了其几个缺点:
①Try阶段:尝试执行,完成所有业务检查(一致性),预留必须业务资源(准隔离性)。
② Confirm阶段:确认执行真正执行业务,不作任何业务检查,只使用Try阶段预留的业务资源, Confirm操作满足幂等性。要求具备幂等设计,Confirm失败后需要进行重试。
③Cancel阶段:取消执行,释放Try阶段预留的业务资源 Cancel操作满足幂等性Cancel阶段的异常和Confirm阶段异常处理方案基本上一致。
举个简单的例子如果你用100元买了一瓶水,在Try阶段你需要向你的钱包检查是否够100元并锁住这100元,水也是一样的。如果有一个失败,则进行cancel(释放这100元和这一瓶水),如果cancel失败不 论什么失败都进行重试cancel,所以需要保持幂等。如果都成功, 则进行confirm,确认这100元扣,和 这一瓶水被卖,如果confirm失败无论什么失败则重试(会依靠活动日志进行重试)。
对于TCC来说适合以下场景:
①强隔离性,严格一致性要求的活动业务。
②执行时间较短的业务。
三、本地消息表
本地消息表这个方案最初是ebay提出的,此方案的核心是将需要分布式处理的任务通过消息日志的方式 来异步执行。消息日志可以存储到本地文本、数据库或消息队列,再通过业务规则自动或人工发起重 试。人工重试更多的是应用于支付场景,通过对账系统对事后问题的处理。
对于本地消息队列来说核心是把大事务转变为小事务,还是举上面用100元去买一瓶水的例子:
本地消息队列是BASE理论,是最终一致模型,适用于对一致性要求不高的场景,实现这个模型时需要 注意重试的幂等。
四、MQ事务
在RocketMQ中实现了分布式事务,实际上其实是对本地消息表的一个封装,将本地消息表移动到了MQ内部,下面简单介绍一下MQ事务。
基本流程如下:
第一阶段Prepared消息,会拿到消息的地址。
第二阶段执行本地事务。
第三阶段通过第一阶段拿到的地址去访问消息,并修改状态。消息接受者就能使用这个消息。
如果确认消息失败,在RocketMq Broker中提供了定时扫描没有更新状态的消息,如果有消息没有得到确认,会向消息发送者发送消息,来判断是否提交,在rocketmq中是以listener的形式给发送者,用来 处理。如果消费超时,则需要一直重试,消息接收端需要保证幂等。如果消息消费失败,这个就需要人工进行 处理,因为这个概率较低,如果为了这种小概率时间而设计这个复杂的流程反而得不偿失。
五、Saga事务
Saga是30年前一篇数据库伦理提到的一个概念。其核心思想是将长事务拆分为多个本地短事务,由Saga事务协调器协调,如果正常结束那就正常完成,如果某个步骤失败,则根据相反顺序一次调用补偿 操作。 Saga的组成:
每个Saga由一系列sub-transaction Ti 组成 每个Ti 都有对应的补偿动作Ci,补偿动作用于撤销Ti造成的结果,这里的每个T,都是一个本地事务。 可以看到,和TCC相比,Saga没有“预留 try”动作,它的Ti就是直接提交到库。
Saga的执行顺序有两种:
① T1, T2, T3, …, Tn
②T1, T2, …, Tj, Cj,…, C2, C1,其中0 < j < n
Saga定义了两种恢复策略:
向后恢复,即上面提到的第二种执行顺序,其中j是发生错误的sub-transaction,这种做法的效果是撤 销掉之前所有成功的sub-transation,使得整个Saga的执行结果撤销。 向前恢复,适用于必须要成功的场景,执行顺序是类似于这样的:T1, T2, …, Tj(失败), Tj(重试),…, Tn,其中j是发生错误的sub- transaction。该情况下不需要Ci。这里要注意的是,在saga模式中不能保证隔离性,因为没有锁住资 源,其他事务依然可以覆盖或者影响当前事务。
还是拿100元买一瓶水的例子来说,这里定义:
①T1=扣100元,T2=给用户加一瓶水,T3=减库存一瓶水;
②C1=加100元,C2=给用户减一瓶水,C3=给库存加一瓶水;
我们一次进行T1,T2,T3如果发生问题,就执行发生问题的C操作的反向。 上面说到的隔离性的问题会出现在,如果执行到T3这个时候需要执行回滚,但是这个用户已经把水喝了(另外一个事务),回滚的时 候就会发现,无法给用户减一瓶水了。这就是事务之间没有隔离性的问题。
可以看见saga模式没有隔离性的影响还是较大,可以参照华为的解决方案:从业务层面入手加入一Session 以及锁的机制来保证能够串行化操作资源。也可以在业务层面通过预先冻结资金的方式隔离这部分资源, 最后在业务操作的过程中可以通过及时读取当前状态的方式获取到最新的更新。
国际开放标准组织Open Group定义了DTS(分布式事务处理模型),模型中包含4种角色:应用程序、事务管理器、资源管理器和通信资源管理器。事务管理器是统管全局的管理者,资源管理器和通信资源 管理器是事务的参与者。
JEE(Java企业版)规范也包含此分布式事务处理模型的规范,并在所有AppServer中进行实现。在JEE 规范中定义了TX协议和XA协议,TX协议定义应用程序与事务管理器之间的接口,XA协议则定义事务管 理器与资源管理器之间的接口。在过去使用 AppServer如WebSphere、 WebLogic、JBoss等配置数据源时会看见类似XADatasource的数据源,这就是实现了分布式事务处理模型的关系型数据库的 数据源。在企业级开发JEE中,关系型数据库、JMS服务扮演资源管理器的角色,而
EJB容器扮演事务管理器 的角色。
下面我们介绍两阶段提交协议、三阶段提交协议及阿里巴巴提出的 TCC,它们都是根据DTS这一思想演变而来的。
两阶段提交协议
两阶段提交协议把分布式事务分为两个阶段,一个是准备阶段,另一个是提交阶段。准备阶段和提交阶 段都是由事务管理器发起的,为了接下来讲解方便,我们将事务管理器称为协调者,将资源管理器称为 参与者。
两阶段提交协议的流程如下所述。
①准备阶段:协调者向参与者发起指令,参与者评估自己的状态,如果参与者评估指令可以完成,则 会写redo或者undo日志(Write-Ahead Log的一种),然后锁定资源,执行操作,但是并不提交。
②提交阶段:如果每个参与者明确返回准备成功,也就是预留资源和执行操作成功,则协调者向参与 者发起提交指令,参与者提交资源变更的事务,释放锁定的资源;如果任何一个参与者明确返回准 备失败,也就是预留资源或者执行操作失败,则协调者向参与者发起中止指令,参与者取消已经变 更的事务,执行undo日志,释放锁定的资源。两阶段提交协议的成功场景如下图所示。
我们看到两阶段提交协议在准备阶段锁定资源,这是一个重量级的操作,能保证强一致性,但是实 现起来复杂、成本较高、不够灵活,更重要的是它有如下致命的问题。
①阻塞:从上面的描述来看,对于任何一次指令都必须收到明确的响应,才会继续进行下一步,否则 处于阻塞状态,占用的资源被一直锁定,不会被释放。
②单点故障:如果协调者宕机,参与者没有协调者指挥,则会一直阻塞,尽管可 以通过选举新的协调 者替代原有协调者,但是如果协调者在发送一个提交指令后宕机,而提交指令仅仅被一个参与者接 收,并且参与者接收后也宕机,则新上任的协调者无法处理这种情况。
③脑裂:协调者发送提交指令,有的参与者接收到并执行了事务,有的参与者没有接收到事务就没有 执行事务,多个参与者之间是不一致的。
上面的所有问题虽然很少发生,但都需要人工干预处理,没有自动化的解决方案,因此两阶段提交协议 在正常情况下能保证系统的强一致性,但是在出现异常的情况下,当前处理的操作处于错误状态,需要 管理员人工干预解决,因此可用性不够好,这也符合CAP协议的一致性和可用性不能兼得的原理。
三阶段提交协议
三阶段提交协议是两阶段提交协议的改进版本。它通过超时机制解决了阻塞的问题,并且把两个阶段增 加为以下三个阶段。
①询问阶段:协调者询问参与者是否可以完成指令,协调者只需要回答是或不是,而不需要做真正的 操作,这个阶段超时会导致中止。
②准备阶段:如果在询问阶段所有参与者都返回可以执行操作,则协调者向参与者发送预执行请求, 然后参与者写redo和undo日志,执行操作但是不提交操作;如果在询问阶段任意参与者返回不能执行操作的结果,则协调者向参与者发送中止请求,这里的逻辑与两阶段提交协议的准备阶段是相 似的。
③提交阶段:如果每个参与者在准备阶段返回准备成功,也就是说预留资源和执行操作成功,则协调 者向参与者发起提交指令,参与者提交资源变更的事务,释放锁定的资源;如果任何参与者返回准 备失败,也就是说预留资源或者执行操作失败,则协调者向参与者发起中止指令,参与者取消已经 变更的事务,执行 undo 日志,释放锁定的资源,这里的逻辑与两阶段提交协议的提交阶段一致。
三阶段提交协议的成功场景示意图如下图所示:
三阶段提交协议与两阶段提交协议主要有以下两个不同点:
①增加了一个询问阶段,询问阶段可以确保尽可能早地发现无法执行操作而需要中止的行为,但是它 并不能发现所有这种行为,只会减少这种情况的发生。
②在准备阶段以后,协调者和参与者执行的任务中都增加了超时,一旦超时,则协调者和参与者都会 继续提交事务,默认为成功,这也是根据概率统计超时后默认为成功的正确性最大。
三阶段提交协议与两阶段提交协议相比,具有如上优点,但是一旦发生超时,系统仍然会发生不一致, 只不过这种情况很少见,好处是至少不会阻塞和永远锁定资源。
TCC
签名讲解了两阶段提交协议和三阶段提交协议,实际上它们能解决常见的分布式事务的问题,但是遇到 极端情况时,系统会产生阻塞或者不一致的问题,需要运营或者技术人员解决。两阶段及三阶段方案中 都包含多个参与者、多个阶段实现一个事务,实现复杂,性能也是一个很大的问题,因此,在互联网的 高并发系统中,鲜有使用两阶段提交和三阶段提交协议的场景。
后来有人提出了TCC协议,TCC协议将一个任务拆分成Try、Confirm、Cancel三个步骤,正常的流程会 先执行Try,如果执行没有问题,则再执行Confirm,如果执行过程中出了问题,则执行操作的逆操作Cancel。从正常的流程上讲,这仍然是一个两阶段提交协议,但是在执行出现问题时有一定的自我修复 能力,如果任何参与者出现了问题,则协调者通过执行操作的逆操作来Cancel之前的操作,达到最终的 一致状态。
可以看出,从时序上来说,如果遇到极端情况,则TCC会有很多问题,例如,如果在取消时一些参与者 收到指令,而另一些参与者没有收到指令,则整个系统仍然是不一致的。对于这种复杂的情况,系统首 先会通过补偿的方式尝试自动修复,如
果系统无法修复,则必须由人工参与解决。
从TCC的逻辑上看,可以说TCC是简化版的三阶段提交协议,解决了两阶段提交协议的阻塞问题,但是 没有解决极端情况下会出现不一致和脑裂的问题。然而,TCC通过自动化补偿手段,将需要人工处理的 不一致情况降到最少,也是一种非常有用的解决方案。某著名的互联网公司在内部的一些中间件上实现 了TCC模式。
我们给出一个使用TCC的实际案例,在秒杀的场景中,用户发起下订单请求,应用层先查询库存,确认 商品库存还有余量,则锁定库存,此时订单状态为待支付,然后指引用户去支付,由于某种原因用户支 付失败或者支付超时,则系统会自动将锁定的库存解锁以供其他用户秒杀。
TCC协议的使用场景如下图所示:
在大规模、高并发服务化系统中,一个功能被拆分成多个具有单一功能的子功能,一个流程会有多个系 统的多个单一功能的服务组合实现,如果使用两阶段提交协议和三阶段提交协议,则确实能解决系统间 的一致性问题。除了这两个协议的自身问
题,其实现也比较复杂、成本比较高,最重要的是性能不好, 相比来看,TCC协议更简单且更容易实现,但是TCC协议由于每个事务都需要执行Try,再执行Confirm,略显臃肿,因此,现实系统的底线是仅仅需要达到最终一致性,而不需要 实现专业的、复杂 的一致性协议。实现最终一致性有一些非常有效、简单的模式,下面就介绍这些模式及其应用场景。
查询模式
任何服务操作都需要提供一个查询接口,用来向外部输出操作执行的状态。服务操作的使用方可以通过 查询接口得知服务操作执行的状态,然后根据不同的状态来做不同的处理操作。
为了能够实现查询,每个服务操作都需要有唯一的流水号标识,也可使用此次服务操作对应的资源ID来 标识,例如:请求流水号、订单号等。首先,单笔查询操作是必须提供的,也鼓励使用单笔订单查询, 这是因为每次调用需要占用的负载是可控
的。批量查询则根据需要来提供,如果使用了批量查询,则需 要有合理的分页机制,并且必须限制分页的大小,以及对批量查询的吞吐量有容量评估、熔断、隔离和 限流等措施。
补偿模式
有了上面的查询模式,在任何情况下,我们都能得知具体的操作所处的状态,如果整个操作都处于不正 常的状态,则我们需要修正操作中有问题的子操作,这可能需要重新执行未完成的子操作,后者取消已 经完成的子操作,通过修复使整个分布式系统达到一致。为了让系统最终达到一致状态而做的努力都叫 作补偿。
对于服务化系统中同步调用的操作,若业务操作发起方还没有收到业务操作执行方的明确返回或者调用 超时,业务发起方需要及时地调用业务执行方来获得操作执行的状态,这里使用在前面学习的查询模 式。在获得业务操作执行方的状态后,如果业务执行方已经完成预设工作,则业务发起方向业务的使用 方返回成功;如果业务操作执行方的状态为失败或者未知,则会立即告诉业务使用方失败,也叫作快速失败策略,然后调用业务操作的逆向操作,保证操作不被执行或者回滚已经执行的操作,让业务使用 方、业务操作发起方和业务操作执行方最终达到一致状态。
补偿模式如下图所示:
异步确保模式
异步确保模式是补偿模式的一个典型案例,经常应用到使用方对响应时间要求不太高的场景中,通常把 这类操作从主流程中摘除,通过异步的方式进行处理,处理后把结果通过通知系统通知给使用方。这个 方案的最大好处是能够对高并发流量进行
消峰,例如:电商系统中的物流、配送,以及支付系统中的计 费、入账等。
在实践中将要执行的异步操作封装后持久入库,然后通过定时捞取未完成的任务进行补偿操作来实现异 步确保模式,只要定时系统足够健壮,则任何任务最终都会被成功执行。
异步确保模式如下图所示:
定期校对模式
系统在没有达到一致之前,系统间的状态是不一致的,甚至是混乱的,需要通过补偿操作来达到最终一 致性的目的,但是如何来发现需要补偿的操作呢?
在操作主流程中的系统间执行校对操作,可以在事后异步地批量校对操作的状态,如果发现不一致的操 作,则进行补偿,补偿操作与补偿模式中的补偿操作是一致的。
另外,实现定期校对的一个关键就是分布式系统中需要有一个自始至终唯一的ID,生成全局唯一ID有以 下两种方法:
①持久型:使用数据库表自增字段或者Sequence生成,为了提高效率,每个应用节点可以缓存一个 批次的ID,如果机器重启则可能会损失一部分ID,但是这并不会产生任何问题。
②时间型:一般由机器号、业务号、时间、单节点内自增ID组成,由于时间一般精确到秒或者毫 秒,因此不需要持久就能保证在分布式系统中全局唯一、粗略递增等。
可靠消息模式
在分布式系统中,对于主流程中优先级比较低的操作,大多采用异步的方式执行,也就是前面提到的异 步确保模型,为了让异步操作的调用方和被调用方充分解耦,也由于专业的消息队列本身具有可伸缩、 可分片、可持久等功能,我们通常通过消息队列实现异步化。对于消息队列,我们需要建立特殊的设施 来保证可靠的消息发送及处理机的幂等性。
缓存一致性模式
在大规模、高并发系统中的一个常见的核心需求就是亿级的读需求,显然,关系型数据库并不是解决高 并发读需求的最佳方案,互联网的经典做法就是使用缓存来抗住读流量。
①如果性能要求不是非常高,则尽量使用分布式缓存,而不要使用本地缓存。
②写缓存时数据一定要完整,如果缓存数据的一部分有效,另一部分无效,则宁可在需要时回源数据 库,也不要把部分数据放入缓存中。
③使用缓存牺牲了一致性,为了提高性能,数据库与缓存只需要保持弱一致性,而不需要保持强一致 性,否则违背了使用缓存的初衷。
④读的顺序是先读缓存,后读数据库,写的顺序要先写数据库,后写缓存。
传输协议
RPC,可以基于TCP协议,也可以基于HTTP协议。
HTTP,基于HTTP协议。
传输效率
RPC,使用自定义的TCP协议,可以让请求报文体积更小,或者使用HTTP2协议,也可以很好的减 少报文的体积,提高传输效率。
HTTP,如果是基于HTTP1.1的协议,请求中会包含很多无用的内容,如果是基于HTTP2.0,那么 简单的封装一下是可以作为一个RPC来使用的,这时标准RPC框架更多的是服务治理。
性能消耗
RPC,可以基于thrift实现高效的二进制传输。
HTTP,大部分是通过json来实现的,字节大小和序列化耗时都比thrift要更消耗性能。
负载均衡
RPC,基本都自带了负载均衡策略。
HTTP,需要配置Nginx,HAProxy来实现。
服务治理
RPC,能做到自动通知,不影响上游。
HTTP,需要事先通知,修改Nginx/HAProxy配置。
总之,RPC主要用于公司内部的服务调用,性能消耗低,传输效率高,服务治理方便。HTTP主要用于对 外的异构环境,浏览器接口调用,APP接口调用,第三方接口调用等。
单一职责原则
一个类,应当只有一个引起它变化的原因;即一个类应该只有一个职责。
就一个类而言,应该只专注于做一件事和仅有一个引起变化的原因,这就是所谓的单一职责原则。该原 则提出了对对象职责的一种理想期望,对象不应该承担太多职责,正如人不应该一心分为二用。唯有专 注,才能保证对象的高内聚;唯有单一,才能保证对象的细粒度。对象的高内聚与细粒度有利于对象的 重用。一个庞大的对象承担了太多的职责,当客户端需要该对象的某一个职责时,就不得不将所有的职责都包含进来,从而造成冗余代码。
里氏替换原则
在面向对象的语言中,继承是必不可少的、优秀的语言机制,它主要有以下几个优点:
①代码共享,减少创建类的工作量,每个子类都拥有父类的方法和属性;
②提高代码的可重用性;
③提高代码的可扩展性;
④提高产品或项目的开放性。
相应的,继承也存在缺点,主要体现在以下几个方面:
①继承是入侵式的。只要继承,就必须拥有父类的所有属性和方法;
②降低代码的灵活性。子类必须拥有父类的属性和方法,使子类受到限制;
③增强了耦合性。当父类的常量、变量和方法修改时,必须考虑子类的修改,这种修改可能造成大片 的代码需要重构。
从整体上看,继承的“利”大于“弊”,然而如何让继承中“利”的因素发挥最大作用,同时减少“弊”所带来的 麻烦,这就需要引入“里氏替换原则”。里氏替换原则的定义有以下两种:
①如果对一个类型为S的对象o1,都有类型为T的对象o2,使得以S定义的所有程序P在所有的对象o1 都代换成o2时,程序P的行为没有发生变化,那么类型T是类型S的子类型。
②所有引用基类的地方必须能透明地使用其子类对象。清晰明确地说明只要父类能出现的地方子类就 可以出现,而且替换为子类也不会产生任何错误或异常,使用者可能根本就不需要知道父类还是子 类;但是反过来则不可以,有子类 的地方,父类未必就能适应。
依赖倒置原则
依赖倒置原则包括三种含义:
①高层模块不应该依赖低层模块,两者都依赖其抽象;
②抽象不依赖细节;
③细节应该依赖于抽象。
传统的过程性系统的设计办法倾向于高层次的模块依赖于低层次的模块;抽象层次依赖于具体层次。
“倒置”原则将这个错误的依赖关系倒置了过来,如下图所示,由此命名为“依赖倒置原则”。
在Java语言中,抽象就是指接口或抽象类,两者都是不能直接被实例化的;细节就是具体的实现类,实 现类实现了接口或继承了抽象类,其特点是可以直接被实例化。依赖倒置原则在Java语言中的表现是:
①模块间的依赖通过抽象发生,实现类之间不发生直接的依赖关系,其依赖关系是通过接口或抽象类 产生;
②接口或抽象类不依赖于实现类;
③实现类依赖于接口或抽象类。
依赖倒置原则更加精确的定义就是“面向接口编程”——OOD(Object-Oriented Design)的精髓之一。依赖倒置原则可以减少类间的耦合性,提高系统的稳定性,降低并行开发引起的风险,提高代码的可读 性和可维护性。依赖倒置原则是JavaBean、EJB和COM等组件设计模型背后的基本原则。
接口隔离原则
接口隔离原则有如下两种定义:
迪米特法则
迪米特法则又叫最少知识原则,意思是一个对象应当对其他对象尽可能少的了解。迪米特法则不同于其 他的OO设计原则,它具有很多种表述方式,其中具有代表性的是以下几种表述:
①只与你直接的朋友们通信;
②不要跟“陌生人”说话;
③每一个软件单位对其他的单位都只有最少的了解,这些了解仅局限于那些与本单位密切相关的软件 单位。
按照迪米特法则,如果两个类不必彼此直接通信,那么这两个类就不应当发生直接的相互作用;如果一 个类需要调用另一个类的某一个方法,可以通过第三者转发这个调用。
开闭原则
开闭原则的定义是:一个软件实体应当对扩展开放,对修改关闭。这个原则说的是,在设计一个模块的 时候,应当使这个模块可以在不被修改的前提下被扩展,即应当可以在不必修改源代码的情况下改变这 个模块的行为。
在面向对象的编程中,开闭原则是最基础的原则,起到总的指导作用,其他原则(单一职责、里氏替 换、依赖倒置、接口隔离、迪米特法则)都是开闭原则的具体形态,即其他原则都是开闭原则的手段和 工具。开闭原则的重要性可以通过以下几个方面来体现。
①开闭原则提高复用性。在面向对象的设计中,所有的逻辑都是从原子逻辑组合而来的,而不是在一 个类中独立实现一个业务逻辑,代码粒度越小,被复用的可能性就越大,避免相同的逻辑重复增 加。开闭原则的设计保证系统是一个在高层次上实现了复用的系统。
②开闭原则提高可维护性。一个软件投产后,维护人员的工作不仅仅是对数据进行维护,还可能对程 序进行扩展,就是扩展一个类,而不是修改一个类。开闭原则对已有软件模块,特别是最重要的抽 象层模块要求不能再修改,这就使变化中的软件系统有一定的稳定性和延续性,便于系统的维护。
③ 开闭原则提高灵活性。所有的软件系统都有一个共同的性质,即对系统的需求都会随时间的推移而 发生变化。在软件系统面临新的需求时,系统的设计必须是稳定的。开闭原则可以通过扩展已有的 软件系统,提供新的行为,能快速应对变化,以满足对软件新的需求,使变化中的软件系统有一定 的适应性和灵活性。
④开闭原则易于测试。测试是软件开发过程中必不可少的一个环节。测试代码不仅要保证逻辑的正确 性,还要保证苛刻条件(高压力、异常、错误)下不产生“有毒代码”(Poisonous Code),因此当有变化提出时,原有健壮的代码要尽量不修改,而是通过扩展来实现。否则,就需要把原有的测 试过程回笼一遍,需要进行单元测试、功能测试、集成测试,甚至是验收测试。开闭原则的使用,保证软件是通过扩展来实现业务逻辑的变化,而不是修改。因此,对于新增加的类,只需新增相应 的测试类,编写对应的测试方法,只要保证新增的类是正确的就可以了。
1.1有
在懒汉式单例模式基础上实现线程同步:
上述代码对静态方法 getInstance()进行同步,以确保多线程环境下只创建一个实例。如果getInstance() 方法未被同步,并且线程A和线程B同时调用此方法,则执行if (instance = = null) 语句时都为真,那么线程A和线程B都会创建一个对象,在内存中就会出现两个对象,这样就违反了单例模式。而使用synchronized关键字进行同步后,则不会出现此种情况。
工厂模式的用意是定义一个创建产品对象的工厂接口,将实际创建性工作推迟到子类中。工厂模式可分 为简单工厂、工厂方法和抽象工厂模式。注意,我们常说的23种经典设计模式,包含了工程方法模式和 抽象工厂模式,而并未包含简单工厂模式。另外,我们平时说的工厂模式,一般默认是指工厂方法模式。
简单工厂
简单工厂模式其实并不算是一种设计模式,更多的时候是一种编程习惯。简单工厂的实现思路是,定义 一个工厂类,根据传入的参数不同返回不同的实例,被创建的实例具有共同的父类或接口。简单工厂的 适用场景是:
①需要创建的对象较少。
②客户端不关心对象的创建过程。
示例:
创建一个可以绘制不同形状的绘图工具,可以绘制圆形,正方形,三角形,每个图形都会有一个draw() 方法用于绘图,不看代码先考虑一下如何通过该模式设计完成此功能。
由题可知圆形,正方形,三角形都属于一种图形,并且都具有draw方法,所以首先可以定义一个接口 或者抽象类,作为这三个图像的公共父类,并在其中声明一个公共的draw方法:
下面就是编写具体的图形,每种图形都实现Shape接口:
下面是工厂类的具体实现:
为工厂类传入不同的type可以new不同的形状,返回结果为Shape 类型,这个就是简单工厂核心的地方了。
工厂方法
工厂方法模式是简单工厂的仅一步深化, 在工厂方法模式中,我们不再提供一个统一的工厂类来创建所有的对象,而是针对不同的对象提供不同的工厂。也就是说每个对象都有一个与之对应的工厂。
工厂方法的实现思路是,定义一个用于创建对象的接口,让子类决定将哪一个类实例化。工厂方法模式 让一个类的实例化延迟到其子类。
示例:现在需要设计一个这样的图片加载类,它具有多个图片加载器,用来加载jpg,png,gif格式的图片, 每个加载器都有一个read()方法,用于读取图片。下面我们完成这个图片加载类。首先完成图片加载器的设计,编写一个加载器的公共接口:
然后完成各个图片加载器的代码:
现在我们按照定义所说定义一个抽象的工厂接口ReaderFactory:
里面有一个getReader()方法返回我们的Reader 类,接下来我们把上面定义好的每个图片加载器都提供一个工厂类,这些工厂类实现了ReaderFactory 。
在每个工厂类中我们都通过重写的getReader()方法返回各自的图片加载器对象。和简单工厂对比一下,最根本的区别在于,简单工厂只有一个统一的工厂类,而工厂方法是针对每个要 创建的对象都会提供一个工厂类,这些工厂类都实现了一个工厂基类。
下面总结一下工厂方法的适用场景:
①客户端不需要知道它所创建的对象的类。
②客户端可以通过子类来指定创建对应的对象。
抽象工厂
这个模式最不好理解,而且在实际应用中局限性也蛮大的,因为这个模式并不符合开闭原则。实际开发 还需要做好权衡。抽象工厂模式是工厂方法的仅一步深化,在这个模式中的工厂类不单单可以创建一个 对象,而是可以创建一组对象。这是和工厂方法最大的不同点。
抽象工厂的实现思路是,提供一个创建一系列相关或相互依赖对象的接口,而无须指定它们具体的类。 抽象工厂和工厂方法一样可以划分为4大部分:
①AbstractFactory(抽象工厂):声明了一组用于创建对象的方法,注意是一组。
②ConcreteFactory(具体工厂):它实现了在抽象工厂中声明的创建对象的方法,生成一组具体对 象。
③AbstractProduct(抽象产品):它为每种对象声明接口,在其中声明了对象所具有的业务方法。
④ConcreteProduct(具体产品):它定义具体工厂生产的具体对象。
示例:
现在需要做一款跨平台的游戏,需要兼容Android,Ios,Wp三个移动操作系统,该游戏针对每个系统 都设计了一套操作控制器(OperationController)和界面控制 器(UIController),下面通过抽闲工厂 方式完成这款游戏的架构设计。由题可知,游戏里边的各个平台的UIController和OperationController应该是我们最终生产的具体产 品。所以新建两个抽象产品接口。
抽象操作控制器:
抽象界面控制器:
然后完成各个系统平台的具体操作控制器和界面控制器。
下面定义一个抽闲工厂,该工厂需要可以创建OperationController和UIController。
在各平台具体的工厂类中完成操作控制器和界面控制器的创建过程。
下面总结一下抽象工厂的适用场景:
①和工厂方法一样客户端不需要知道它所创建的对象的类。
②需要一组对象共同完成某种功能时。并且可能存在多组对象完成不同功能的情况。
③系统结构稳定,不会频繁的增加对象。
简单工厂模式其实并不算是一种设计模式,更多的时候是一种编程习惯。简单工厂的实现思路是,定义 一个工厂类,根据传入的参数不同返回不同的实例,被创建的实例具有共同的父类或接口。
工厂方法模式是简单工厂的仅一步深化, 在工厂方法模式中,我们不再提供一个统一的工厂类来创建所有的对象,而是针对不同的对象提供不同的工厂。也就是说每个对象都有一个与之对应的工厂。工厂方 法的实现思路是,定义一个用于创建对象的接口,让子类决定将哪一个类实例化。工厂方法模式让一个 类的实例化延迟到其子类。
抽象工厂模式是工厂方法的仅一步深化,在这个模式中的工厂类不单单可以创建一个对象,而是可以创 建一组对象。这是和工厂方法最大的不同点。抽象工厂的实现思路是,提供一个创建一系列相关或相互 依赖对象的接口,而无须指定它们具体的类。
简单工厂
创建一个可以绘制不同形状的绘图工具,可以绘制圆形,正方形,三角形,每个图形都会有一个draw() 方法用于绘图,不看代码先考虑一下如何通过该模式设计完成此功能。
由题可知圆形,正方形,三角形都属于一种图形,并且都具有draw方法,所以首先可以定义一个接口 或者抽象类,作为这三个图像的公共父类,并在其中声明一个公共的draw方法:
下面就是编写具体的图形,每种图形都实现Shape接口:
下面是工厂类的具体实现:
为工厂类传入不同的type可以new不同的形状,返回结果为Shape 类型,这个就是简单工厂核心的地方了。
工厂方法
示例:
现在需要设计一个这样的图片加载类,它具有多个图片加载器,用来加载jpg,png,gif格式的图片,每个加载器都有一个read()方法,用于读取图片。下面我们完成这个图片加载类。首先完成图片加载器的设计,编写一个加载器的公共接口:
然后完成各个图片加载器的代码:
现在我们按照定义所说定义一个抽象的工厂接口ReaderFactory:
里面有一个getReader()方法返回我们的Reader 类,接下来我们把上面定义好的每个图片加载器都提供一个工厂类,这些工厂类实现了ReaderFactory 。
在每个工厂类中我们都通过重写的getReader()方法返回各自的图片加载器对象。
抽象工厂
示例:
现在需要做一款跨平台的游戏,需要兼容Android,Ios,Wp三个移动操作系统,该游戏针对每个系统都设计了一套操作控制器(OperationController)和界面控制器(UIController),下面通过抽闲工厂方式完成这款游戏的架构设计。由题可知,游戏里边的各个平台的UIController和OperationController应该是我们最终生产的具体产
品。所以新建两个抽象产品接口。
抽象操作控制器:
抽象界面控制器:
然后完成各个系统平台的具体操作控制器和界面控制器。
下面定义一个抽闲工厂,该工厂需要可以创建OperationController和UIController。
在各平台具体的工厂类中完成操作控制器和界面控制器的创建过程。