0%

Java面试准备

Java面试准备

Java基础

String、StringBuilder、StringBuffer

String 类中使用 final 关键字修饰字符数组来保存字符串,private final char value[],所以 String 对象是不可变的。补充(来自issue 675):在 Java 9 之后,String 类的实现改用 byte 数组存储字符串 private final byte[] value

而 StringBuilder 与 StringBuffer 都继承自 AbstractStringBuilder 类,在 AbstractStringBuilder 中也是使用字符数组保存字符串char[] value 但是没有用 final 关键字修饰,所以这两种对象都是可变的。

线程安全性

String 中的对象是不可变的,也就可以理解为常量,线程安全。AbstractStringBuilder 是 StringBuilder 与 StringBuffer 的公共父类,定义了一些字符串的基本操作,如 expandCapacity、append、insert、indexOf 等公共方法。StringBuffer 对方法加了同步锁或者对调用的方法加了同步锁,所以是线程安全的。StringBuilder 并没有对方法进行加同步锁,所以是非线程安全的。

性能

每次对 String 类型进行改变的时候,都会生成一个新的 String 对象,然后将指针指向新的 String 对象。StringBuffer 每次都会对 StringBuffer 对象本身进行操作,而不是生成新的对象并改变对象引用。相同情况下使用 StringBuilder 相比使用 StringBuffer 仅能获得 10%~15% 左右的性能提升,但却要冒多线程不安全的风险。

对于三者使用的总结:

操作少量的数据: 适用 String

单线程操作字符串缓冲区下操作大量数据: 适用 StringBuilder

多线程操作字符串缓冲区下操作大量数据: 适用 StringBuffer

包装类

1
2
Integer i = new Integer(xxx);
Integer i = xxx;

在通过valueOf方法创建Integer对象的时候,如果数值在[-128,127]之间,便返回指向IntegerCache.cache中已经存在的对象的引用;否则创建一个新的Integer对象。

同时:Integer、Short、Byte、Character、Long这几个类的valueOf方法的实现是类似的。而Double、Float的valueOf方法的实现是类似的。而对于Boolean类,则是通过两个静态变量实现的。

1
2
3
4
5
public static Boolean valueOf(boolean b) {
return (b ? TRUE : FALSE);
}
public static final Boolean TRUE = new Boolean(true);
public static final Boolean FALSE = new Boolean(false);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Main {
public static void main(String[] args) {
Integer a = 1;
Integer b = 2;
Integer c = 3;
Integer d = 3;
Integer e = 321;
Integer f = 321;
Long g = 3L;
Long h = 2L;

System.out.println(c==d); // true
System.out.println(e==f); // false
System.out.println(c==(a+b)); // true
System.out.println(c.equals(a+b)); // true
System.out.println(g==(a+b)); // true
System.out.println(g.equals(a+b)); // false
System.out.println(g.equals(a+h)); // true
}
}

第一个和第二个输出结果没有什么疑问。第三句由于 a+b包含了算术运算,因此会触发自动拆箱过程(会调用intValue方法),因此它们比较的是数值是否相等。而对于c.equals(a+b)会先触发自动拆箱过程,再触发自动装箱过程,也就是说a+b,会先各自调用intValue方法,得到了加法运算后的数值之后,便调用Integer.valueOf方法,再进行equals比较。同理对于后面的也是这样,不过要注意倒数第二个和最后一个输出的结果(如果数值是int类型的,装箱过程调用的是Integer.valueOf;如果是long类型的,装箱调用的Long.valueOf方法)。

equals()和hashCode()方法

没重写了equals的类调用equals方法和使用==等价,比较两个对象的地址是否相同
重写equals方法时,需要注意满足其原生的一些特点,可参考下面代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
  @Override
public boolean equals(Object obj){
if(obj == null){
return false;
}

//如果是同一个对象返回true,反之返回false
if(this == obj){
return true;
}

//判断是否类型相同
if(this.getClass() != obj.getClass()){
return false;
}
// 最后制定自己想要的规则进行重写
Person person = (Person)obj;
return name.equals(person.name) && age == person.age;
}

hashCode方法获取对象的一个哈希码,即一个整数值。且hashCode() 在散列表中才有用,在其它情况下没用。在散列表中hashCode() 的作用是获取对象的散列码,进而确定该对象在散列表中的位置。

由此可知,若两个元素相等,它们的散列码一定相等;但反过来确不一定。在散列表中,
1、如果两个对象相等,那么它们的hashCode()值一定要相同;
2、如果两个对象hashCode()相等,它们并不一定相等。
注意:这是在散列表中的情况。在非散列表中一定如此!

为什么重写 equals 时必须重写 hashCode 方法?

本质:为了遵守2个对象equals,那么其hashCode一定相同的规则。

如果两个对象相等,则 hashcode 一定也是相同的。两个对象相等,对两个对象分别调用 equals 方法都返回 true。但是,两个对象有相同的 hashcode 值,它们也不一定是相等的 。因此,equals 方法被覆盖过,则 hashCode 方法也必须被覆盖。

hashCode()的默认行为是对堆上的对象产生独特值。如果没有重写 hashCode(),则该 class 的两个对象无论如何都不会相等(即使这两个对象指向相同的数据)

Java值传递

Java 程序设计语言对对象采用的不是引用调用,实际上,对象引用也是按值传递的。

下面再总结一下 Java 中方法参数的使用情况:

  • 一个方法不能修改一个基本数据类型的参数(即数值型或布尔型)。
  • 一个方法可以改变一个对象参数的状态。
  • 一个方法不能让对象参数引用一个新的对象。

可参考下面的代码理解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Person{
String name;
}
public class Test {
public static void main(String[] args) {
Person p1 = new Person();
Person p2 = new Person();
p1.name = "1";
p2.name = "2";
change(p1, p2);
System.out.println(p1.name);
// 输出 1
changeName(p1, p2);
System.out.println(p1.name);
// 输出 2
}

public static void change(Person p1, Person p2) {
p1 = p2;
}
public static void changeName(Person p1, Person p2) {
p1.name = p2.name;
}
}

final关键字

final 关键字主要用在三个地方:变量、方法、类。

对于一个 final 变量,如果是基本数据类型的变量,则其数值一旦在初始化之后便不能更改;如果是引用类型的变量,则在对其初始化之后便不能再让其指向另一个对象。

当用 final 修饰一个类时,表明这个类不能被继承。final 类中的所有成员方法都会被隐式地指定为 final 方法。

使用 final 方法的原因有两个。第一个原因是把方法锁定,以防任何继承类修改它的含义;第二个原因是效率。在早期的 Java 实现版本中,会将 final 方法转为内嵌调用。但是如果方法过于庞大,可能看不到内嵌调用带来的任何性能提升(现在的 Java 版本已经不需要使用 final 方法进行这些优化了)。类中所有的 private 方法都隐式地指定为 final。

I/O流

流的种类:

按照流的流向分,可以分为输入流和输出流;

按照操作单元划分,可以划分为字节流和字符流;

按照流的角色划分为节点流和处理流。

对于所有相关类,都由下方四个抽象基类进行派生

InputStream/Reader: 所有的输入流的基类,前者是字节输入流,后者是字符输入流。

OutputStream/Writer: 所有输出流的基类,前者是字节输出流,后者是字符输出流。

BIO、NIO、AIO

BIO (Blocking I/O): 同步阻塞 I/O 模式,数据的读取写入必须阻塞在一个线程内等待其完成。在活动连接数不是特别高(小于单机 1000)的情况下,这种模型是比较不错的,可以让每一个连接专注于自己的 I/O 并且编程模型简单,也不用过多考虑系统的过载、限流等问题。线程池本身就是一个天然的漏斗,可以缓冲一些系统处理不了的连接或请求。但是,当面对十万甚至百万级连接的时候,传统的 BIO 模型是无能为力的。因此,我们需要一种更高效的 I/O 处理模型来应对更高的并发量。

NIO (Non-blocking/New I/O): NIO 是一种同步非阻塞的 I/O 模型,在 Java 1.4 中引入了 NIO 框架,对应 java.nio 包,提供了 Channel , Selector,Buffer 等抽象。NIO 中的 N 可以理解为 Non-blocking,不单纯是 New。它支持面向缓冲的,基于通道的 I/O 操作方法。 NIO 提供了与传统 BIO 模型中的 SocketServerSocket 相对应的 SocketChannelServerSocketChannel 两种不同的套接字通道实现,两种通道都支持阻塞和非阻塞两种模式。阻塞模式使用就像传统中的支持一样,比较简单,但是性能和可靠性都不好;非阻塞模式正好与之相反。对于低负载、低并发的应用程序,可以使用同步阻塞 I/O 来提升开发速率和更好的维护性;对于高负载、高并发的(网络)应用,应使用 NIO 的非阻塞模式来开发

AIO (Asynchronous I/O): AIO 也就是 NIO 2。在 Java 7 中引入了 NIO 的改进版 NIO 2,它是异步非阻塞的 IO 模型。异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。AIO 是异步 IO 的缩写,虽然 NIO 在网络操作中,提供了非阻塞的方法,但是 NIO 的 IO 行为还是同步的。对于 NIO 来说,我们的业务线程是在 IO 操作准备好时,得到通知,接着就由这个线程自行进行 IO 操作,IO 操作本身是同步的。查阅网上相关资料,我发现就目前来说 AIO 的应用还不是很广泛,Netty 之前也尝试使用过 AIO,不过又放弃了。

Java集合

List

ArrayList和LinkedList

均不同步,均不保证线程安全。

ArrayList:底层由Object数组实现。插入元素到末尾O(1),插入元素到指定位置O(n-i)。且支持高效的随机访问。

LinkedList:底层使用的是 双向链表 数据结构(JDK1.6 之前为循环链表,JDK1.7 取消了循环),插入元素到末尾O(1),插入元素到指定位置O(n),需要移动指针。

补充:RandomAccess 接口。里面没有任何定义,仅仅作为标识,是否支持快速随机访问。ArrayList实现了该接口而LinkedList没有。

Vector:List的古老实现类,底层使用Object数组实现,且保证线程安全。

ArrayList扩容机制

对于空参构造时,会初始化一个空数组,直到第一个元素被添加时,才会扩容到10。懒加载(但在jdk7及以前会直接初始化一个容量为10的数组)

对于需要扩容的过程。则是ensureCapacityInternal() -> ensureExplicitCapacity() -> grow()

对于前两个方法,主要就是处理容量初始化和判断是否需要扩容。

对于核心grow()方法,需要扩容时,会首先扩容置原容量的1.5倍左右(new = old + old >> 1),然后如果new满足需求,则会直接用new作为新容量,否则会将当前所需容量作为新容量。如果new已经大于了所给的MAX_ARRAY_SIZEInteger.MAX_VALUE - 8,则会根据当前所需容量和MAX_ARRAY_SIZE进行比较吗,从而决定取MAX_ARRAY_SIZE或者Integer.MAX_VALUE

tips:向 ArrayList 添加大量元素之前最好先使用ensureCapacity 方法,以减少增量重新分配的次数。

Map

HashMap vs HashTable

HashMap: 线程不安全。支持null作为键和值,但只允许存在一个以null为键的键值对,允许多个键值对以null作为值。

HashTable: 线程安全(现在基本使用ConcurrentHashMap保证线程安全),HashTable基本已经被淘汰。HashTable不允许null作为键或者值,会抛出NullPointerException异常。

① 创建时如果不指定容量初始值,Hashtable 默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1。HashMap 默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。② 创建时如果给定了容量初始值,那么 Hashtable 会直接使用你给定的大小,而 HashMap 会将其扩充为 2 的幂次方大小(HashMap 中的tableSizeFor()方法保证)。也就是说 HashMap 总是使用 2 的幂作为哈希表的大小,后面会介绍到为什么是 2 的幂次方。

HashSet

底层由HashMap实现。

添加元素机制:当你把对象加入HashSet时,HashSet 会先计算对象的hashcode值来判断对象加入的位置,同时也会与其他加入的对象的 hashcode 值作比较,如果没有相符的 hashcodeHashSet 会假设对象没有重复出现。但是如果发现有相同 hashcode 值的对象,这时会调用equals()方法来检查 hashcode 相等的对象是否真的相同。如果两者相同,HashSet 就不会让加入操作成功。

HashMap底层实现

JDK1.8 之前 HashMap 底层是 数组和链表 结合在一起使用也就是 链表散列HashMap 通过 key 的 hashCode 经过扰动函数处理过后得到 hash 值,然后通过 (n - 1) & hash 判断当前元素存放的位置(这里的 n 指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。

JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。Hashtable 没有这样的机制。

HashMap长度为2的幂次方

为了能让 HashMap 存取高效,尽量较少碰撞,也就是要尽量把数据分配均匀。我们上面也讲到了过了,Hash 值的范围值-2147483648到2147483647,前后加起来大概40亿的映射空间,只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但问题是一个40亿长度的数组,内存是放不下的。所以这个散列值是不能直接拿来用的。用之前还要先做对数组的长度取模运算,得到的余数才能用来要存放的位置也就是对应的数组下标。这个数组下标的计算方法是“ (n - 1) & hash”。(n代表数组长度)。这也就解释了 HashMap 的长度为什么是2的幂次方。

(n - 1) & hash也就是hash % (n - 1)的快速版本。

HashMap多线程情况下死循环的问题

详情请查看:https://coolshell.cn/articles/9606.html

ConcurrentHashMap 和 Hashtable 的区别]

ConcurrentHashMapHashtable 的区别主要体现在实现线程安全的方式上不同。

底层数据结构: JDK1.7 的 ConcurrentHashMap 底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟 HashMap1.8 的结构一样,数组+链表/红黑二叉树。Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采用 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的;

实现线程安全的方式(重要):在 JDK1.7 的时候,ConcurrentHashMap(分段锁) 对整个桶数组进行了分割分段(Segment),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。 到了 JDK1.8 的时候已经摒弃了 Segment 的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。(JDK1.6 以后 对 synchronized 锁做了很多优化) 整个看起来就像是优化过且线程安全的 HashMap,虽然在 JDK1.8 中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本;② Hashtable(同一把锁) :使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。

ConcurrentHashMap线程安全的具体实现方式/底层具体实现

jdk1.7:首先将数据分为一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。

ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成

Segment 实现了 ReentrantLock,所以 Segment 是一种可重入锁,扮演锁的角色。HashEntry 用于存储键值对数据。

1
2
static class Segment<K,V> extends ReentrantLock implements Serializable {
}

一个 ConcurrentHashMap 里包含一个 Segment 数组。Segment 的结构和 HashMap 类似,是一种数组和链表结构,一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素,每个 Segment 守护着一个 HashEntry 数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 的锁。

jdk1.8:ConcurrentHashMap 取消了 Segment 分段锁,采用 CAS 和 synchronized 来保证并发安全。数据结构跟 HashMap1.8 的结构类似,数组+链表/红黑二叉树。Java 8 在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为 O(N))转换为红黑树(寻址时间复杂度为 O(log(N)))。synchronized 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,效率又提升 N 倍。

总结

List

  • ArraylistObject[]数组
  • VectorObject[]数组
  • LinkedList: 双向链表(JDK1.6 之前为循环链表,JDK1.7 取消了循环)

Set

  • HashSet(无序,唯一): 基于 HashMap 实现的,底层采用 HashMap 来保存元素
  • LinkedHashSetLinkedHashSetHashSet 的子类,并且其内部是通过 LinkedHashMap 来实现的。有点类似于我们之前说的 LinkedHashMap 其内部是基于 HashMap 实现一样,不过还是有一点点区别的
  • TreeSet(有序,唯一): 红黑树(自平衡的排序二叉树)

再来看看 Map 接口下面的集合。

Map

  • HashMap: JDK1.8 之前 HashMap 由数组+链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。JDK1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间
  • LinkedHashMapLinkedHashMap 继承自 HashMap,所以它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。另外,LinkedHashMap 在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。详细可以查看:《LinkedHashMap 源码详细分析(JDK1.8)》
  • Hashtable: 数组+链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的
  • TreeMap: 红黑树(自平衡的排序二叉树

Java多线程

为什么我们调用 start() 方法时会执行 run() 方法,为什么我们不能直接调用 run() 方法?

new 一个 Thread,线程进入了新建状态。调用 start()方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。 但是,直接执行 run() 方法,会把 run() 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。

总结: 调用 start() 方法方可启动线程并使线程进入就绪状态,直接执行 run() 方法的话不会以多线程的方式执行。

Synchronized

synchronized 关键字最主要的三种使用方式:

1.修饰实例方法: 作用于当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁

1
2
3
synchronized void method() {
//业务代码
}

2.修饰静态方法: 也就是给当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得 当前 class 的锁。因为静态成员不属于任何一个实例对象,是类成员( _static 表明这是该类的一个静态资源,不管 new 了多少个对象,只有一份_)。所以,如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁

1
2
3
synchronized void staic method() {
//业务代码
}

3.修饰代码块 :指定加锁对象,对给定对象/类加锁。synchronized(this|object) 表示进入同步代码库前要获得给定对象的锁synchronized(类.class) 表示进入同步代码前要获得 当前 class 的锁

1
2
3
synchronized(this) {
//业务代码
}

总结:

  • synchronized 关键字加到 static 静态方法和 synchronized(class) 代码块上都是是给 Class 类上锁。
  • synchronized 关键字加到实例方法上是给对象实例上锁。
  • 尽量不要使用 synchronized(String a) 因为 JVM 中,字符串常量池具有缓存功能!

MESI缓存一致性协议

多个cpu从主内存读取同一个数据到各自的高速缓存,当其中某个cpu修改了缓存里的数据,该数据会马上同步回主内存,其它cpu通过总线嗅探机制可以感知到数据的变化从而将自己缓存里的数据失效。从而使得cpu重新到主存中读取已经被更新的数据。

volatile

是一种轻量级的同步机制,保证了可见性和有序性,通过内存屏障能够防止指令重排。

保证可见性的原理:

可见性:总线嗅探机制。

当volatile修饰的变量发生改变时,线程A会立刻将该变量从工作内存同步到共享内存中且强迫其他拥有该变量副本的线程(线程B)中的该变量失效,使其必须重新从主存中拷贝更新后的变量。

且有volatile修饰的共享变量进行写操作的时候多出一条带lock前缀的指令,且lock前缀的指令在多核处理器下会引发两件事情:

  1. 将当前处理器缓存行的数据写回到系统内存。
  2. 这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。

内存屏障

内次屏障分为以下4类

屏障类型 指令示例 说明
LoadLoad Barries Load1;LoadLoad;Load2 确保Load1数据的装载先于Load2以及后续装载指令的装载。
StoreStore Barries Store1;StoreStore;Store2 确保Store1数据刷新到内存先于Store2以及后续存储指令的存储。
LoadStore Barries Load1;LoadStore;Store2 确保Load1数据的装载先于Store2数据刷新到内存以及后续存储指令的存储。
StoreLoad Barries Store1;StoreLoad;Load2 确保Store1数据刷新到内存先于Load2数据的装载以及后续装载指令的装载。

volatile实现有序性:

  • 在每个volatile写操作的前面插入一个StoreStore屏障。禁止前面的普通写和下面的volatile写重排序。
  • 在每个volatile写操作的后面插入一个StoreLoad屏障。禁止前面的volatile写和下面可能有的volatile读的重排序。
  • 在每个volatile读操作的后面插入一个LoadLoad屏障。禁止前面的volatile读和下面的普通读重排序。
  • 在每个volatile读操作的后面再插入一个LoadStore屏障。禁止前面的volatile读和下面的普通写重排序。

synchronized保证可见性和有序性原理:

可见性:

JMM关于synchronized的两条规定:

1)线程解锁前,必须把共享变量的最新值刷新到主内存中

2)线程加锁时,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新获取最新的值

(注意:加锁与解锁需要是同一把锁)

有序性:

因为synchronized是一种排他锁,所以同一时刻只有一个线程执行,保证加锁的代码是单线程的,遵循as-if-serial语义,因此能够保证有序性。

volatile和synchronized同步使用的原因

关于这个问题有一个很经典的例子,就是单例模式中的双重检查加锁对象初始化可能为null的问题,这个问题就是指令重排引起的。

具体来说就是synchronized虽然保证了原子性,但却没有保证指令重排序的正确性;

volatile使共享变量在线程的工作内存中修改后的值能够立即更新到进程主内存,即 volitile 可以保证共享变量值对所有线程的“可见性”,但是它不能保证原子性,

而且他还有一个很好的附加功能,那就是禁止指令重排。

将 synchronized 与 volatile 联合使用就可以解决这个问题

CAS(compare and swap)

CAS机制当中使用了3个基本操作数:内存地址V,旧的预期值A,要修改的新值B。

更新一个变量的时候,只有当变量的预期值A和内存地址V当中的实际值相同时,才会将内存地址V对应的值修改为B。

缺点:会一直循环,开销较大。

对于一个共享变量执行操作时,可以通过循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS无法保证原子性,需要通过锁来保证原子性。

ABA问题

所谓ABA问题,就是CAS算法实现需要取出内存中某时刻的数据并在当下时刻比较并替换,这里存在一个时间差,使用AtomicStampedReference类可以解决ABA问题。这个类维护了一个“版本号”Stamp,在进行CAS操作的时候,不仅要比较当前值,还要比较版本号。只有两者都相等,才执行更新操作。

阻塞队列

MySQL调优

redo log、undo log、binlog

image.png

  • redo log是属于innoDB层面,binlog属于MySQL Server层面的,这样在数据库用别的存储引擎时可以达到一致性的要求。
  • redo log是物理日志,记录该数据页更新的内容;binlog是逻辑日志,记录的是这个更新语句的原始逻辑
  • redo log是循环写,日志空间大小固定;binlog是追加写,是指一份写到一定大小的时候会更换下一个文件,不会覆盖。
  • binlog可以作为恢复数据使用,主从复制搭建,redo log作为异常宕机或者介质故障后的数据恢复使用。

事务特性:ACID。

事务隔离界别:

  1. 读未提交(READ UNCOMMITTED)
  2. 读提交 (READ COMMITTED)
  3. 可重复读 (REPEATABLE READ)-> 默认
  4. 串行化 (SERIALIZABLE)

三范式:

1、不可分割 2、依赖于主键 主键约束 3、外键约束 BF、消除传递依赖

MySQL存储引擎

存储引擎是针对而言的,而不是针对整个数据库。即每个表可拥有不同的存储引擎。

种类:

MyISAM:高速,在MySQL5.5版本前是作为默认存储引擎,但不支持事务。

InnoDB:作为MySQL 5.5版本后的默认存储引擎,支持事务和行级锁(行锁才能产生死锁),比MyISAM稍慢。

Memory:内存存储引擎,效率很高,但数据容易丢失,用于临时表使用。

顺序IO:追加操作。传输时间

随机IO:随机操作,可能写在随机的扇区。寻道时间+旋转时间+传输时间

MyISAM引擎:

.frm文件:存储表的结构定义

.MYD文件:存储数据的文件

.MYI文件:存储索引的文件

支持表锁,不支持行锁。不支持事务。

计算count:有专门存储count的地方。

主键索引为非聚集索引(索引文件和数据文件分离)

InnoDB引擎:

.frm文件:存储表的结构定义

.ibd文件:数据和索引均存储在该文件

支持表锁和行锁,也支持事务。

计算count:需要扫表。

因为数据文件本身就是按照B+树组织的索引结构文件。

主键索引为聚集索引。

MyISAM索引B+树中的叶节点存储的data为:该索引对应数据的磁盘文件指针

InnoDB索引B+树中的叶节点存储的data为:直接为该索引对应的数据,而不用再多一步指针

MySQL性能分析

  1. 使用【慢查询功能】,获取查询时间较长的sql语句
  2. 【查看执行计划】,查看有问题的sql的执行计划
  3. 使用【show profiles】查看有问题的sql语句的性能使用情况

MySQL索引优化

索引也很大,存储在磁盘上,且索引也就是一种数据结构(B+树)。

索引优劣

优点:① 提高数据检索效率,降低IO成本;② 通过索引列对数据进行排序,降低排序成本和CPU消耗。(被索引的列会自动排序,对于order by等语句效率也会提高)

缺点:① 索引会占据磁盘空间;② 能提高查询效率但是会降低更新表的效率。比如每次对表进行增删改,需要对索引结构进行变更。

常用索引:单列索引和组合索引。

单列索引:
① 普通索引:MySQL基本索引类型,允许重复值和空值。
② 唯一索引:索引列值唯一,可包含空值。
③ 主键索引:特殊唯一索引,允许空值。

组合索引:
在表的多个字段组合上创建的一个索引。
组合索引的使用需要遵循最左前缀原则(最左匹配原则)。
且一般情况建议使用组合索引代替单列索引(主键索引除外)。

MySQL的索引实现

HASH:无法进行范围查找,很少的存储引擎支持。Memory存储引擎支持。

B+ tree:几乎都使用该数据结构存储索引。

聚集索引(IOT索引组织表)——InnoDB

聚集索引即表数据和索引在一起的。

MySQL在执行查询时,一般情况是通过优化器选择一个索引来进行使用的。

主键索引(聚簇索引)的叶子节点存储数据行,辅助索引只会存储主键值(不是地址值)。辅助索引可以有多个,主键索引有且只有一个。

即如果是非主键查询,需要搜索两次索引树(一次是辅助索引树,一次是主键索引树),最终取得数据。

若没有主键:

先寻找一个唯一非空列作为主键索引,如果还是没有。则自动生成一个隐藏列用作主键索引。

InnoDB表为何必须要主键,且推荐使用整型自增主键而非UUID

因为InnoDB需要B+ tree来存储数据,而B+ tree依赖于主键索引。

UUID占据空间更大
② 查找或者插入时,UUID之间的比较和整型之间的比较相比效率较低
③ 当自增主键插入到B+ tree时,可以直接添加在叶子节点最右方,方便快捷分裂的可能也很小,而UUID插入时则可能会导致已经存储满的节点分裂和树的自动平衡,效率低。

非聚集索引(堆组织表)——MyISAM

由索引得到数据的地址然后从磁盘中进行读取。

组合索引的使用

为何使用组合索引

为了节省mysql索引存储空间以及提升搜索性能,可建立组合索引(能使用组合索引就不使用单例索引)

例如:创建如下的一个组合索引,相当于建立了col1 | col1 col2 | col1 col2 col3三个索引∶

以下语句会创建—棵B+ Tree ,但是它相当于三棵索引树的功效

1
ALTER TABLE 'table_name' ADD INDEX index_name('col1','col2','col3')

注意:

当查询条件为... where col2 = xxx时,是不会走索引的。

而查询条件为... where col2 = xxx and col1 = xxx and col3 = xxx是会由优化器优化查询条件的顺序从而走col1 col2 col3这个索引的。

如何创建组合索引

如何选择哪些列用来创建组合索引 ?

1.常出现在where条件中的列,建议用来创建组合索引,至于组合索引中的顺序,是很重要的。使用到最左前缀原则。但是因为MySQL中存在查询优化器,所以你的书写SQL条件的顺序,不一定是where条件顺序。

2.常出现在order by和group by语句中的列。最后按照顺序去创建组合索引。

3.常出现在select语句中的列,也建议按照顺序,创建组合索引。

最左前缀原则

顾名思义,就是最左优先,这个最左是针对于组合索引和前缀索引,理解如下∶

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

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

如何使用索引

哪些情况需要创建索引

  1. 主键自动建立唯一索引
  2. 频繁作为查询条件的字段应该创建索引(业务)
  3. 多表关联查询中,关联字段应该创建索引
  4. 查询中统计或者分组字段,应该创建索引
  5. 查询中排序的字段,应该创建索引
1
mysql创建组合索引的规先会对组合索引的最左边的,也就是第一个name字段的数据进行排序,在第一个字段的排序基础上,然后再对后面第二个的cid字段进行排序。其实就相当于实现了类似 order by name cid这样一种排序规则。

哪些情况不需要创建索引

  1. 表记录太少
  2. 经常进行增删改操作的表
  3. 更新的字段
  4. where条件里使用频率不高的字段

查看执行计划

建表语句

1
2
3
4
5
6
7
8
9
10
11
create table user(
id int primary key,
name varchar(100),
age int,
sex char(1),
address varchar(100)
);
alter table user add index idx_name_age(name(10), age);
alter table user add index idx_sex(sex);

insert into user(id, name, age, sex, address) values (1, 'zhangsan', 20, '0', '致真大厦');

特别说明

name列的长度是varchar(100),但是创建索引的时候,指定的长度却是10,这是使用了前缀索引这个概念。

前缀索引一般是针对字符串。

MySQL前缀索引和索引选择性

有时候需要索引很长的字符列,这会让索引变得大且慢。通常可以索引开始的部分字符,这样可以大大节约索引空间,从而提高索引效率。但这样也会降低索引的选择性索引的选择性是指不重复的索引值(也称为基数,cardinality)和数据表的记录总数的比值,范围从1/#T到1之间。索引的选择性越高则查询效率越高,因为选择性高的索引可以让MySQL在查找时过滤掉更多的行。唯一索引的选择性是1,这是最好的索引选择性,性能也是最好的。

一般情况下某个前缀的选择性也是足够高的,足以满足查询性能。对于BLOB,TEXT,或者很长的VARCHAR类型的列,必须使用前缀索引,因为MySQL不允许索引这些列的完整长度。

诀窍在于要选择足够长的前缀以保证较高的选择性,同时又不能太长(以便节约空间)。前缀应该足够长,以使得前缀索引的选择性接近于索引的整个列。换句话说,前缀的”基数“应该接近于完整的列的”基数“。

前缀索引是一种能使索引更小,更快的有效办法,但另一方面也有其缺点:

mysql无法使用其前缀索引做ORDER BY和GROUP BY,也无法使用前缀索引做覆盖扫描。

介绍

MySQL提供了一个EXPLAIN命令,它可以对SELECT语句的执行计划进行分析,并输出SELECT执行的详细信息,以供开发人员针对性优化。

使用explain这个命令来查看一个这些SQL语句的执行计划,查看该SQL语句有没有使用上了索引,有没有做全表扫描,这都可以通过explain命令来查看。

可以通过explain命令深入了解MySQL的基于开销的优化器,还可以获得很多可能被优化器考虑到的访问策略的细节,以及当运行SQL语句时哪种策略预计会被优化器采用。

EXPLAIN命令用法十分简单,在SELECT语句前加上explain就可以了,例如:

1
explain select * from user;

参数说明

EXPLAIN命令的输出内容大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
mysql> explain select * from user where id = 2\G
******************** 1. row ************************
id: 1
select_type: SIMPLE
table: user_info
partitions: NULL
type: const
possible_keys: PRIMARY
key: PRIMARY
key_len: 8
ref: const
row: 1
filtered: 100.00
Extra: NULL
1 row in set, 1 warning (0.00 sec)

各列含义

  • id: SELECT查询的标识符.每个SELECT都会自动分配一个唯一的标识符.
  • select_type: SELECT查询的类型.
  • table: 查询的是哪个表
  • partitions: 匹配的分区
  • type: join类型
  • possible_keys: 此次查询中可能选用的索引
  • key: 此次查询中确切使用到的索引.
  • ref: 哪个字段或常数与key一起被使用
  • rows: 显示此查询一共扫描了多少行.这个是一个估计值.
  • filtered: 表示此查询条件所过滤的数据的百分比
  • extra: 额外的信息
type

显示的是单位查询的连接类型或者理解为访问类型,访问性能依次从好到差:

1
2
3
4
5
6
7
8
9
10
11
12
system
const
eq_ref
ref
fulltext
ref_or_null
unique_subquery
index_subquery
range
index_merge
index
ALL

注意:

除了All之外,其他的type都可以用到索引
除了index_merge之外,其他的type只能用到一个索引
最少要使用到range级别

system

只有一行数据,或者是空表时

const(重要)

使用唯一索引或者主键,返回记录一定是1行记录的等值where条件时,通常type是const。其他数据库也叫做唯一索引扫描。

eq_ref(重要)

此类型通常出现在多表的join查询,表示对于前表的每一个结果,都只能匹配到后表的一行结果.并且查询的比较操作通常是=,查询效率较高。

ref相比,eq_ref主要针对唯一索引。

ref(重要)

针对非唯一性索引或者组合索引;使用等值( =)查询。或者是使用了最左前缀规则索引的查询。

fulltext

全文索引检索,要注意,全文索引的优先级很高,若全文索引和普通索引同时存在时,mysql不管代价,优先选择使用全文索引

ref_or_null

与ref方法类似,只是增加了null值的比较。实际用的不多。

unique_subquery

用于where中的in形式子查论查询返回不重复值唯一值

index_subquery

用于in形式子查询使用到了辅助索引或者in常数列表,子查询可能返回重复值,可以使用索引将子查询去重,

range(重要)

索引范围扫描,常见于使用>,<,is null, between ,in ,like等运算符的查询中。

index_merge

表示查询使用了两个以上的索引,最后取交集或者并集,常见and , or的条件使用了不同的索引,官方排序这个在ref_or_null之后,但是实际上由于要读取所个索引,性能可能大部分时间都不如range

index(重要)

select结果列中使用到了索引,type会显示为index。即查询的结果列就是索引列,可以直接从索引树上获得,而不用再回表查询。

索引扫描,把索引从头到尾扫一遍,常见于使用索引列就可以处理不需要读取数据文件的查询、可以使用索引排序或者分组的查询。

all(重要)

这个就是全表扫描数据文件,然后再在server层进行过滤返回符合要求的记录。

extra(重要)

这个列包含不适合在其他列中显示但十分重要的额外的信息,这个列可以显示的信息非常多,有几十种。

using index(重要)

查询时不需要回表查询,直接通过索引就可以获取查询的结果数据。

  • 表示相应的SELECT查询中使用到了覆盖索引(Covering Index),避免访问表的数据行,效率不错!(这也是为何大多公司要求尽量不使用select *的原因,因为无法做到索引覆盖,会导致回表查询)
  • 如果同时出现Using Where,说明索引被用来执行查找素引键值
  • 如果没有同时出现Using Where,表明索引用来读取教据而非执行查找动作。
using where(重要)

表示Mysql将对storage engine提取的结果进行过滤,过滤条件字段无索引;

如下图,对于该查询语句,使用到了主键索引,所以在innodb存储引擎中会进行第一步过滤,然后对于name = 'lisi'则是在MySQL server层进行的第二次过滤,如果在server层进行了第二次过滤,则会显示——using where

也可以将这两层分别对应数据库的逻辑分页和物理分页进行理解。

同时在5.6版本之后,推出了ICP索引下推,即如果下图中name也有索引,则会将其过滤也放在底层进行过滤,进而增大效率。

image.png

using index condition(重要)

Using index condition 会先条件过滤索引,过滤完索引后找到所有符合索引条件的数据行,随后用WHERE 子句中的其他条件去过滤这些数据行;

因为MySQL的架构原因,分成了server层和引擎层,才有所谓的“下推”的说法。所以ICP ( Index ConditionPushdown,索引下推)其实就是实现了index filter技术,将原来的在server层进行的table filter中可以进行index filter的部分,在引擎层面使用index filter进行处理,不再需要回表进行table filter。

查询条件中分为限制条件和检查条件,5.6之前,存储引擎只能根据限制条件扫描数据并返回,然后server层根据检查条件进行过滤再返回真正符合查询的数据。5.6.x之后支持ICP特性,可以把检查条件也下推到存储引擎层,不符合检查条件和限制条件的数据,直接不读取,这样就大大减少了存储引擎扫描的记录数星。

using filesort(重要)
  • 排序时无法使用到索引时,就会出现这个。常见于order by和group by语句中。
  • 说明MySQL会使用一个外部的索引排序,而不是按照索引顺序进行读取。
  • MySQL中无法利用索引完成的排序操作称为“文件排序”。
using temporary
  • 表示使用了临时表存储中问结果。
  • MySQL在对查询结果order by和group by时使用临时表
  • 临时表可以是内存临时表和磁盘临时表,执行计划中看不出来,需要查看status变量,used_tmp_table,used_tmp_disk_table才能看出来。
distinct

在select部分使用了distinct关键字

no tables used

不带from字句的查询或者From dual查询

索引失效分析

案例环境

1
2
3
4
5
6
7
8
9
10
11
staffs员工表 三条记录
+----+------+-----+---------+---------------------+
| id | name | age | pos | add_time |
+----+------+-----+---------+---------------------+
| 1 | z3 | 22 | manager | 2016-12-09 09:31:34 |
| 2 | July | 23 | dev | 2016-12-09 09:31:34 |
| 3 | 2000 | 23 | dev | 2016-12-09 09:31:34 |
+----+------+-----+---------+---------------------+

索引:
主键索引(id)和联合索引(name, age, pos)

案例演示

1、全值匹配我最爱(等值匹配且为全值)
2、最佳左前缀法则
3、不在索引列上做任何操作(计算、函数、(自动or手动)类型转换),会导致索引失效而转向全表扫描
4、存储引擎不能使用索引中范围条件右边的列,即在where语句中范围条件右边的列,无法使用索引。
5、尽量使用覆盖索引(只访问索引的查询(索引列和查询列一致)),减少select *
6、mysql在使用不等于(!=或者<>)的时候无法使用索引会导致全表扫描|
7、is null ,is not null也无法使用索引
8、like以通配符开头('%abc...')Mysql索引失效会变成全表扫描的操作
9、字符串不加单引号索引失效
10、少用or,用它来连接时会索引失效

MySQL性能优化

服务器层面优化

将数据保存在内存中,保证从内存读取数据。

  • 设置足够大的innodb_buffer_pool_size,将数据读到内存中(建议设置为总内存的3/4或者4/5)

  • 可通过下面命令查看
    show global status like 'innodb_buffer_pool_pages_%'
    若innodb_buffer_pool_pages_free为0则表示buffer pool已被用光
    
    1
    2
    3
    4
    5
    6
    7
    8
    9

    ##### 内存预热
    将磁盘数据在MysQL Server启动的时候,读取到内存中。

    ##### 降低磁盘写入次数

    * 对于生产环境来说,很多日志是不需要开启的,比如∶通用查询日志、慢查询日志、错误日志

    * 使用足够大的写入缓存**innodb_log_file_size**
    推荐 innodb_log_file_size设置为0.25 * innodb_buffer_pool_size
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29

    * 设置合适的**innodb_flush_log_at_trx_commit**,和日志落盘有关系。

    ##### 提高磁盘读写

    * 可以考虑使用SSD硬盘,不过得考虑成本是否合适。

    #### SQL设计层面优化

    具体优化方案如下:

    * 设计中间表,一般针对于统计分析功能,或者实时性不高的需求( OLTP、OLAP )
    * 为减少关联查询,创建合理的冗余字段(考虑数据库的三范式和查询性能的取舍,创建冗余字段还需要注意数据—致性问题)
    * 对于字段太多的大表,考虑垂直拆表(比如一个表有100多个字段)
    * 对于表中经常不被使用的字段或者存储数据比较多的字段,考虑拆表(比如商品表中会存储商品介绍,此时可以将商品介绍字段单独拆解到另一个表中,使用商品ID关联)
    * 每张表建议都要有一个主键(主键索引),而且主键类型最好是int类型,建议自增主键(不考虑分布式系统的情况下)。

    #### SQL语句优化(开发人员)

    ##### 索引优化

    * 为搜索字段(where中的条件)、排序字段、 select查询列,创建合适的索引,不过要考虑数据的业务场景∶查询多还是增删多?
    * 尽量建立组合索引并注意组合索引的创建顺序,按照顺序组织查询条件、尽量将筛选粒度大的查询条件放到最左边。
    * 尽量使用覆盖索引,SELECT语句中尽量不要使用\*。
    * order by、group by语句要尽量使用到索引

    ##### 其他优化

    * 尽量不使用count(\*)、尽量使用count(主键)
    COUNT(*): 查询行数,是会遍历所有的行、所有的列。

COUNT(列): 查询指定列不为null的行数(过滤null),如果列可以为空,则COUNT ()不等于COUNT(列),除非指定的列是非空的列才会让COUNT ()等于COUNT(列)

COUNT(伪列): 比如COUNT (1)

1
2

* JOIN两张表的关联字段建立索引,而且最好字段类型是一样的。

SELECT * FROM orders o LEFT OIN user u on o.user_id = u.id

orders表中的user_id和user表中的id,类型要一致

1
2
3

* WHERE条件中尽量不要使用1=1、not in语句(建议使用not exists ) .
* 不用MySQL内置的函数,因为内置函数不会建立查询缓存。

SQL查询语句和查询结果都会在第一次查询只会存储到MySQL的查询缓存中,如果需要获取到查询缓存中的查询结果,查询的SQL语句必须和第一次的查询SQL语句一致。

SELECT * FROM user where birthday = now()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51

## 分布式事务

两个基本场景

> 多数据源:多个数据库之间的一个事务操作。跨库事务指的是,一个应用某个功能需要操作多个库,不同的库中存储不同
> 的业务数据。在真实应用场景下,一个业务操作多个库也是比较常见的,那么多个数据库是之间是互相不可见的,如何保证数据库的一致性呢?此时就必须使用分布式事务的解决方案。
>
> 多服务:多台服务器之间的事务操作。事务分布在不同服务器上,要求不同服务器事务要么都成功要么都失败,称为分布式事务。

分布式事务就是为了保证不同资源服务器的数据一致性。

### 分布式事务事务模型——DTP模型

1)应用程序(Application Program : AP): 定义事务边界(事务开始,结束)

2)资源管理器(Resource Manager: RM): 任何用来存储数据的服务。

3)事务管理器(Transaction Manager : TM ): 监控事务进度,负责事务提交,回滚。

4)通信资源管理(Communcation Resource Manager : CRM )

5)通信协议(负责事务模型之间的通信协议)

当一个DTP模型中,存在多个模型实例时,会形成一种树形调用关系,叫做**全局事务树形结构(GlobalTransaction Tree Structure)**。

### 分布式事务事务模型-XA规范
XA规范的最主要的作用是,就是定义了RM-TM的交互接口

XA∶定义了TM和RM交互接口的规范。(事务注册,开始,回滚,事务结束...)

XA标准∶存储数据的服务都开发了支持分布式事务接口规范:XA(MySQL,RocketMQ)

TM事务管理器也必须按照XA规范来进行调用事务控制接口。

### 分布式事务事务模型-2PC(2阶段提交)

两阶段提交协议(Two Phase Commit ),XA规范对其进行了优化。而从字面意思来理解,TwoPhase Commit,就是将提交(commit)过程划分为2个阶段(Phase):

[![https://img.rruu.net/image/601e3519d55f7](https://img.rruu.net/image/601e3519d55f7)](https://img.rruu.net/image/601e3519d55f7)

### 分布式事务事务模型-3PC(3阶段提交)

三阶段提交(3PC)[Three-phase commit],是二阶段提交(2PC)的改进版本。与两阶段提交不同的是,三阶段提交有两个改动点

* 引入超时机制。同时在协调者和参与者中都引入超时机制。
* 在第一阶段和第二阶段中插入一个准备阶段。保证了在最后提交阶段之前各参与节点的状态是一致的。也就是说,除了引入超时机制之外,3PC把2PC的准备阶段再次一分为二,这样三阶段提交就有CanCommit、PreCommit、DoCommit三个阶段

### 分布式事务解决方案-JTA

Java事务APl ( JTA : Java Transaction APl )和它的同胞Java事务服务(JTS : Java Transaction Service ),为J2EE平台提供了分布式事务服务(distributed transaction)的能力。某种程度上,可以认为JTA规范是XA规范的Java版,其把XA规范中规定的DTP模型交互接口抽象成Java接口中的方法,并规定每个方法要实现什么样的功能。

Java事务JTA分布式事务控制规范:
JTA是XA接口规范的Java版本。控制分布式事务。
1、AP
2、Application Server (Jboss,weblogic,websphere等等)
3、TM
4、RM

Java分布式事务:Application Server 实现XA接口的规范,在服务内部自己根据XA规范,开发了一套分布式事务控逻辑TM,把TM集成到jboss,weblogic

问题:TOMCAT服务器没有实现XA接口规范,不能自己控制分布式事务?
第三方框架: atomikos —- xa

Atomiko主要用来控制多数据源这样的分布式事务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102

### 分布式事务解决方案-LCN

TX-LCN由两大模块组成,TxClient、TxManager,TxClient作为模块的依赖框架,提供TX-LCN的标准支持,TxManager作为分布式事务的控制器.

事务发起方或者参与都由TxClient端来控制

原理:

> LCN模式是通过代理Connection的方式实现对本地事务的操作,然后在由TxManager统一协调控制事务。当本地事务提交回滚或者关闭连接时将会执行假操作,该代理的连接将由LCN连接池管理。

特点:

> 该模式对代码的嵌入性为低。
> 该模式仅限于本地存在连接对象且可通过连接对象控制事务的模块。
> 该模式下的事务提交与回滚是由本地事务方控制,对于数据一致性上有较高的保障。该模式缺陷在于代理的连接需要随事务发起方一起释放连接,增加了连接占用的时间。

### 分布式事务解决方案-TCC

TCC——事务补偿机制,控制分布式事务的实现。

TCC事务机制相对于传统事务机制(X/Open XA Two-Phase-Commit),其特征在于它不依赖资源管理器(RM)对XA的支持,而是通过对(由业务系统提供的)业务逻辑的调度来实现分布式事务。

主要由三步操作∶

> Try:尝试执行业务
> Confirm:确认执行业务
> Cancel:取消执行业务。

特点:

> 该模式对代码的嵌入性高,要求每个业务需要写三种步骤的操作。该模式对有无本地事务控制都可以支持使用面广。
> 数据一致性控制几乎完全由开发者控制,对业务开发难度要求高。

### 分布式事务解决方案-RocketMQ
实际系统的开发过程中,可能服务间的调用是异步的(MQ消息中间件异步通知)、那么如何保证这种异步的各个服务间的分布式事务呢?

### 分布式事务解决方案-尽最大可能通知

最大努力通知型(Best-effort delivery)是最简单的一种柔性事务,适用于一些最终一致性时间敏感度低的业务,且被动方处理结果不影响主动方的处理结果。典型的使用场景∶如银行通知、商户通知等。最大努力通知型的实现方案,一般符合以下特点:

> 不可靠消息∶业务活动主动方,在完成业务处理之后,向业务活动的被动方发送消息,直到通知N次后不再通知,允许消息丢失(不可靠消息)。
> 定期校对∶业务活动的被动方,根据定时策略,向业务活动主动方查询(主动方提供查询接口),恢复丢失的业务消息。

### CAP

C和A无法同时存在。

因为在保证强一致性的时候,主节点会将从节点锁定并进行同步来保证一致性,此时用户请求则无法立刻得到结果,违反可用性。

因此存在的组合只有CP和AP模型。因此选型也就只有强一致性(2PC)和最终一致性(MQ、TCC)两种选择。

### BASE理论
BASE是Basically Available (基本可用)、Soft state(软状态)和Eventually consistent (最终一致性)三个短语的缩写。

* 基本可用(Basically Available ):指分布式系统在出现不可预知故障的时候,允许损失部分可用性。
* 软状态( Soft State ):指允许系统中的数据存在中间状态,并认该中间状态的存在不会影响系统的整体可用性。
* 最终一致(Eventual Consistency ):强调的是所有的数据更新操作,在经过一段时间的同步之后,最终都能够达到一个一致的状态

### 柔性事务

> 最大努力通知(非可靠消息、定期校对)
>
> 可靠消息最终一致性(异步确保型)
>
> TCC(两阶段型、补偿型)

## IO多路复用

### select

非多线程,是单线程,多线程存在上下文切换的开销。

简单思路:死循环中遍历所有fd文件描述符,获得其中有数据的部分进行读取和处理。

具体实现:

> ①首先获得所有的fd
> ②在一个死循环中,将所有fd拷入一个rset中(rset是一个位图bitmap),将fd对应的编号置为1
> ③调用select函数,从用户态拷贝rset到内核态,并进行阻塞等待,监听所有fd,直到有对应的有数据到来
> ④将有数据到来的fd(可能会有多个)进行置位,是在rset中置位,fd本身没有变化
> ⑤再次遍历rset,读取其中被置位的fd的数据,并进行相应处理。再次进入死循环

缺点:

> 位图的大小是有限的,默认1024位
>
> rset不能重用,内核态对其进行了改变,导致每次需要重置rset并重新拷贝fd
>
> 用户态到内核态的转换和拷贝开销较大
>
> 在内核态监听完成并置位rset后,还需要再次遍历rset,时间开销较大

### poll

```c
struct pullfd {
int fd;
short events; // 初始化为需要监听的事件 如 PULLIN
short revents; // 用于置位使用
}
// 不再使用bitmap,而是采用自定义结构体

具体实现和select类似,相同点和区别在于:

相同点:①都是需要从用户态拷贝到内核态并由内核进行监听处理,开销较大②都需要对存储fd的数据结构进行二次遍历③也是阻塞调用

①未使用bitmap进行存储,而是采用pullfd[]数组进行存储
②内核监听到数据时,不再对pullfd[]整体置位,只对revents成员置位,这样使得pullfd[]可以重用。
③内核监听完成后,对pullfd进行遍历时,如果revents被置位,则进行数据读取和处理,同时将revents重新赋值为0。
④存储的数量远大于bitmap的限制1024位。

epoll

image.png

同样存在一个结构体epoll_event,包含fd和event数据,但不包含revents

具体实现:

①准备数据,通过epoll_ctl将fd和events拷贝进入一个由epoll_create创建的白板中,同时还会在内核创建一个红黑树和一个链表
②同样通过死循环,然后调用epoll_wait方法,同时上文提到的白板是并不是用户态和内核态共享的(b站视频有误)
③通过epoll_wait方法,内核通过监听,将fd进行”置位”,这里的置位是指重排序,将有数据的fd放到链表前面,然后返回有数据的fd数量
④通过返回值再进行遍历,省去了之前O(n)复杂度的再次遍历,然后对数据进行读取和处理

两种工作模式

LT模式

fd可读之后,如果服务程序读走一部分就结束此次读取,LT模式下该文件描述符仍然可读
fd可写之后,如果服务程序写了一部分就结束此次写入,LT模式下该文件描述符也仍然可写

ET模式

fd可读之后,如果服务程序读走一部分就结束此次读取,ET模式下该文件描述符是不可读,需要等到下次有数据到达时才可变为可读,所有我们要保证循环读取数据,以确保把所有数据读出
fd可写之后,如果服务程序写了一部分就结束此次写入,ET模式下该文件描述符是不可写的,我们要保证写入数据,确保把数据写满

redis和nginx、java的nio在linux下都是通过epoll实现。

JVM

JVM常识

程序的执行方式

静态编译、动态编译和动态解释执行。

注意:此处的编译是指编译成OS能直接运行的机器码。

JVM的运行模式

JVM有两种运行模式: Server模式与Client棒式。

两种模式的区别在于:

  • Client模式启动速度较快,Server模式启动较慢;
  • 但是启动进入稳定期长期运行之后Server模式的程序运行速度比Client要快很多。
  • 因为Server模式启动的JVM采用的是重量级的虚拟机,对程序采用了更多的优化;而Client模式启动的VM采用的是轻量级的虚拟机。所以Server启动慢,但稳定后速度比Client远远要快。

JVM架构理解

JVM 程序执行流程

java编译成字节码、动态编译和解释为机器码的过程分析

编译器和解释器的协调工作流程:

在部分商用虚拟机中(如HotSpot),Java程序最初是通过解释器(Interpreter)进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为”热点代码”。为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器(Just In Time Compiler,下文统称IT编译器)。

由于Java虚拟机规范并没有具体的约束规则去限制即使编译器应该如何实现,所以这部分功能完全是与虚拟机具体实现相关的内容,如无特殊说明,我们提到的编译器、即时编译器都是指Hotspot虚拟机内的即时编译器,虚拟机也是特指HotSpot虚拟机。

我们的JIT是属于动态编译方式的,动态编译(dynamic compilation)指的是”在运行时进行编译”;与之相对的是事前编译(ahead-of-time compilation,简称AOT),也叫静态编译(static compilation)。

JIT编译(just-in-time compilation)狭义来说是当某段代码即将第一次被执打时进行编译,因而叫”即时编译”。JIT编译是动态编译的一种特例。]JIT编译一词后来被泛化,时常与动态编译等价;但要注意广义与狭义的JIT编译所指的区别。

JIT的使用

  • 为何HotSpot需要使用解释器和编译器并存的架构?
  • JVM为什么要实现两个不同的即时编译器?
  • 程序何时会使用解释器执行?何时会使用编译器执行?
  • 哪些程序代码会被编译成为本地代码?如何编译?
  • JAVA代码的执行效率就一定比C,C++静态执行的执行差?JAVA代码解析执行有何优势?

为什么要使用解释器与编译器并存的架构

尽管并不是所有的Java虚拟机都采用解释器与编译器并存的架构,但许多主流的商用虚拟机(如HotSpot),都同时包含解释器和编译器。

解释器与编译器特点

  • 当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率
  • 当程序运行环境中内存资源限制较大(如部分嵌入式系统中),可以使用解释器执行节约内存,反之可以使用编译执行来提升效率。

编译的时间开销

解释器的执行,抽象的看是这样的:
输入的代码->[解释器解释执行]->执行结果

而要JIT编译然后再执行的话,抽象的看则是:
输入的代码→>[编译器编译]->编译后的代码→>[执行]->执行结果

说JIT比解释快,其实说的是”执行编译、代码”比”解释器解释执行”要快,并不是说”编译”这个动作比”解释”这个动作快。JIT编译再怎么快,至少也比解释执行一次略慢一些,而要得到最后的执行结果还得再经过一个”执行编译后的代码”的过程。所以,对“只执行一次””的代码而言,解释执行其实总是比JIT编译执行要快。

怎么算是”只执行一次的代码”呢?粗略说,下面两个条件同时满足时就是严格的“只执行一次”
1、只被调用一次,例如类的构造器(class initializer,())
2、没有循环
对只执行一次的代码做JIT编译再执行,可以说是得不偿失。
对只执行少量次数的代码,JIT编译带来的执行速度的提升也未必能抵消掉最初编译带来的开销。

只有对频繁执行的代码,JIT编译才能保证有正面的收益。

编译的空间开销

对一般的Java方法而言,编译后代码的大小相对于字节码的大小,膨胀比达到10x是很正常的。同上面说的时间开销一样,这里的空间开销也是,只有对执行频繁的代码才值得编译,如果把所有代码都编译则会显著增加代码所占空间,导致”代码爆炸”。

这也就解释了为什么有些VM会选择不总是做JIT编译,而是选择用解释器 + JIT编译器的混合执行引擎。

为何要实现两个不同的即时编译器

HotSpot虚拟机中内置了两个即时编译器:Client Complier和Server Complier0,简称为C1、C2编译器,分别用在客户端和服务端。

目前主流的HotSpot虚拟机中默认是采用解释器与其中一个编译器直接配合的方式工作。程序使用哪个编译器,取决于虚拟机运行的模式。HotSpot虚拟机会根据自身版本与宿主机器的硬件性能自动选择运行模式,用户也可以使用”-client”或”-server”参数去强制指定虚拟机运行在Client模式或Server模式。

用Client Complier获取更高的编译速度,用Server Complier来获取更好的编译质量。为什么提供多个即时编译器与为什么提供多个垃圾收集器类似,都是为了适应不同的应用场景。

哪些程序代码会被即时编译

程序中的代码只有是热点代码时,才会编译为本地代码,那么什么是热点代码呢?

运行过程中会被即时编译器编译的“热点代码”有两类:

  1. 被多次调用的方法。
  2. 被多次执行的循环体。

两种情况,编译器都是以整个方法作为编译对象。这种编译方法因为编译发生在方法执行过程之中,因此形象的称之为栈上替换(On Stack Replacement,oSR),即方法栈帧还在栈上,方法就被替换了。

如何判断热点代码呢

要知道方法或一段代码是不是热点代码,是不是需要触发即时编译,需要进行Hot Spot Detection(热点探测)。

目前主要的热点探测方式有以下两种:

  • 基于采样的热点探测
    采用这种方法的虚拟机会周期性地检查各个线程的栈顶,如果发现某些方法经常出现在栈顶,那这个方法就是”热点方法”。这种探测方法的好处是实现简单高效,还可以很容易地获取方法调用关系(将调用堆栈展开即可),缺点是很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测。
  • 基于计数器的热点探测
    采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阀值,就认为它是”热点方法”,这种统计方法实现复杂一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系L它的统计结果相对更加精确严谨。

热点检测方式

在HotSpot虚拟机中使用的是第二种―—基于计数器的热点探测方法,因此它为每个方法准备了两个计数器︰方法调用计数器和回边计数器。在确定虚拟机运行参数的前提下,这两个计数器都有一个确定的阈值,当计数器超过阈值溢出了,就会触发JIT编译。

方法调用计数器

顾名思义,这个计数器用于统计方法被调用的次数。

回边计数器

它的作用就是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为”回边”。

JIT优化

公共子表达式的消除

javac编译器不会对公共子表达式进行消除。进入即时编译器后,JIT会对公共子表达式进行消除,且还有可能进行代数替换,即将可以计算出的结果直接计算得到,进行优化。

方法内联

指的是:在JIT即时编译时,将方法调用直接使用方法体内的代码进行替换,从而减少方法调用过程中的压栈和入栈的开销。

当JVM检测到一些小方法频繁调用时,会使用方法内联进行优化。

逃逸分析

逃逸分析(Escape Analysis)是目前Java虚拟机中比较前沿的优化技术。这是一种可以有效减少]ava程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析. Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。

个人理解:如果对象逃逸了,则无法对其进行优化,只能分配到堆上,如果没有逃逸,则可以将其分配到栈上。

逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中,称为方法逃逸。

逃逸分析包括:

  • 全局变量赋值逃逸
  • 方法返回值逃逸
  • 实例引用发生逃逸
  • 线程逃逸:赋值给类变量或可以在其他线程中访问的实例变量
1
2
3
4
5
6
7
8
9
10
11
12
13
public static Object object;
public void globalVariableEscape() {//全局变量赋值逃逸
object =new Object();
}
public Object methodEscape() { //方法返回值逃逸
return new Object();
}
public void instancePassEscape() {//实例引用发生逃逸
this.speak(this);
}
public void speak(EscapeAna1ysis escapeAna1ysis) {
System.out.println("Escape He11o");
}

方法逃逸案例

1
2
3
4
5
6
public static StringBuffer createStringBuffer(String s1,String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb;
}

StringBuffer sb是一个方法内部变量,上述代码中直接将sb返回,这样这个StringBuffer有可能被其他方法所改变,这样它的作用域就不只是在方法内部,虽然它是一个局部变量,称其逃逸到了方法外部。甚至还有可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。

上述代码如果想要StringBuffer sb不逃出方法,可以这样写:

1
2
3
4
5
6
public static String createStringBuffer(String s1,String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}

不直接返回StringBuffer,那么StringBuffer将不会逃逸出方法。

使用逃逸分析,编译器可以对代码做如下优化:

1
2
3
4
5
6
7
一、同步省略。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。

二、将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分
配的候选,而不是堆分配。

三、分离对象或标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全
部)可以不存储在内存,而是存储在CPU寄存器中。

在Java代码运行时,通过JVM参数可指定是否开启逃逸分析。

1
2
-XX:+DoEscapeAnalysis: open
-XX:-DoEscapeAnalysis: close

从jdk1.7开始默认开启逃逸分析。

对象上的栈分配

我们知道,在一般情况下,对象和数组元素的内存分配是在堆内存上进行的。但是随着JIT编译器的日渐成熟,很多优化使这种分配策略并不绝对。JIT编译器就可以在编译期间根据逃谗分析的结果,来决定是否可以将对象的内存分配从堆转化为栈。

标量替换

在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 存在类A
public class A {
public int a = 1, b = 2;
}
// 存在一方法使用类里的a和b
private void getAB() {
A x = new A();
x.a;
x.b;
}
// 编译时JVM会直接编译成这样
private void getAB() {
a = 1;
b = 2;
}

/* 这就是标量替换 */

同步锁消除

同样基于逃逸分析,当加锁的变量不会发生逃逸,是线程私有的完全没有必要加锁。在JIT编译时期就可以将同步锁去掉,以减少加锁与解锁造成的资源开销。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class TestLockEliminate {
public static String getString(String s1,String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
public static void main(String[] args) {
long tsStart = System.currentTimeMillis();
for (int i = 0; i < 1000000; i++) {
getString( ""TestLockE1iminate ", "suffix"");
}
System.out.println("一共耗费: "+ (System.currentTimeMillis() - tsStart) + "ms");
}
}

getString()方法中的StringBuffer数以函数内部的局部变量,进作用于方法内部,不可能逃逸出该方法,因此他就不可能被多个线程同时访问,也就没有资源的竞争,但是StringBuffer的append操作却需要执行同步操作,代码如下:

1
2
3
4
5
6
@Override
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}

逃逸分析和锁消除分别可以使用参数-xX:+DOEscapeAnalysis和-XX:+EliminateLocks(锁消除必须在-server模式下)开启。

带你认识一下class文件

class文件概述

内容为16进制。开头为CAFEBABE魔数

常量池数据区

双亲委派:当类加载器碰到一个类时,需要交给其父类加载器加载,只有当其父类加载器无法加载时,自己才去尝试加载。

破坏双亲委派