Java基础

Java中是如何支持正则表达式操作的?

Java中的String类提供了支持正则表达式操作的方法,包括:matches()、replaceAll()、replaceFirst()、split()。此外,Java中可以用Pattern类表示正则表达式对象,它提供了丰富的API进行各种正则表达式操作。

描述一下正则表达式及其用途:

在编写处理字符串的程序时,经常会有查找符合某些复杂规则的字符串的需要。正则表达式就是用于描述这些规则的工具。正则表达式尤其擅长处理字符串匹配。

在用 && 时需要注意的事项:

&&又称为短路运算,如果&&左边的表达式的值是false,右边的表达式会被直接短路掉,不会进行运算。,例如在验证用户登录时判定用户名不是null而且不是空字符串,应当写为:username != null &&!username.equals(""),二者的顺序不能交换,更不能用&运算符,因为第一个条件如果不成立,根本不能进行字符串的equals比较,否则会产生NullPointerException异常。

int 和 Integer有什么区别?

int 是基本数据类型,但是为了能够将这些基本数据类型当成对象操作,Java为每一个基本数据类型都引入了对应的包装类型(wrapper class),int的包装类就是Integer,从Java 5开始引入了自动装箱/拆箱机制,使得二者可以相互转换。

String , StringBuffer 和 StringBuilder 又什么区别?

Mutability Difference:

String is immutable, if you try to alter their values, another object gets created, whereas StringBuffer and StringBuilder are mutable so they can change their values.

String 是不可变的,如果你试图改变它们的值,那么另一个对象将被创建。但是 StringBuffer 和 StringBuilder 是可变的,它们的数值可以修改。

Thread-Safety Difference:

The difference between StringBuffer and StringBuilder is that StringBuffer is thread-safe. So when the application needs to be run only in a single thread then it is better to use StringBuilder. StringBuilder is more efficient than StringBuffer.

StringBuffer 和 StringBuilder 的不同之处在于,StringBuffer 是线程安全的,所以当应用只需要单线程运行用 StringBuilder 更好,StringBuilder 的效率比 StringBuffer 好。

Situations:

  • If your string is not going to change use a String class because a String object is immutable.
  • If your string can change (example: lots of logic and operations in the construction of the string) and will only be accessed from a single thread, using a StringBuilder is good enough.
  • If your string can change, and will be accessed from multiple threads, use a StringBufferbecause StringBuffer is synchronous so you have thread-safety.

stackoverflow

底层实现上的话,StringBuffer其实就是比StringBuilder多了Synchronized修饰符。

String是最基本的数据类型吗?

String 不是基本的数据类型,基本数据类型包括byte、int、char、long、float、double、boolean和short。 java.lang.String类是final类型的,因此不可以继承这个类、不能修改这个类。为了提高效率节省空间,我们应该用StringBuffer类。

请你讲讲数组(Array)和列表(ArrayList)的区别?什么时候应该使用Array而不是ArrayList?

数组(Array):

double[] array = {1.2,1.6,1.9};
for(double e : array) {
    System.out.println(e);
}

列表(ArrayList):

public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable

根据源码: RandomAccess(随机访问),根据所继承的 RandomAccess 接口可知 ArrayList 继承了数组的随机访问特点。

private static final int DEFAULT_CAPACITY = 10;

根据源码:Default initial capacity(默认初始容量)可知初始容量大小为10。

private void grow(int minCapacity) {
    // overflow-conscious code
    int oldCapacity = elementData.length;
    int newCapacity = oldCapacity + (oldCapacity >> 1);
    if (newCapacity - minCapacity < 0)
    newCapacity = minCapacity;
    if (newCapacity - MAX_ARRAY_SIZE > 0)
    newCapacity = hugeCapacity(minCapacity);
    // minCapacity is usually close to size, so this is a win:
    elementData = Arrays.copyOf(elementData, newCapacity);
}

根据源码:这是一个动态扩容函数:

int newCapacity = oldCapacity + (oldCapacity >> 1); 表明新容量是旧容量的1.5倍。

elementData = Arrays.copyOf(elementData, newCapacity); 扩容操作调用了 Arrays.copyOf() 把原数组整个复制到新数组中,这个操作代价很高,因此最好在创建 ArrayList 对象时就指定大概的容量大小,减少扩容操作的次数。

总结:ArrayList 是最常用的 List 实现类,内部是通过数组实现的,它允许对元素进行快速随机访问。数组的缺点是每个元素之间不能有间隔,当数组大小不满足时需要增加存储能力,就要将已经有数组的数据复制到新的存储空间中。当从 ArrayList 的中间位置插入或者删除元素时,需要对数组进行复制、移动、代价比较高。因此,它适合随机查找和遍历,不适合插入和删除。

因此当确定数组大小时用 Array,不确定数组大小时用 ArrayList。

Java是值传递还是引用传递?

值传递是对基本型变量而言的,传递的是该变量的一个副本,改变副本不影响原变量。 引用传递一般是对于对象型变量而言的,传递的是该对象地址的一个副本,,并不是原对象本身。所以对引用对象进行操作会同时改变原对象.。 一般认为,java内的传递都是值传递。

请你说明符号“==”比较的是什么?

“==”两边如果是基本类型,就是比较数值是否相等。两边如果是对象,就是比较内存地址。

new Integer(123) 与 Integer.valueOf(123) 的区别在于?

  • new Integer(123) 每次都会新建一个对象;
  • Integer.valueOf(123) 会使用缓存池中的对象,多次调用会取得同一个对象的引用。

查看源码可知:valueOf() 方法的实现比较简单,就是先判断值是否在缓存池中,如果在的话就直接返回缓存池的内容。

public static Integer valueOf(int i) {
    if (i >= IntegerCache.low && i <= IntegerCache.high)
    	return IntegerCache.cache[i + (-IntegerCache.low)];
    return new Integer(i);
}

但其他基本数据类型都没用缓存池,查看 Double,Float 的源码可得:

public static Double valueOf(double d) {
	return new Double(d);
}
public static Float valueOf(float f) {
	return new Float(f);
}

它们都是直接新建一个对象,完全没用用到缓存。

equals 中需要注意的地方:

if(username.equals(“xxx”){
	......
}

这个 equals 的用法有什么不妥之处?

username可能为null,会报空指针错误,应改为"xxx".equals(username)

请你讲讲Java里面的final关键字是怎么用的?

当用 final 修饰一个类时,表明这个类不能被继承。如果一个类你永远不会让他被继承,就可以用final进行修饰。

当用 final 修饰一个方法时,表明把方法锁定,以防任何继承类修改它的含义。

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

请判断,两个对象值相同(x.equals(y) == true),但却可有不同的hash code,该说法是否正确,为什么?

不对,如果两个对象x和y满足x.equals(y) == true,它们的哈希码(hash code)应当相同。

Java对于eqauls方法和hashCode方法是这样规定的:

  • 如果两个对象相同(equals方法返回true),那么它们的hashCode值一定要相同;
  • 如果两个对象的hashCode相同,它们并不一定相同。

请说明Java是否支持多继承?

Java中类不支持多继承,只支持单继承(即一个类只有一个父类)。 但是java中的接口支持多继承,,即一个子接口可以有多个父接口。(接口的作用是用来扩展对象的功能,一个子接口继承多个父接口,说明子接口扩展了多个功能,当类实现接口时,类就扩展了相应的功能)。

请你谈谈如何通过反射创建对象?

  • 方法1:通过类对象调用newInstance()方法,例如:String.class.newInstance()

  • 方法2:通过类对象的getConstructor()或getDeclaredConstructor()方法获得构造器(Constructor)对象并调用其newInstance()方法创建对象,例如:String.class.getConstructor(String.class).newInstance("Hello");

解释一下类加载机制,双亲委派模型,好处是什么?

某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父类加载器,依次递归,如果父类加载器可以完成类加载任务,就成功返回;只有父类加载器无法完成此加载任务时,才自己去加载。

使用双亲委派模型的好处在于Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存在在rt.jar中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的Bootstrap ClassLoader进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。相反,如果没有双亲委派模型而是由各个类加载器自行加载的话,如果用户编写了一个java.lang.Object的同名类并放在ClassPath中,那系统中将会出现多个不同的Object类,程序将混乱。因此,如果开发者尝试编写一个与rt.jar类库中重名的Java类,可以正常编译,但是永远无法被加载运行。

能不能自己写个类叫java.lang.System

**答案:**通常不可以,但可以采取另类方法达到这个需求。 **解释:**为了不让我们写System类,类加载采用委托机制,这样可以保证爸爸们优先,爸爸们能找到的类,儿子就没有机会加载。而System类是Bootstrap加载器加载的,就算自己重写,也总是使用Java系统提供的System,自己写的System类根本没有机会得到加载。

但是,我们可以自己定义一个类加载器来达到这个目的,为了避免双亲委托机制,这个类加载器也必须是特殊的。由于系统自带的三个类加载器都加载特定目录下的类,如果我们自己的类加载器放在一个特殊的目录,那么系统的加载器就无法加载,也就是最终还是由我们自己的加载器加载。

请说说快速失败(fail-fast)是如何产生的?

fail-fast 机制是 java 集合中的一种错误机制,当多个线程对同一个集合进行操作的时候,就可能产生 fail-fast 事件。例如:当一个线程 A 通过 iterator 去遍历某集合的过程中,若该集合内容被其他线程改变了,那么当 A 访问集合时,就会发生 ConcurrentModificationException 错误。那么就产生了 fail-fast 事件。

接下来,我们再系统的梳理一下fail-fast是怎么产生的。步骤如下: (01) 新建了一个ArrayList,名称为arrayList。 (02) 向arrayList中添加内容。 (03) 新建一个“线程a”,并在“线程a”中通过Iterator反复的读取arrayList的值 (04) 新建一个“线程b”,在“线程b”中删除arrayList中的一个“节点A”。 (05) 这时,就会产生有趣的事件了。 在某一时刻,“线程a”创建了arrayList的Iterator。此时“节点A”仍然存在于arrayList中,创建arrayList时,expectedModCount = modCount(假设它们此时的值为N)。 在“线程a”在遍历arrayList过程中的某一时刻,“线程b”执行了,并且“线程b”删除了arrayList中的“节点A”。“线程b”执行remove()进行删除操作时,在remove()中执行了“modCount++”,此时modCount变成了N+1! “线程a”接着遍历,当它执行到next()函数时,调用checkForComodification()比较“expectedModCount”和“modCount”的大小;而“expectedModCount=N”,“modCount=N+1”,这样,便抛出ConcurrentModificationException异常,产生fail-fast事件。

注意:java.util包下的集合类都是快速失败的,不能在多线程下发生并发修改(迭代过程中被修改)。

Java为什么不支持多继承

多继承指一个子类能同时继承于多个父类,从而同时拥有多个父类的特征,但缺点是显著的。

  • 若子类继承的父类中拥有相同的成员变量,子类在引用该变量时将无法判别使用哪个父类的成员变量
  • 若一个子类继承的多个父类拥有相同方法,同时子类并未覆盖该方法(若覆盖,则直接使用子类中该方法),那么调用该方法时将无法确定调用哪个父类的方法。

比如我们定义一个动物(类)既是狗(父类1)也是猫(父类2),两个父类都有“叫”这个方法。那么当我们调用“叫”这个方法时,它就不知道是狗叫还是猫叫了,这就是多重继承的冲突。

https://blog.csdn.net/qq_22256565/article/details/46606777

https://www.zhihu.com/question/24317891


GC如何判断一个对象是垃圾?

使用垃圾回收算法。 目前主要的垃圾回收算法是可达性分析算法,就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(用图论的话来说,就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的。

在Java语言中,可作为GC Roots的对象包括下面几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象。
  • 方法区中类静态属性引用的对象。
  • 方法区中常量引用的对象。
  • 本地方法栈中JNI(即一般说的Native方法)引用的对象。

Java内存泄漏引起的原因?

长生命周期的对象持有短生命周期对象的引用就很可能发生内存泄漏,尽管短生命周期对象已经不再需要,但是因为长生命周期持有它的引用而导致不能被回收,这就是Java中内存泄漏的发生场景。


Java 线程池核心线程数量一般设成多少

线程池究竟设成多大是要看你给线程池处理什么样的任务,任务类型不同,线程池大小的设置方式也是不同的。

任务一般可分为:CPU密集型、IO密集型、混合型,对于不同类型的任务需要分配不同大小的线程池。

CPU密集型任务 尽量使用较小的线程池,一般为CPU核心数+1。 因为CPU密集型任务使得CPU使用率很高,若开过多的线程数,只能增加上下文切换的次数,因此会带来额外的开销。

IO密集型任务 可以使用稍大的线程池,一般为2*CPU核心数。 IO密集型任务CPU使用率并不高,因此可以让CPU在等待IO的时候去处理别的任务,充分利用CPU时间。

混合型任务可以将任务分成IO密集型和CPU密集型任务,然后分别用不同的线程池去处理。 只要分完之后两个任务的执行时间相差不大,那么就会比串行执行来的高效。因为如果划分之后两个任务执行时间相差甚远,那么先执行完的任务就要等后执行完的任务,最终的时间仍然取决于后执行完的任务,而且还要加上任务拆分与合并的开销,得不偿失。


谈谈对 spring IoC 的理解

IoC(控制反转),将创建对象的控制权交给 spring 管理,IoC 容器本质上是一个 Map(key,value),Map 中存放各种对象。 IOC 容器就像是一个工厂一样,当我们需要创建一个对象的时候,只需要配置好配置文件/注解即可,完全不用考虑对象是如何被创建出来的。

谈谈对 spring AOP 的理解

AOP(面向切面编程),将那些与业务无关,却为业务模块所共同调用的逻辑(例如事务处理、日志管理、权限控制等)封装起来,便于减少系统的重复代码降低模块间的耦合度,并有利于未来的可拓展性和可维护性

谈谈对线程安全的理解

”线程安全“ 其实不是特指线程的安全,而是指内存的安全。

每个进程的内存空间都有一块公共的区域,通常叫做堆内存。进程内所有线程都可以访问该区域,这就造成了内存中数据不安全。

那我们该怎么办呢?
  • 使用局部变量(即将变量定义在方法内)。这个原理其实是将数据放入线程的栈内存中,栈内存其他线程无权访问。
  • 使用 ThreadLocal 类,ThradLocal 类可以把数据复制N份,每个线程各领一份,各玩各的,互不影响。
  • 变量加 final 关键字,使其变成只读。
  • (悲观锁解决方案)加互斥锁,每次操作前必须获取锁,操作完成后释放锁。
  • (乐观锁解决方案)在并发很小的情况下,数据被修改的概率很小,但又存在这种可能,这时用 CAS。就是在线程退出操作的时候,记录下当前数据的状态。当线程再次进入操作的时候,把之前保存的那一份数据状态与现在的数据状态进行对比,如果数据没有被改动,那么就继续操作,改动过就重新处理。(当然需要考虑 ABA 问题,这个问题也好解决,加一个版本号就行了,规定只要修改了数据,版本号就需要加1。那么就是需要对照保存的数据和版本号,都一致才说明没有被修改)。但这种方案只适用于并发量不高的情况。

乐观锁持乐观态度,就是假设我的数据不会被修改,如果被修改了,大不了从头再来。

悲观锁持悲观态度,就是我认定我的数据一定会被修改,所以我直接加锁。

Java类加载过程

类加载的过程包括了加载,验证,准备,解析,初始化,使用,卸载七个阶段

  • 加载:在Java堆中把 .java 文件转化成 .class 字节码文件。
  • 验证:验证加载进来的 class 文件是不是符合 JVM 的规范,验证其魔术值
  • 准备:为类成员分配内存,并将其初始化为初始值
  • 解析:把符号引用解析为直接引用
  • 初始化:将我们定义的 static 变量或者 static 静态代码块按顺序组织成 构造器(即类构造器)来初始化变量

操作系统

1.时间片轮转(轮询)

时间片轮转是操作系统的CPU分时共享技术。如果同时有很多个进程在执行,操作系统会将 CPU 的执行时间分成很多份,进程按照某种策略轮流在CPU上运行。由于现代 CPU 的计算能力非常强大,虽然每个进程都只被执行了很短一个时间,但是在外部看来却好像是所有的进程都在同时执行,每个进程似乎都独占一个 CPU 执行。

所以虽然从外部看起来,多个进程在同时运行,但是在实际物理上,进程并不总是在 CPU 上运行的,一方面进程共享CPU,所以需要等待CPU运行,另一方面,进程在执行 I/O 操作的时候,也不需要 CPU 运行。

2.进程在生命周期中,主要有三种状态,运行、就绪、阻塞

运行:当一个进程在CPU上运行时,则称该进程处于运行状态。处于运行状态的进程的数目小于等于 CPU的数目。

就绪:当一个进程获得了除CPU以外的一切所需资源,只要得到CPU即可运行,则称此进程处于就绪状态,就绪状态有时候也被称为等待运行状态。

阻塞:也称为等待或睡眠状态,当一个进程正在等待某一事件发生(例如等待 I/O 完成,等待锁……)而暂时停止运行,这时即使把CPU分配给进程也无法运行,故称该进程处于阻塞状态。

3.我们的应用程序是多线程的吗?

我们开发的应用程序通常以一个进程的方式在操作系统中启动,然后在进程中创 建很多线程,每个线程处理一个用户请求。

以 Java 的 web 开发为例,似乎我们编程的时候通常并不需要自己创建和启动线程,那么我们的程序是如何被多线程并发执行,同时处理多个用户请求的呢?实际中,启动多线程,为每个用户请求分配一个处理线程的工作是在web容器中完成的,比如常用的 Tomcat 容器。

很多 web 开发者容易忽略,那就是不管你是否有意识,你开发的web程序都是被多线程执行的,web 开发天然就是多线程开发。

4.既然是多线程的,那么如何解决线程安全问题?

加锁

5.加锁会有线程阻塞,系统会变慢,甚至不可服务。如何解决?

锁会引起线程阻塞,如果有很多线程同时在运行,那么就会出现线程排队等待锁的情况,线程无法并行执行,系统响应速度就会变慢。此外I/O操作也会引起阻塞,对数据库连接的获取也可能会引起阻塞。目前典型的 web 应用都是基于 RDBMS 关系数据库的,web应用要想访问数据库,必须获得数据库连接,而受数据库资源限制,每个web应用能建立的数据库的连接是有限的,如果并发线程数超过了连接数,那么就会有部分线程无法获得连接而进入阻塞,等待其他线程释放连接后才能访问数据库,并发的线程数越多,等待连接的时间也越多,从 web 请求者角度看,响应时间变长,系统变慢。

被阻塞的线程越多,占据的系统资源也越多,这些被阻塞的线程既不能继续执行,也不能释放当前已经占据的资源,在系统中一边等待一边消耗资源,如果阻塞的线程数超过了某个系统资源的极限,就会导致系统宕机,应用崩溃。

解决系统因高并发而导致的响应变慢、应用崩溃的主要手段是使用分布式系统架构,用更多的服务器构成一个集群,以便共同处理用户的并发请求,保证每台服务器的并发负载不会太高。此外必要时还需要在请求入口处进行限流,减小系统的并发请求数;在应用内进行业务降级,减小线程的资源消耗。

集合

arraylist linkedlist vector区别?

hashmap hashtable区别?

线程是否安全: HashMap 是非线程安全的,HashTable 是线程安全的;HashTable 内部的方法基本都经过synchronized 修饰。(如果你要保证线程安全的话就使用 ConcurrentHashMap 吧!);

效率: 因为线程安全的问题,HashMap 要比 HashTable 效率高一点。另外,HashTable 基本被淘汰,不要在代码中使用它;

对Null key 和Null value的支持: HashMap 中,null 可以作为键,这样的键只有一个,可以有一个或多个键所对应的值为 null。。但是在 HashTable 中 put 进的键值只要有一个 null,直接抛出 NullPointerException。

初始容量大小和每次扩充容量大小的不同 : ①创建时如果不指定容量初始值,Hashtable 默认的初始大小为11,之后每次扩充,容量变为原来的2n+1。HashMap 默认的初始化大小为16。之后每次扩充,容量变为原来的2倍。

底层数据结构: JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间。Hashtable 没有这样的机制。


hashmap底层原理?

HashMap 的初始容量为16,加载因子是0.75(即超过初始容量的0.75倍即开启自动扩容),扩容增量为原容量的一倍。

HashMap 底层的存储方式为拉链法,即每个元素存储的时候先将key值通过哈希转变成一个整数,然后用这个整数对数组长度取模求余,余数即为索引。如果取余后的索引位置已经被某个结点占据了,那么新节点就挂载在它的下面,形成一个节点链表。查找的时候先进入链表,然后遍历链表,依次比较key值,然后返回和该key值相等的value。

如果不同的输入得到了同一个哈希值,就发生了"哈希碰撞"。哈希碰撞后链表长度增加,获取value值的速度就会变慢。那么怎么解决呢?

使用重哈希,即扩容底层数组,使其长度增加,这样所有元素对新长度取模,元素的排列将再次变得松散起来。扩容使用 resize() 方法实现,需要注意的是,扩容操作同样需要把 oldTable 的所有键值对重新插入 newTable 中,因此这一步是很费时的。

HashMap 允许插入键为 null 的键值对。但是因为无法调用 null 的 hashCode() 方法,也就无法确定该键值对的桶下标,只能通过强制指定一个桶下标来存放。HashMap 使用第 0 个桶存放键为 null 的键值对。

从 JDK 1.8 开始,一个桶存储的链表长度大于等于 8 时会将链表转换为红黑树。这样查找的平均次数由链表的长度变成了树的高度,查找的时间复杂度由O(n) 变成了 O(log(n))。

为什么 hashMap 要引入红黑树呢? 因为红黑数不需要那么平衡,所以恢复平衡状态要做的操作少,插入2次旋转内,删除3次以内。

请你讲讲数组(Array)和列表(ArrayList)的区别?什么时候应该使用Array而不是ArrayList?

数组(Array):

double[] array = {1.2,1.6,1.9};
for(double e : array) {
    System.out.println(e);
}

列表(ArrayList):

public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable

根据源码: RandomAccess(随机访问),根据所继承的 RandomAccess 接口可知 ArrayList 继承了数组的随机访问特点。

private static final int DEFAULT_CAPACITY = 10;

根据源码:Default initial capacity(默认初始容量)可知初始容量大小为10。

private void grow(int minCapacity) {
    // overflow-conscious code
    int oldCapacity = elementData.length;
    int newCapacity = oldCapacity + (oldCapacity >> 1);
    if (newCapacity - minCapacity < 0)
    newCapacity = minCapacity;
    if (newCapacity - MAX_ARRAY_SIZE > 0)
    newCapacity = hugeCapacity(minCapacity);
    // minCapacity is usually close to size, so this is a win:
    elementData = Arrays.copyOf(elementData, newCapacity);
}

根据源码:这是一个动态扩容函数:

int newCapacity = oldCapacity + (oldCapacity >> 1); 表明新容量是旧容量的1.5倍。

elementData = Arrays.copyOf(elementData, newCapacity); 扩容操作调用了 Arrays.copyOf() 把原数组整个复制到新数组中,这个操作代价很高,因此最好在创建 ArrayList 对象时就指定大概的容量大小,减少扩容操作的次数。

总结:ArrayList 是最常用的 List 实现类,内部是通过数组实现的,它允许对元素进行快速随机访问。数组的缺点是每个元素之间不能有间隔,当数组大小不满足时需要增加存储能力,就要将已经有数组的数据复制到新的存储空间中。当从 ArrayList 的中间位置插入或者删除元素时,需要对数组进行复制、移动、代价比较高。因此,它适合随机查找和遍历,不适合插入和删除。

因此当确定数组大小时用 Array,不确定数组大小时用 ArrayList。


CopyOnWriteArrayList

读写分离ArrayList:

写操作在复制的数组上进行,读操作在原始数组上进行,读写分离,互不影响。

阅读源码时会发现,CopyOnWriteArrayList 执行添加删除操作时会加锁,防止多线程环境下的写入冲突和并发写入时数据丢失。

public boolean add(E e) {
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        Object[] elements = getArray();
        int len = elements.length;
        Object[] newElements = Arrays.copyOf(elements, len + 1);
        newElements[len] = e;
        setArray(newElements);
        return true;
    } finally {
        lock.unlock();
    }
}

写操作之后将指向原始数组内存地址的指针指向新的复制地址的内存地址。

因此CopyOnWriteArrayList 是怎么解决线程安全问题的?答案就是 写时复制,并加锁。而在遍历的时候用到是旧集合。

适用场景

CopyOnWriteArrayList 在写操作的同时允许读操作,大大提高了读操作的性能,因此很适合读多写少的应用场景。

但是 CopyOnWriteArrayList 有其缺陷:

内存占用:在写操作时需要复制一个新的数组,使得内存占用为原来的两倍左右;

数据不一致:读操作不能读取实时性的数据,因为部分写操作的数据还未同步到读数组中。

所以 CopyOnWriteArrayList 不适合内存敏感以及对实时性要求很高的场景

简单说一说Java中的CopyOnWriteArrayList


LinkedList

基于双向链表实现,既然基于链表,那么肯定不支持随机访问。且增删比改查快。

由于 LinkedList 内部是一个双向链表,因此每添加一个元素,就需要在内存中分配一个空间并生成一个节点,该节点存储了新添加的元素,同时还要生成该节点与链表之间的双向连接。


ArrayList 和 LinkedList 的区别?

ArrayList 基于数组,因此在开头插入最耗时,因为此时整个数组都需要往后移动一位。 LinkedList 基于链表,因此在中间插入最耗时,因为是双向链表,从两端往中间进行查找,因此越靠近中间,插入越耗时。


ConcurrentHashMap?

用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。整个看起来就像是优化过且线程安全的 HashMap


Hashtable?

使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。


有序集合,无序集合

有序集合:集合里的元素可以根据key或index访问

无序集合:集合里的元素只能遍历。

所以,凡是实现set的AbstractSet, CopyOnWriteArraySet, EnumSet, HashSet, JobStateReasons, LinkedHashSet, TreeSet 都是无序的

凡是实现List的 AbstractList, AbstractSequentialList, ArrayList, AttributeList, CopyOnWriteArrayList, LinkedList, RoleList, RoleUnresolvedList, Stack, Vector 都是有序的

并发

多线程和多进程的区别?

进程是资源分配的基本单位,线程是程序执行时的最小单位。

一个进程可以包含多个线程,多个线程共享进程的堆和方法区资源。但是每个线程有自己的程序计数器和栈区域。

线程的程序计数器的作用?

线程的程序计数器是一块内存区域,用来记录线程当前要执行的指令地址。因为CPU的资源调度方式是时间片轮转。因此如果在CPU分配给当前线程的时间片内该线程依然没有执行完,那么当前线程就需要让出CPU,待再次分配到CPU时间片时,从程序计数器中取出之前指令地址继续执行。

进程和线程的示意图

谈谈对线程安全的理解

“线程安全” 其实不是特指线程的安全,而是指内存中共享资源的安全。当多个线程修改共享资源,就会产生线程安全问题。

每个进程的内存空间都有一块公共的区域,通常叫做堆内存。进程内所有线程都可以访问该区域,这就造成了内存中数据不安全。

那我们该怎么办呢?

  • 使用局部变量(即将变量定义在方法内)。这个原理其实是将数据放入线程的栈内存中,栈内存其他线程无权访问。
  • 使用 ThreadLocal 类,ThradLocal 类可以把数据复制N份,每个线程各领一份,各玩各的,互不影响。
  • 变量加 final 关键字,使其变成只读。
  • (悲观锁解决方案)加互斥锁,每次操作前必须获取锁,操作完成后释放锁。
  • (乐观锁解决方案)在并发很小的情况下,数据被修改的概率很小,但又存在这种可能,这时用 CAS。就是在线程退出操作的时候,记录下当前数据的状态。当线程再次进入操作的时候,把之前保存的那一份数据状态与现在的数据状态进行对比,如果数据没有被改动,那么就继续操作,改动过就重新处理。(当然需要考虑 ABA 问题,这个问题也好解决,加一个版本号就行了,规定只要修改了数据,版本号就需要加1。那么就是需要对照保存的数据和版本号,都一致才说明没有被修改)。但这种方案只适用于并发量不高的情况。

乐观锁持乐观态度,就是假设我的数据不会被修改,如果被修改了,大不了从头再来。

悲观锁持悲观态度,就是我认定我的数据一定会被修改,所以我直接加锁。

volatile 和 synchronized 关键字的区别?

当变量被声明为 volatile 时,线程在写入变量时不会把值缓存,而是直接把值刷新到主内存。那么其他线程读取该变量时,会从主内存中获取,而不是从当前工作内存中读取。保证了可见性,但不保证操作的原子性。

线程进入 synchronized 代码块之后会自动获取监视器锁,这时其他线程访问该代码块会被阻塞挂起。在进入 synchronized 代码块的时候,变量会从线程的工作内存中清除,这样当 synchronized 块中要使用该变量只能到主内存中读取。在离开 synchronized 代码块的时候会讲变量的值刷新回主内存。synchronized 实现了线程的安全性,即内存可见性和原子性。

volatile关键字的作用:

  • 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的;

  • 能禁止指令重排序,保证代码的有序性;


volatile关键词在双重校验单例下的作用,具体是解决了什么问题?

volatile关键字的另外一个作用就是防止指令重排序。代码在实际执行过程中,并不全是按照编写的顺序进行执行的,在保证单线程执行结果不变的情况下,编译器或者CPU可能会对指令进行重排序,以提高程序的执行效率。但是在多线程的情况下,指令重排序可能会造成一些问题,最常见的就是双重校验锁单例模式:

public class SingletonSafe {
    private static volatile SingletonSafe singleton;

    private SingletonSafe() {
    }

    public static SingletonSafe getSingleton() {
        if (singleton == null) {
            synchronized (SingletonSafe.class) {
                if (singleton == null) {
                    singleton = new SingletonSafe();
                }
            }
        }
        return singleton;
    }
}

如果没有使用volatile关键字,则可能会出现其他线程获取了一个未初始化完成的singleton对象。

参考链接:https://juejin.im/post/5d7112f4f265da03d7283cd0


什么是乐观锁和悲观锁?

乐观锁持乐观态度,就是坚信自己的数据不会被修改,如果被修改了就重头再来。典型的就是 CAS 操作。CAS 操作会在线程退出时保存一个当前数据的快照,当线程再次进入操作的时候,将当前数据状态与快照对比,没有改动则继续操作,有改动则从头再来。要注意ABA问题,也好解决,加一个版本号。

悲观锁持悲观态度,就是认定我的数据一定会被修改,那么不如一开始就直接加锁。典型的是 synchronized 。

创建线程的三种方式?

  • 继承 Thread 类并重写 run 方法

    这种方法因为继承了 Thread 类,由于 java 不支持多继承,因此这种方法无法再继承其他类。另外由于任务和代码没有分离,因此当多个线程执行一样的任务时,就需要写多份任务代码。且任务没有返回值。

  • 实现 runable 接口的 run 方法

    可以继承其他类,任务和代码也分离了,但任务也没有返回值。

  • 使用 FutureTask 方式,实现 callable 接口

    可以通过 futureTask.get() 方法获取返回值


谈谈 wait() 和 sleep() 的区别?

wait()

当一个线程调用一个共享变量的 wait() 方法时,该调用线程会被阻塞挂起,并且释放获取到的监视器锁。

直到发生下面两种情况才会有返回:

  • 其他线程调用了该共享变量的 notify() 方法或 notifyAll() 方法,用来唤醒正在等待的线程。
  • 其他线程调用了该线程的 interrupt() 方法,该线程抛出 interruptedException 异常。

另外需要注意的是当调用一个共享变量的 wait() 方法时,需要先获取该共享变量的监视器锁。即使用 synchronized 关键字。

sleep()

sleep()会使线程暂时让出指定时间的执行权,也就是在这期间不参与CPU的调度,但不会释放该线程所持有的监视器锁。

不同点:

  • 类的不同:sleep() 来自 Thread,wait() 来自 Object。
  • 释放锁:sleep() 不释放锁;wait() 释放锁。
  • 用法不同:sleep() 时间到会自动恢复;wait() 可以使用 notify()/notifyAll()直接唤醒。

notify() 和 notifyAll() 的区别?

一个线程调用共享变量的 notify() 方法后,会唤醒一个在该共享变量上调用 wait 系列方法后被挂起的线程。一个共享变量上可能有多个被挂起的线程,具体唤醒哪一个由虚拟机决定。

notifyAll() 会唤醒在该共享变量上所有挂起的线程。

要想调用共享变量的 notify() 和 notifyAll() 方法,也需要先获取到该共享变量的监视器锁。


join()

public static void main(String[] args){
    ...
    thread1.join();
    thread2.join();
    System.out.println("all child thread over!");
}

在项目实践中,经常需要等待一个场景中的某几件事全部完成后才能继续向下执行,这时就会用到 join()

主线程首先会在调用thread1.join()后被阻塞,等待thread1执行完毕后,thread2开始阻塞,以此类推,最终会等所有子线程都结束后main函数才会返回。

什么是死锁?

当线程 A 持有独占锁a,并尝试去获取独占锁 b 的同时,线程 B 持有独占锁 b,并尝试获取独占锁 a 的情况下,就会发生 AB 两个线程由于互相持有对方需要的锁,而发生的阻塞现象,我们称为死锁。

如何避免死锁?

所有线程获取资源的顺序保持一致。

守护线程和用户线程的区别?

daemon 线程(守护线程)和 user 线程(用户线程),在 JVM 启动时会调用 main 函数,main 函数所在线程就是一个用户线程,其实于此同时 jvm 内部还启动了很多守护线程,比如垃圾回收线程。

当最后一个用户线程退出,jvm 就会退出,而不管还有几个守护线程正在运行。

设置守护线程方法:

Thread t = new Thread();
t.setDaemon(true);

谈谈对 ThreadLocal 的理解?

ThreadLocal 是 JDK 包提供的,它提供了线程本地变量,也就是说如果你创建了一个 ThreadLocal 变量,那么访问这个变量的每个线程都会有这个变量的一个本地副本。当多个线程操作这个变量,实际操作的是自己本地内存里的变量。

CAS 四个操作数

对象内存地址,对象中的变量的偏移量,变量预期值,新值。

同步和异步的区别?

同步是阻塞模式,异步是非阻塞模式。

同步就是指一个进程在执行某个请求的时候,若该请求需要一段时间才能返回信息,那么这个进程将会一直等待下去,直到收到返回信息才继续执行下去;

异步是指进程不需要一直等下去,而是继续执行下面的操作,不管其他进程的状态。当有消息返回时系统会通知进程进行处理,这样可以提高执行的效率。

同步和异步的区别

互斥锁、条件锁、读写锁以及自旋锁?

如何理解互斥锁、条件锁、读写锁以及自旋锁?


进程,线程间的通信方式?

线程间的通信、同步方式与进程间通信方式


Synchronized,底层是如何实现的?

Volatile底层是如何实现的?

对于线程池的理解?

使用线程池的好处是减少线程在创建和销毁上所用的时间。

https://segmentfault.com/a/1190000015808897

计算机网络

TCP三次握手,四次挥手?

三次握手:

第一次握手:客户端发信,服务端收到了,此时服务端就能明白客户端的发信能力和自己的收信能力没有问题。

第二次握手:服务端发信,客户端收信。此时客户端就会明白自己的发信能力没有问题,而且自己的收信能力也没有问题。并且服务端的发信和收信也没有问题。但服务端不知道自己的发信能力有没有问题。因此需要第三次握手。

第三次握手:客户端发信,服务端收信。服务端能收到回信,说明自己的发信能力没有问题。第三次握手是为了让服务端知道自己的发信和客户端的收信没有问题。

三次握手参看码农翻身


四次挥手

由于TCP连接是全双工的,因此每个方向都必须单独进行关闭。

第一次挥手:TCP客户端发送一个FIN,用来关闭客户到服务器的数据传送。发送FIN表示自己要终止连接了。

第二次挥手:但是关闭连接时,当服务端收到FIN报文时,很可能并不会立即关闭SOCKET,所以只能先回复一个ACK报文,告诉客户端,"你发的FIN报文我收到了"。只有等到我所有的报文都发送完了,我才能发送FIN报文。(注:发送FIN报文表示自己要终止连接了)。这个时候客户端就进入FIN_WAIT状态,继续等待服务端的FIN报文。

第三次挥手:服务器关闭客户端的连接,发送一个FIN给客户端。即当服务端确定数据已发送完成,则向客户端发送FIN报文,"告诉客户端,好了,我这边数据发完了,我也准备关闭连接了"。

第四次挥手:客户端发回ACK报文确认,并将确认序号设置为收到序号加1。发送ACK后客户端进入TIME_WAIT状态。如果服务端没有收到ACK则可以重传。服务端收到ACK后,断开连接了。客户端等待了2MSL后依然没有收到回复,则证明服务端已正常关闭,那好,我客户端也可以关闭连接了。

第一次挥手和第四次挥手由客户端发送给服务端。第二次挥手和第三次挥手由服务端发送给客户端。

参考链接:四次挥手


四次挥手中 TIME_WAIT 状态是在哪里发生的?

客户端,用于等待服务端的回复,如果没有回复,则说明服务端已经正常关闭。


TCP流量控制和拥塞控制?

流量控制

TCP 利用滑动窗口协议来实现流量控制。

为什么要进行流量控制?因为当发送方把数据发送得过快,接收方可能会来不及接收,这就会造成数据的丢失。所谓流量控制就是让发送方的发送速率不要太快,要让接收方来得及接收。

TCP 滑动窗口的单位是字节,即发送方单次发送的字节数不能超过接收方给出的接收窗口的数值。


拥塞控制

拥塞控制:拥塞控制是作用于网络的,它是防止过多的数据注入到网络中,避免出现网络负载过大的情况;常用的方法就是:( 1 )慢开始、拥塞避免( 2 )快重传、快恢复。


https 为什么比 http 安全?

(1) 所有信息都是加密传播,第三方无法窃听。

(2) 具有校验机制,一旦被篡改,通信双方会立刻发现。

(3) 配备身份证书,防止身份被冒充。

HTTP 有以下安全性问题:

使用明文进行通信,内容可能会被窃听;

不验证通信方的身份,通信方的身份有可能遭遇伪装;

无法证明报文的完整性,报文有可能遭篡改。

HTTPS

HTTPS 并不是新协议,而是让 HTTP 先和 SSL(Secure Sockets Layer)通信,再由 SSL 和 TCP 通信,也就是说 HTTPS 使用了隧道进行通信。

通过使用 SSL,HTTPS 具有了加密(防窃听)、认证(防伪装)和完整性保护(防篡改)。


TCP 长连接和短连接的区别?

TCP 本身并没有长短连接的区别,长短与否,完全取决于我们怎么用它。

短连接:每次通信时,创建 Socket;一次通信结束,调用 socket.close()。这就是一般意义上的短连接,短连接的好处是管理起来比较简单,存在的连接都是可用的连接,不需要额外的控制手段。

长连接:每次通信完毕后,不会关闭连接,这样可以做到连接的复用。长连接的好处是省去了创建连接的耗时。

短连接和长连接的优势,分别是对方的劣势。想要图简单,不追求高性能,使用短连接合适,这样我们就不需要操心连接状态的管理;想要追求性能,使用长连接,我们就需要担心各种问题:比如端对端连接的维护,连接的保活。

聊聊 TCP 长连接和心跳那些事

TCP长连接和短连接


数据库

数据库事务的隔离级别,MySQL默认的隔离级别

数据库事务的隔离级别
  • 未提交读
  • 提交读
  • 可重复读
  • 可串行化
MySQL默认的隔离级别:

可重复读

大多数数据库系统的隔离级别:

提交读

参考链接:五分钟搞清楚MySQL事务隔离级别


什么是事务?

事务是指满足 ACID 特性的一组操作,可以通过 commit 提交一个事务,也可以通过 rookback 进行回滚。

ACID 即 原子性,一致性,隔离性,持久性。


什么是事务的原子性?

一个事务内的操作要么全部成功,要么全部失败


什么是事务的隔离性?

一个事务所做的修改在最终提交以前,对其它事务是不可见的。

由于一个事务中间过程可能会进行很多操作,因此在并发环境下,事务的隔离性很难保证,会出现很多并发一致性问题。如丢失修改,读脏数据,不可重复读,幻影读等问题。

数据库管理系统通过提供事务的隔离级别来处理并发一致性问题。

参考链接:并发一致性问题


Mysql的行锁和表锁( 锁是计算机协调多个进程或纯线程并发访问某一资源的机制)

表级锁: 每次操作锁住整张表。开销小,加锁快;不会出现死锁;锁定粒度大,发生锁冲突的概率最高,并发度最低;

行级锁: 每次操作锁住一行数据。开销大,加锁慢;会出现死锁;锁定粒度最小,发生锁冲突的概率最低,并发度也最高;

Hash表的时间复杂度为什么是O(1)?

因为有Hash冲突的存在,所以“Hash 表的时间复杂度为什么O(1)?”这句话并不严 谨,极端情况下,如果所有Key的数组下标都冲突,那么Hash表就退化为一条链表,查询的时间复杂度是O(N)。但是作为一个面试题,“Hash表的时间复杂度为什么是O(1)”是没有问题的。

关于 hash 底层原理,详见 集合.md 中的 hashmap 底层原理分析。

数据库为什么使用B+树进行索引?

innodb的恢复方式?

覆盖索引?最左前缀原则?

Redis 的恢复策略你用哪个?

Redis 底层六大数据结构?

数据库的聚集索引和非聚集索引?

MySQL一条语句的执行过程,解析过程?查询缓存?怎么判断是否命中?

存储引擎myisam和innodb的区别?

b+树,以及联合索引实现原理

为什么PrepareStatement性能更好更安全?

在 Java 程序中访问数据库的时候,有两种提交 SQL 语句的方式,一种是通过 Statement 直接提交 SQL;另一种是先通过 PrepareStatement 预编译 SQL,然后设置可变参数再提交执行。

Statement 直接提交 SQL:

statement.executeUpdata("update users set userName = 'williams' where userId = 101");

PrepareStatement 预编译提交 SQL:

PreparedStatement updateUser = con.prepareStatement("update users set userName = ? where userId = ?"
updateUser.setInt(1, "williams");
updateUser.setInt(2, 101);
updateUser.executeUpdate();

看代码,似乎第一种方式更加简单,但是编程实践中,主要用第二种。使用 MyBatis 等 ORM 框架时,这些框架内部也是用第二种方式提交 SQL。那为什么要舍简单而求复杂呢?

这样做主要有两个好处。

一个是 PrepareStatement 会预先提交带占位符的 SQL 到数据库进行预处理,提前生成执行计划,当给定占位符参数,真正执行 SQL 的时候,执行引擎可以直接执行,效率更好一点。

另一个好处则更为重要,PrepareStatement 可以防止 SQL 注入攻击。假设我们允许用户通过 App 输入一个名字到数据中心查找用户信息,如果用户输入的字符串是 Frank,那么生成的 SQL 是这样的:

select * from users where username = 'Frank';

但是如果用户输入的是这样一个字符串:

Frank';drop table users;--

那么生成的 SQL 就是这样的:

select * from users where username = 'Frank';drop table users;--';

这条 SQL 提交到数据库以后,会被当做两条 SQL 执行,一条是正常的 select 查询 SQL, 一条是删除 users 表的 SQL。黑客提交一个请求然后 users 表被删除了,系统崩溃了,这就是 SQL 注入攻击。如果用 Statement 提交 SQL 就会出现这种情况。

但如果用 PrepareStatement 则可以避免 SQL 被注入攻击。因为一开始构造 PrepareStatement 的时候就已经提交了查询 SQL,并被数据库预先生成好了执行计划,后面黑客不管提交什么样的字符串,都只能交给这个执行计划去执行,不可能再生成一个新的 SQL 了,也就不会被攻击了。

MySQL 的五大存储引擎

MySQL 的存储引擎采用了插件的形式,每个存储引擎都面向一种特定的数据库应用环境。同时开源的 MySQL 还允许开发人员设置自己的存储引擎,下面是一些常见的存储引擎:

  • InnoDB 存储引擎:它是 MySQL 5.5 版本之后默认的存储引擎,最大的特点是支持事务、行级锁定、外键约束等。 MyISAM 存储引擎:在 MySQL 5.5 版本之前是默认的存储引擎,不支持事务,也不支持外键,最大的特点是速度快,占用资源少。

  • Memory 存储引擎:使用系统内存作为存储介质,以便得到更快的响应速度。不过如果 mysqld 进程崩溃,则会导致所有的数据丢失,因此我们只有当数据是临时的情况下才使用 Memory 存储引擎。

  • NDB 存储引擎:也叫做 NDB Cluster 存储引擎,主要用于 MySQL Cluster 分布式集群环境,类似于 Oracle 的 RAC 集群。

  • Archive 存储引擎:它有很好的压缩机制,用于文件归档,在请求写入时会进行压缩,所以也经常用来做仓库。

需要注意的是,数据库的设计在于表的设计,而在 MySQL 中每个表的设计都可以采用不同的存储引擎,我们可以根据实际的数据处理需要来选择存储引擎,这也是 MySQL 的强大之处。

JVM

JVM 的组成构造?

JVM 主要由类加载器、运行时数据区、执行引擎三个部分组成。 运行时数据区主要包括方法区、堆、Java 栈(java栈中又分虚拟机栈和本地方法栈)、程序计数寄存器。

方法区主要存放从磁盘加载进来的类字节码,而在程序运行过程中创建的类实例则存放在堆里。程序运行的时候,实际上是以线程为单位运行的,当 JVM 进入启动类的 main 方法的时候,就会为应用程序创建一个主线程,main 方法里的代码就会被这个主线程执行,每个 线程有自己的 Java 栈,栈里存放着方法运行期的局部变量。而当前线程执行到哪一行字节码指令,这个信息则被存放在程序计数寄存器。

JVM 的垃圾回收

要想进行垃圾回收,首先一个问题就是如何知道哪些对象是不再被使用的,可以清理。

JVM 通过一种可达性分析算法进行垃圾对象的识别,具体过程是:从线程栈帧中的局部变量,或者是方法区的静态变量出发,将这些变量引用的对象进行标记,然后看这些被标记的 对象是否引用了其他对象,继续进行标记,所有被标记过的对象都是被使用的对象,而那些没有被标记的对象就是可回收的垃圾对象了。所以你可以看出来,可达性分析算法其实是一 个引用标记算法。

进行完标记以后,JVM 就会对垃圾对象占用的内存进行回收,回收主要有三种方法。

第一种方式是清理:将垃圾对象占据的内存清理掉,其实 JVM 并不会真的将这些垃圾内存 进行清理,而是将这些垃圾对象占用的内存空间标记为空闲,记录在一个空闲列表里,当应用程序需要创建新对象的时候,就从空闲列表中找一段空闲内存分配给这个新对象。 但这样做有一个很明显的缺陷,由于垃圾对象是散落在内存空间各处的,所以标记出来的空 闲空间也是不连续的,当应用程序创建一个数组需要申请一段连续的大内存空间时,即使堆 空间中有足够的空闲空间,也无法为应用程序分配内存。

第二种方式是压缩:从堆空间的头部开始,将存活的对象拷贝放在一段连续的内存空间中, 那么其余的空间就是连续的空闲空间。

第三种方法是复制:将堆空间分成两部分,只在其中一部分创建对象,当这个部分空间用完 的时候,将标记过的可用对象复制到另一个空间中。JVM 将这两个空间分别命名为 from 区域和 to 区域。当对象从 from 区域复制到 to 区域后,两个区域交换名称引用,继续在 from 区域创建对象,直到 from 区域满。

下面这系列图可以让你直观地了解 JVM 三种不同的垃圾回收机制。

回收前:

清理:

压缩:

复制:

新生代和老年代

JVM 在具体进行垃圾回收的时候,会进行分代回收。绝大多数的 Java 对象存活时间都非常 短,很多时候就是在一个方法内创建对象,对象引用放在栈中,当方法调用结束,栈帧出栈 的时候,这个对象就失去引用了,成为垃圾。针对这种情况,JVM 将堆空间分成新生代 (young)和老年代(old)两个区域,创建对象的时候,只在新生代创建,当新生代空间 不足的时候,只对新生代进行垃圾回收,这样需要处理的内存空间就比较小,垃圾回收速度 就比较快。

新生代又分为 Eden 区、From 区和 To 区三个区域,每次垃圾回收都是扫描 Eden 区和 From 区,将存活对象复制到 To 区,然后交换 From 区和 To 区的名称引用,下次垃圾回 收的时候继续将存活对象从 From 区复制到 To 区。当一个对象经过几次新生代垃圾回收, 也就是几次从 From 区复制到 To 区以后,依然存活,那么这个对象就会被复制到老年代区域。

当老年代空间已满,也就是无法将新生代中多次复制后依然存活的对象复制进去的时候,就会对新生代和老年代的内存空间进行一次全量垃圾回收,即 Full GC。所以根据应用程序的 对象存活时间,合理设置老年代和新生代的空间比例对 JVM 垃圾回收的性能有很大影响, JVM 设置老年代新生代比例的参数是 -XX:NewRatio。

四种垃圾回收器

垃圾回收器主要是用来管理堆内存的,如果堆内存满了还没有被回收,就会出现 OutOfMemroyError异常。

第一种是 Serial 串行垃圾回收器,这是 JVM 早期的垃圾回收器,只有一个线程执行垃圾 回收。

第二种是 Parallel 并行垃圾回收器,它启动多线程执行垃圾回收。如果 JVM 运行在多核 CPU 上,那么显然并行垃圾回收要比串行垃圾回收效率高。 在串行和并行垃圾回收过程中,当垃圾回收线程工作的时候,必须要停止用户线程的工作, 否则可能会导致对象的引用标记错乱,因此垃圾回收过程也被称为 stop the world,在用 户视角看来,所有的程序都不再执行,整个世界都停止了。

第三种是 CMS 并发垃圾回收器,在垃圾回收的某些阶段,垃圾回收线程和用户线程可以并发 运行,因此对用户线程的影响较小。Web 应用这类对用户响应时间比较敏感的场景,适用 CMS 垃圾回收器。

第四种是 G1 垃圾回收器,它将整个堆空间分成多个子区域,然后在这些子区域上各自独 立进行垃圾回收,在回收过程中垃圾回收线程和用户线程也是并发运行。G1 综合了以前几 种垃圾回收器的优势,适用于各种场景,是未来主要的垃圾回收器。

为什么要了解JVM?

比如遇到 OutOfMemoryError,我们就知道是堆空间不足了,可能是 JVM 分配的内存空 间不足以让程序正常运行,这时候我们需要通过调整 -Xmx 参数增加内存空间。也可能是 程序存在内存泄漏,比如一些对象被放入 List 或者 Map 等容器对象中,虽然这些对象程序 已经不再使用了,但是这些对象依然被容器对象引用,无法进行垃圾回收,导致内存溢出, 这时候可以通过 jmap 命令查看堆中的对象情况,分析是否有内存泄漏。

如果遇到 StackOverflowError,我们就知道是线程栈空间不足,栈空间不足通常是因为方 法调用的层次太多,导致栈帧太多。我们可以先通过栈异常信息观察是否存在错误的递归调 用,因为每次递归都会使嵌套方法调用更深入一层。如果调用是正常的,可以尝试调整 - Xss 参数增加栈空间大小。

如果程序运行卡顿,部分请求响应延迟比较厉害,那么可以通过 jstat 命令查看垃圾回收器 的运行状况,是否存在较长时间的 FullGC,然后调整垃圾回收器的相关参数,使垃圾回收 对程序运行的影响尽可能小。

执行引擎在执行字节码指令的时候,是解释执行的,也就是每个字节码指令都会被解释成一 个底层的 CPU 指令,但是这样的解释执行效率比较差,JVM 对此进行了优化,将频繁执行 的代码编译为底层 CPU 指令存储起来,后面再执行的时候,直接执行编译好的指令,不再 解释执行,这就是 JVM 的即时编译 JIT。Web 应用程序通常是长时间运行的,使用 JIT 会 有很好的优化效果,可以通过 -server 参数打开 JIT 的 C2 编译器进行优化。

高并发

应对高并发大流量的策略:

  • 分流:采用分布式的方式将流量分流,把压力分流到每一个集群的服务器中。
  • 缓存:使用缓存减少对数据库的读写。提升系统的访问性能。
  • 异步:异步处理请求,让没有得出结果的请求先返回,在结果出来后再通知请求方。这样可以在单位时间内处理更多的请求。

在系统设计初期,我们会考虑采用 Scale-up 的方式来应对并发。Scale-up 即购买配置更好的服务器。但是当系统超过单机的极限时,就需要使用 Scale-out 的方式了。Scale-out 即购买更多的普通服务器,组成集群,达到分流的效果。

异步的设计那自然就是相对同步而言的。同步是程序需要一步步执行完才能得出结果,弊端就是如果调用方法响应时间比较长,就会造成调用方阻塞,在高并发下就会造成性能下降甚至是雪崩。而异步不需要等待方法执行完就可以返回执行其他的逻辑。可以在这个方法执行完成后再通过回调,事件通知等方式将结果返回给调用方。

高并发系统追求3个目标:

高性能,高可用,可扩展。

  • 高性能代表了用户体验,毫秒级的响应时间肯定比秒级的体验好。
  • 高可用即系统的稳定性,全年不宕机即意味着高可用。
  • 可扩展即应对突发而来的大流量可以对现有的系统迅速实现扩容,以扛住峰值流量。

性能优化

性能优化也要有数据支撑。在优化过程中,你要时刻了解你的优化让响应时间减少了多少,提升了多少的吞吐量。

一般来说,度量性能的指标是系统接口的响应时间,但是单次的响应时间是没有意义的,你需要知道一段时间的性能情况是什么样的。脱离了并发来谈性能是没有意义的,我们通常使用吞吐量或者同时在线用户数来度量并发和流量。

性能的度量指标我们一般用分位值来表示。 什么是分位值:以90分位为例,我们把这段时间请求的响应时间从小到大排序,假如一共有100个请求,那么排在第90位的响应时间就是90分位值。

从用户使用体验的角度来看,200ms是第一个分界点:接口的响应时间在200ms之内,用户是感觉不到延迟的,就像是瞬时发生的一样。所以,健康系统的99分位值的响应时间通常需要控制在200ms之内。

高并发下的性能优化的方法是:

  • 提高系统的处理核心数
  • 减少单次任务响应时间