一、springboot自动装配原理:

@SpringBootApplication注解,点进去,有三个核心注解

image.png

1.@SpringBootConfiguration
我们点进去通过源码得知他是一个@Configuration,所以也就是对spring原生注解的封装

2.@EnableAutoConfiguration
一旦加上此注解,那么将会开启自动装配功能,容易点讲,Spring会试图在自己的classpath(类路径)下找到所有配置的Bean然后进行装配。装配Bean时,会根据若干个(Conditional)定制规则来进行初始化。源码如下:
image.png

image.png

@import注解里加载了AutoConfigurationImportSelector自动装配类的字节码文件,而AutoConfigurationImportSelector又实现了DeferredImportSelector接口。该接口主要是为了导入@Configuration的配置项,而DeferredImportSelector是延期导入,当所有的@Configuration都处理过后才会执行。

回过头来我们看一下AutoConfigurationImportSelector的自动配备核心方法 selectImport:

  1. //自动装配的方法@Overridepublic String[] selectImports(AnnotationMetadata annotationMetadata) { if (!isEnabled(annotationMetadata)) { //判断是否自动装配 return NO_IMPORTS; } AutoConfigurationEntry autoConfigurationEntry = getAutoConfigurationEntry(annotationMetadata); return StringUtils.toStringArray(autoConfigurationEntry.getConfigurations());}protected AutoConfigurationEntry getAutoConfigurationEntry(AnnotationMetadata annotationMetadata) { if (!isEnabled(annotationMetadata)) { return EMPTY_ENTRY; } //获取所有元数据信息 AnnotationAttributes attributes = getAttributes(annotationMetadata); //获取所有加载bean的条件配置 List<String> configurations = getCandidateConfigurations(annotationMetadata, attributes); //过滤并删除掉重复的bean configurations = removeDuplicates(configurations); Set<String> exclusions = getExclusions(annotationMetadata, attributes); checkExcludedClasses(configurations, exclusions); configurations.removeAll(exclusions); configurations = getConfigurationClassFilter().filter(configurations); fireAutoConfigurationImportEvents(configurations, exclusions); return new AutoConfigurationEntry(configurations, exclusions);}

该方法刚开始会先判断是否进行自动装配,而后会从META-INF/spring-autoconfigure-metadata.properties
读取元数据与元数据的相关属性,紧接着会调用getCandidateConfigurations方法:

  1. protected List<String> getCandidateConfigurations(AnnotationMetadata metadata, AnnotationAttributes attributes) { List<String> configurations = SpringFactoriesLoader.loadFactoryNames(getSpringFactoriesLoaderFactoryClass(), getBeanClassLoader()); Assert.notEmpty(configurations, "No auto configuration classes found in META-INF/spring.factories. If you " + "are using a custom packaging, make sure that file is correct."); return configurations;}

在这里又遇到我们的老熟人了–SpringFactoryiesLoader, 它会读取META-INF/spring.factories下的EnableAutoConfiguration的配置,紧接着在进行排除与过滤,进而得到需要装配的类。最后让所有配置在META-INF/spring.factories下的AutoConfigurationImportListener执行AutoConfigurationImportEvent事件,源码如下

  1. private void fireAutoConfigurationImportEvents(List<String> configurations, Set<String> exclusions) { List<AutoConfigurationImportListener> listeners = getAutoConfigurationImportListeners(); if (!listeners.isEmpty()) { AutoConfigurationImportEvent event = new AutoConfigurationImportEvent(this, configurations, exclusions); for (AutoConfigurationImportListener listener : listeners) { invokeAwareMethods(listener); listener.onAutoConfigurationImportEvent(event); } } } protected List<AutoConfigurationImportListener> getAutoConfigurationImportListeners() { return SpringFactoriesLoader.loadFactories(AutoConfigurationImportListener.class, this.beanClassLoader); }

3.@ComponentScan

默认扫描的是与该类同级的类或者同级包下的所有类,是spring的原生注解之一

总结:自动装配还是利用了SpringFactoriesLoader来加载META-INF/spring.factoires文件里所有配置的

EnableAutoConfgruation,它会经过exclude和filter等操作,最终确定要装配的类。

二、springmvc执行流程

执行流程分析:
(1)浏览器提交请求到中央调度器。
(2)中央调度器直接将请求转给处理器映射器。
(3)处理器映射器会根据请求,找到处理该请求的处理器,并将其封装为处理器执行链后返回给中央调度器。
(4)中央调度器根据处理器执行链中的处理器,找到能够执行该处理器的处理器适配器。
(5)处理器适配器调用执行处理器。
(6)处理器将处理结果及要跳转的视图封装到一个对象 ModelAndView 中,并将其返回给处理器适配器。
(7)处理器适配器直接将结果返回给中央调度器。
(8)中央调度器调用视图解析器,将 ModelAndView 中的视图名称封装为视图对象。
(9)视图解析器将封装了的视图对象返回给中央调度器。
(10)中央调度器调用视图对象,让其自己进行渲染,即进行数据填充,形成响应对象。
(11)中央调度器响应浏览器。
流程图如下:

image.png

三、集合

HashMap

你了解HashMap的底层实现原理吗?

HashMap的底层是Hash表结构,元素的排列是根据哈希算法和哈希函数排序的,且不可重复。

JDK8以前,Hash表的底层是【数组】+【链表】

JDK8及之后,变成了【数组】+【链表】+【红黑树】

存入新键值对时,如果出现哈希冲突,会先判断键是否相同,如果键相同,会比较值,值相同则不放入,值不同则修改原值;如果键不相同,则会以链表形式挂下来,并且1.7版本中是头插法,1.8版本是尾插法。

什么是哈希冲突?

哈希冲突就是两个元素在通过哈希函数后,得到的角标是相同的,在同一个哈希槽中。哈希冲突的四种解决思路分别是:重哈希法,开放地址法,建立公共溢出,链地址法。

HashMap的扩容机制是怎么样的?它什么时候会转化为红黑树?

Hash表中数组的分手手动初始化,和自动初始化,自动初初始会在第一次插入元素时开辟空间,默认长度为16,扩容因子为0.75,每次扩容量为自身的2倍长度,扩容之后存入数组的新索引位置就会改变。手动初始化的话,可以在创建对象时自定义初始数组长度,但HashMap不一定会自主设置的数值初始化数组,而按2的n次方创建。

HashMap1.7版本的的扩容时机是先判断是否达到阈值,达到先扩容,再添加元素,并且采用的是头插法,也就是旧元素挂在新元素下。

而HashMap1.8的扩容时机是先添加元素是否达到阈值,达到直接扩容,且使用的是尾插法,即新元素挂在旧元素下面。

初始化后,当存入新的键值对时,会先判断数组长度是否大于64,再判断链表元素是否大于等于8时,如果两者都成立,链表会自动转换成红黑树,如果数组小于64,会从第9个开始先扩容,直到数组大于等于64时,链表长度再增加,就会转为红黑树。

细节:

在添加第一个元素的时候是直接添加进数组的,而不会进入到红黑树转化的判断的,所以里面的binCount并没有创建。添加第二元素并发生了哈希冲突时,才进入红黑树转化的判断,同时初始化binCount=0,它判断的是binCount>=7,也就是0至7,有8个元素时,再加上没有进行判断的1个元素,即第9个元素时,才会转化为红黑树。

为什么1.7是头插法,1.8是尾插法?

1.7版本使用头插法是因为头插法是操作速度最快的,找到数组位置就直接找到插入位置了,但这样插入方法在并发场景下会因为多个线程同时扩容出现循环列表,也就是Hashmap的死锁问题。

1.8版本加入了红黑树来优化哈希桶中的遍历效率,相比头插法而言,尾插法在操作额外的遍历消耗(指遍历哈希桶)已经小很多,也可以避免之前的循环列表问题,同时如果已经变成红黑树了,也不能再用头插法了,而是按红黑树自己的规则排列了。

如果是头插法,怎么才能获取之前的旧元素呢?

因为1.7版本的头插法,是新元素在上面,旧元素挂新元素后面,所以新元素始终是在数组上的,可以通过在对象上重写toString方法,加上对象的HashCode值,这样只要打印出来相同的HashCode说明发生了哈希冲突,这时候只需要遍历即可,要取哪个就指定那个HashCode,相同就取出,而上一个老元素就是第二个获取的元素。

什么是HashMap双链循环/死锁?

双链循环是JDK1.7及更早的版本之前才有的问题。在多线程扩容的情况下,一个线程执行到一半,还未扩容,而另一个线程却抢走先行扩容了,这时候可能出现第一个线程的元素与第二个线程中的元素相互引用的情况,相互引用就会造成死锁。

比如一个数线长度为4,有两个数,一个为2,一个为10,那么这两个数都会在索引2上形成哈希桶结构,此时进行扩容,本来在新数组中是2指向10的,结果但之前那个前程正好断在10指向新数组的中间,这就会导至10又重新指向2,最终导while判断中的e永远不会等于null,造成死循环。

JDK1.8版本避免了双链循环,但不是完全避免,看过一些测试文章,红黑树之间也可能出现死循环,只是比较1.7版本,几率降低。

为什么1.7是先扩容再添加,1.8却改成先添加再扩容?

因为1.7版本中的扩容机制有两个条件:

1、 存放新值的时候当前已有元素的个数必须大于等于阈值(数组长度*0.75)。

2、 存放新值的时候当前存放数据发生hash碰撞(当前key计算的hash值换算出来的数组下标位置已经存在值)

要满足以上两个条件,很可能出现数组16个元素都填满的情况(正好无碰撞填满数组),如果是先添加再扩容,就会导致第17个元素必然发生哈希冲突,这不是我们要的结果,我们要的是尽量减少哈希冲突,所以需要先扩容,再放入元素。

而在1.8版本中,扩容的条件改成了实际数量大于等于阈值就扩容,所以允许了先添加再扩容这种情况,也可能是作者认为没有1.7那么强制性需要先扩容了,为了更符合思考逻辑,改成了先添加,再扩容。

HashMap为什么数组长度始终是2的n次方?

在HashMap的底层对于数组的操作其实是(n-1)&hash,当数组的长度为2的n次时,减1转为二进制后,他被任何数字&上都不会超过这个数字,比如数组长度为8,减1后为7,那么它的数组长度就是0-7,共8个,即元素可以在这个数组上全部排满,而如果是奇数,或者不是2的n次的偶数,一定会有一个二进制为0,也就是无论另一个数是什么,都不会被存入数组,会浪费掉的位置。

你了解Hashtable吗?它跟HashMap有什么区别?

HashMap是线程不安全的(多线程环境下会出问题);

Hashtable是线程安全的(但效率低下);

Hashtable底层和哈希表一样,扩容因子是0.75,扩容倍率是2倍。

Hashtable一次只能执行一个线程(全表加锁),采取悲观锁(增善改的方法上都加了synchronized)保证了线程安全。

说一说ConcurrentHashMap,JDK7版本跟JDK8版本有什么不同?

ConcurrentHashMap1.7版本:

创建对象

1、默认创建一个长度16,加载因子为0.75的大数组,但这个大数组一但创建无法扩容,所以加载因子是给小数组用的。

2、还会创建一个长度为2的小数组,把地址值赋值给0索引处。其他索引位置的元素均为null。

插入元素

第一次插入新元素时,会根据键的哈希值来计算出在大数组中应存入的位置。

· 如果为null,则按照模板创建小数组,大数组只用来存放地址值。

o 创建完毕,会进行二次哈希,计算出在小数组中应存入的位置。

o 直接存入。

· 如果不为null,就会根据记录的地址值找到小数组。

o 二次哈希,计算出在小数组中应存入的位置。

o 如果需要扩容,则先将小数组扩容2倍。

o 如果不需要扩容,则判断小数组的这个位置有没有元素。

§ 如果没有元素,则直接存。

§ 如果有元素,就会调用equals方法,比较属性值

· 如果equals为true,相同则不存;

· 如果equals为false,新元素替换老元素,老元素挂在新元素下面,形成哈希桶结构(链表)。

线程安全

1、用synchronized同步代码块形式保证线程安全,锁住大数组的一个地址值连同它的小数组。

2、最多同时访问16个线程,因为大数组只有16个哈希槽。

ConcurrentHashMap1.8版本:

区别1.7:

· 底层结构改变:

哈希表(数组会扩容)——【数组】+【链表】+【红黑树】

1.7是旧元素挂新元素下面(旧挂新——头插法);1.8是新元素挂在旧元素下面(新挂旧——尾插法)!

· 线程安全机制改变:结合CAS机制+synchronized同步代码块形式保证线程安全。

如果该索引为null,则利用cas算法,将本结点添加到数组中。(第一次添加用CAS算法)

如果该索引不为null,则利用volatile关键字获得当前位置最新的结点地址,新元素挂在旧元素下面。(因为1.8后有了红黑树,会自动进行排序调整,再调整链表头就浪费资源了,而旧版本需要后进先出)

(红黑树转化条件见HashMap底层)

有元素后,再对该元素进行操作时,会给头结点(第一个元素)做为锁对象,加上synchronized同步代码块(锁对象的方式),保证线程安全。

· 加载机制改变:懒加载——第一次添加元素时初始化数组。(添加元素时,判断数组是否为空,或者长度为0,如果是,就初始化数组)

List集合是线程不安全的,你是怎么使用List集合的呢?

使用Collections集合工具类,对集合进行同步处理:

List list = Collections.synchronizedList(new ArrayList<>());

但是在多线程开发中,对其进行遍历,需要添加 synchronized 关键字,因为List的 add、index 等方法中都是带有synchronized 关键字,但是在 iterator 中没有synchronized 关键字。

你了解ArrayList和LinkedList的底层原理吗?

首先,List集合是有序集合,即存取有序,List集合的特点是存取顺序一致,存储元素可重复,都有索引。

ArrayList的底层是数组,一个索引对应一个元素,所以查询速度快;但是在增删时,需要调整整组数据的移动,所以增删较慢。

而LinkedList的底层是双向链表,每次查询时都要从两头开始查询(离头近就从头查,离尾近就从尾查),所以查询较慢;但是增删时,只需要将链表头结点和尾结点指向新插入的结点即可,所以增删速度较快。

但如果是新增的数据量较大的情况下,ArrayList的新增效率反面比LinkedList的效率更高。因为ArrayListr底层数组的扩容是1.5倍,数据量越大,扩容的速度就越快,而链表仍需一个个断开链接和重续新链接。

最后,jdk8版还对ArrayList做了懒加载优化,在之前是构造ArrayList时就默认开辟10个空间,jdk8之后变成了只有放入第1个元素时,才会开辟10个空间。

四、多线程、高并发、锁

高并发

并发编程的三要素是什么(线程的安全性问题体现在哪)?

原子性:一个或多个操作要么全部执行成功,要么全部执行失败。

可见性:一个线程对共享变量的修改,另一个线程能够立刻看到(synchronized,volatile)。

有序性:程序执行的顺序按照代码的先后顺序执行。(有序性不代表禁止指令重排)。

什么是JAVA内存模型?

首先,JAVA内存模型是指JMM,而不是指内存结构,内存结构是在物理上的区域划分,而JMM则是抽象概念上的划分。

JMM(内存模型)主要包括两块:主内存+工作内存

主内存:多个线程间通信的共享内存称之为主内存,即,数据是多个线程工共享的,在物理内存结构上通常对应“堆”中的线程共享数据。

工作内存:多个线程各自对应自己的本地内存,即,数据只属于该线程自己的,在物理内存结构上通常对应“本地方法栈”中的线程私有数据。

Java内存模型规定了所有的变量都存储在主内存(Main Memory)中,每条线程还有自己的工作内存(Working Memory),线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量,不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值得传递均需要通过主内存来实现。

volatile 关键字的作用

Java 提供了 volatile 关键字来保证可见性禁止指令重排(一定有序)。 volatile 提供 happens-before 的保证,确保一个线程的修改能对其他线程是可见的。当一个共享变量被 volatile 修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。

Volatile是怎么保证可见性的?

对volatile修饰的变量,执行写操作的话,JVM会发送一条lock前缀指令给CPU,CPU在计算完之后会立即将这个值写回主内存,同时因为有MESI缓存一致性协议,所以各个CPU都会对总线进行嗅探,自己本地缓存中的数据是否被别人修改。

如果发现别人修改了某个缓存的数据,那么CPU就会将自己本地缓存的数据过期,然后这个CPU上执行的线程在读取那个变量的时候,就会从主内存重新加载最新的数据。

小结:lock前缀指令 + MESI缓存一致性协议。

Volatile能保证强一致性吗?

不能。可见性可以认为是最弱的“一致性”(弱一致),只保证用户见到的数据是一致的,但不保证任意时刻,存储的数据都是一致的(强一致)。它只能保证线程过来读取数据时,能获取到当前的最新数据。

什么是MESI缓存一致性协议?

M(修改, Modified): 本地处理器已经修改缓存行, 即是脏行, 它的内容与内存中的内容不一样. 并且此cache只有本地一个拷贝(专有)。

E(专有, Exclusive): 缓存行内容和内存中的一样, 而且其它处理器都没有这行数据。

S(共享, Shared): 缓存行内容和内存中的一样, 有可能其它处理器也存在此缓存行的拷贝。

I(无效, Invalid): 缓存行失效, 不能使用。

过程:

Core0修改v后,发送一个信号,将Core1缓存的v标记为失效,并将修改值写回内存。

Core0可能会多次修改v,每次修改都只发送一个信号(发信号时会锁住缓存间的总线),Core1缓存的v保持着失效标记。

Core1使用v前,发现缓存中的v已经失效了,得知v已经被修改了,于是重新从其他缓存或内存中加载v。

Volatile是怎么做到禁止指令重排的?

对于volatile修改变量的读写操作,都会加入内存屏障。

每个volatile写操作前面,加StoreStore屏障,禁止上面的普通写和他重排;每个volatile写操作后面,加StoreLoad屏障,禁止跟下面的volatile读/写重排。

每个volatile读操作后面,加LoadLoad屏障,禁止下面的普通读和voaltile读重排;每个volatile读操作后面,加LoadStore屏障,禁止下面的普通写和volatile读重排。

synchronized为什么又叫内置锁?**

synchronized有多个叫法:内置锁、隐式锁、同步锁、对象锁等。

synchronized是内置于JDK中的,底层实现是native,由C/C++语言实现;同时,加锁、解锁都是JDK自动完成,不需要用户显示控制,非常方便。

Native关键字的作用是什么?

使用native关键字说明这个方法是原生函数,也就是这个方法是用C/C++语言实现的,并且被编译成了DLL,由java去调用。
这些函数的实现体在DLL中,JDK的源代码中并不包含,你应该是看不到的。对于不同的平台它们也是不同的。这也是java的底层机制,实际上java就是在不同的平台上调用不同的native方法实现对操作系统的访问的。
java是跨平台的语言,既然是跨了平台,所付出的代价就是牺牲一些对底层的控制,而java要实现对底层的控制,就需要一些其他语言的帮助,这个就是native的作用了。

synchronized 和 volatile 的区别是什么?

synchronized 表示只有一个线程可以获取作用对象的锁,执行代码,阻塞其他线程。

volatile 表示变量在 CPU 的寄存器中是不确定的,必须从主存中读取。保证多线程环境下变量的可见性;禁止指令重排序。

区别:

volatile 是变量修饰符;synchronized 可以修饰类、方法、变量。

volatile 仅能实现变量的修改可见性,不能保证原子性;而 synchronized 则可以保证变量的修改可见性和原子性。

volatile 不会造成线程的阻塞;synchronized 可能会造成线程的阻塞。

volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化。

同步方法和同步块,哪个是更好的选择?

同步块。

一条原则:同步范围越小越好。

Synchronized和监视器(monitor)有什么关系?为什么Synchronized可以使用任意对象?

首先,每个对象都可以被认为是一个“监视器monitor”,这个监视器由三部分组成:独占锁、入口队列,等待队列。

注意:一个对象只能有一个独占锁,但是任意线程都可以拥有这个独占锁(说白了,锁占锁就是一个标记)。

Synchronized需要获取对象锁,实际上就是获取的是对象中的独占锁,通过这个标记来判断是否已有线程进入占用(所以synchronized无论使用什么对象都可以,每个对象在堆中都有独占锁)。

而入口队列中放的则是要竞争锁资源的其他线程,如果线程使用了wait方法,则进入对象的等待列队中。

Synchronized的作用是什么?

synchronized 关键字是用来控制线程同步的,就是在多线程的环境下,控制 synchronized 代码段不被多个线程同时执行,即保证线程的串行化。

synchronized 可以保证可见性、原子性、有序性三大特性。

Syncrhronized怎么保证可见性?

JMM中使用happens-before语义(即遵循happens-before关系,由JMM定义的规则):

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

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

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

  1. 通过以上两点,可以看到synchronized能够实现可见性。

Synchronized怎么保证原子性?

JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。通过该标志,表明线程进入该方法时,需要monitorenter,退出该方法时需要monitorexit。

在执行同步代码块之前之后都有一个monitor字样,其中前面的是monitorenter,后面的是离开monitorexit,不难想象一个线程也执行同步代码块,首先要获取锁,而获取锁的过程就是monitorenter ,在执行完代码块之后,要释放锁,释放锁就是执行monitorexit指令。

为什么会有两个monitorexit呢?

这个主要是防止在同步代码块中线程因异常退出,而锁没有得到释放,这必然会造成死锁(等待的线程永远获取不到锁)。因此最后一个monitorexit是保证在异常情况下,锁也可以得到释放,避免死锁,它由编译器自动产生的一个异常处理器来执行。

synhronized可重入的原理

重入锁是指一个线程获取到该锁之后,该线程可以继续获得该锁,且不再被阻塞。底层原理维护一个计数器,当线程获取该锁时,计数器加一,再次获得该锁时继续加一,释放锁时,计数器减一,当计数器值为0时,表明该锁未被任何线程所持有,其它线程可以竞争获取锁。

Synchronized怎么保证有序性?

首先,Synchronized保证有序性,但不表示他能禁止指令重排。

有序性是指程序间的依赖顺序和代码顺序一致。

而之所以会有序性问题,是因为硬件层面做了很多优化,比如处理器做强化和指令重排等,这些技术引入会导致有序性问题。

这有序性问题主要出在多线程中,因为单线程中是遵循JMM的as-if-serial语义的,能保证数据间的依赖关系的,比如A依赖于B,B依赖于C,那A的实现之前,必须会先执行C。

as-if-serial语义的意思是:不管怎么重排序,单线程程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。

但是多线程就可能因为指令重排导致在另一个线程中先执行到了C,多线程程序的语义就被重排序破坏了!

Synchronized同步代码块可以锁住当前线程,这样每个线程单独执行,就可以保证有序性了。

Synchronized中的锁中什么是重量锁(对象锁),自旋锁,自适应自旋锁,轻量锁,偏向锁,锁消除,锁粗化?

自旋锁:

线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。同时我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的,所以引入自旋锁。

就是等待锁的线程并不进入阻塞状态,而是执行一个无意义的循环。在循环结束后查看锁是否已经被释放,若已经释放则直接进入执行状态。因为长时间无意义循环也会大量浪费系统资源,因此自旋锁适用于间隔时间短的加锁场景。

自适应自旋锁:

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

偏向锁:偏向于第一个获得它的线程。当线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程 ID,以后该线程在进入和退出同步块时不需要花进行CAS加锁和解锁操作。

适用于只有1个线程的情况。无法代替重量锁。

轻量锁:如果有第二线程过来竞争,则从偏向锁升级为轻量锁,线程尝试使用 CAS 将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败。

适用于只有2个线程情况。无法代替重量锁。

重量锁:当有3个及以上的线程竞争时,升级为重量锁,获得锁的执行,没获得锁的阻塞挂起,直到持有锁的线程执行完同步块唤醒它们。

重量级锁通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。

锁消除:JVM检测到不可能存在共享数据竞争,这时JVM会对这些同步锁进行锁消除。比如一个方法中使用变量是属于自己方法中的,那么这个变量是只属于该线程自己的,其他线程抢不走,这时候这个方法中的变量就没必要加锁了。

锁消除的依据是逃逸分析(底层判断该数据是否有被全局引用或者程序指向无法被访问到的地方等)的数据支持。

锁粗化:锁粗化就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。如上面实例:vector每次add的时候都需要加锁操作,JVM检测到对同一个对象(vector)连续加锁、解锁操作,会合并一个更大范围的加锁、解锁操作,即加锁解锁操作会移到for循环之外。

多线程中 synchronized 锁升级的原理是什么?

偏向所锁,轻量级锁都是乐观锁,重量级锁是悲观锁。

一个对象刚开始实例化的时候,没有任何线程来访问它的时候。它是可偏向的,意味着,它现在认为只可能有一个线程来访问它,所以当第一个线程来访问它的时候,它会偏向这个线程,此时,对象持有偏向锁。偏向第一个线程,这个线程在修改对象头成为偏向锁的时候使用CAS操作,并将对象头中的ThreadID改成自己的ID,之后再次访问这个对象时,只需要对比ID,不需要再使用CAS在进行操作。

一旦有第二个线程访问这个对象,因为偏向锁不会主动释放,所以第二个线程可以看到对象时偏向状态,这时表明在这个对象上已经存在竞争了,检查原来持有该对象锁的线程是否依然存活,如果挂了,则可以将对象变为无锁状态,然后重新偏向新的线程,如果原来的线程依然存活,则马上执行那个线程的操作栈,检查该对象的使用情况,如果仍然需要持有偏向锁,则偏向锁升级为轻量级锁,(偏向锁就是这个时候升级为轻量级锁的)。如果不存在使用了,则可以将对象回复成无锁状态,然后重新偏向。

轻量级锁认为竞争存在,但是竞争的程度很轻,一般两个线程对于同一个锁的操作都会错开,或者说稍微等待一下(自旋),另一个线程就会释放锁。 但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程以外的线程都阻塞,防止CPU空转。

锁会自动升级,那会降级吗?

Synchronized锁只会自动升级,不会降级(ReentrantReadWriteLock读写锁可以降级)。

什么是CAS自旋?

CAS: Compare And Swap(比较并转换),即执行一半,发现已被其他线程抢先修改数据,该线程则重新获取最新内存值的过程。

自旋涉及三个值:新值、旧值、内存值(内存位置),线程会先获取内存值,然后复制到变量副本,生成旧值,旧值在一系列操作后生成新值。

若旧值等于内存值,说明没有被线程B抢先执行赋值,则修改内存值为新值;

若旧值不等于内存值,说明内存值已被其它线程修改,则自旋(获取新的内存,再重新操作)。

自旋会存在什么问题?

1、ABA问题:也就是第一个线程刚获得A,就被第二个线程抢走也获得A,并且改成B后又改回A,这时候第一个线程再执行,发现是它要的A就继续执行,这就会有潜藏的问题,比如修改的是金额存一笔跟存两笔就是两个概念了。

2、循环时间开销大:如果资源竞争激烈,CAS自旋概率较大,反而浪费更多CPU,导致效率比Synchronized更低。

3、只能保证一个共享变量的原子操作:CAS对多个共享变量操作的时候,无法保证原子性,只能用锁。

什么是死锁?

A需要B解锁,B需要A解锁,两个都在中间互相等待,却谁也无法满足条件,从而发生阻塞,就是死锁。

怎么防止死锁?

1、不要写嵌套锁,容易死锁;

2、尽量少用同步代码块(Synchronized);

3、尽量使用ReentrantLock的tryLock方法设置超时时间,超时可以退出,防止死锁;

4、尽量降低锁粒度,尽量不要几个功能一把锁;

5、尽量使用JUC包;

synchronized 和 ReentrantLock 区别是什么?

synchronized 是和 if、else、for、while 一样的关键字;

ReentrantLock 是类,这是二者的本质区别。

synchronized 早期的实现比较低效,对比 ReentrantLock,大多数场景性能都相差较大,但是在 Java 6 中对 synchronized 进行了非常多的改进。

相同点:两者都是可重入锁

主要区别如下:

ReentrantLock 必须手动获取与释放锁,而 synchronized 不需要手动释放和开启锁;

ReentrantLock 只适用于代码块锁,而 synchronized 可以修饰类、方法、变量等。

二者的锁机制其实也是不一样的。ReentrantLock 底层调用的是 Unsafe 的park 方法加锁,synchronized 操作的应该是对象头中 mark word。

Java中每一个对象都可以作为锁,这是synchronized实现同步的基础:

普通同步方法,锁是当前实例对象;静态同步方法,锁是当前类的class对象;同步方法块,锁是括号里面的对象。

Lock锁是公平锁还是非公平锁?

看情况。使用ReentrantLock锁时,可以通过构造方法确定使用公平锁还是非公平锁。

ReentrantLock默认使用的是非公平锁,减少一定的上下文切换,保证系统更大的吞吐量。

乐观锁是公平锁还是非公平锁?

乐观锁对应的是悲观锁,和是否是公平锁没有必然联系。

死锁与活锁的区别?

活锁和死锁的区别在于,处于活锁的实体是在不断的改变状态,这就是所谓的“活”, 而处于死锁的实体表现为等待;活锁有可能自行解开,死锁则不能。

你了解AQS机制吗?它的核心原理是什么?

AQS(Abstract Queued Synchronizer:抽象队列同步)是一个抽象类,它提供了一个双向队列,可以看成是一个用来实现同步锁及其他涉及到同步功能的核心组件,比如ReentrantLock,ReentrantReadWriteLock,FutureTask等等皆是基于AQS的。

AQS的核心原理是,如果资源空间,就设请求线程为有效的工作线程,并锁定该线程;

如果资源已被占用,AQS就把当前线程以及等待状态信息构造成一个Node加入到同步队列中,同时再阻塞该线程。

当获取锁的线程释放锁以后,会从队列中唤醒一个阻塞的节点(线程)。

AQS定义了两种资源共享方式:

1、独占,只有一个线程能执行;ReentrantLock

2、共享,多个线程可同时执行:Semphore、CountDownLatch

什么是可重入锁(ReentrantLock)?

ReentrantLock重入锁,是实现Lock接口的一个类,也是在实际编程中使用频率很高的一个锁,支持重入性,表示能够对共享资源能够重复加锁,即当前线程获取该锁再次获取不会被阻塞。

Synchronized隐式支持重入性,具体见之前的回答。

ReentrantLock怎么实现公平锁和非公平锁?

ReentrantLock支持公平锁和非公平锁两种方式,通过构造方法来决定使用哪个锁方式。

什么是公平锁,什么是非公平锁?

公平锁,是针对获取锁而言的,如果一个锁是公平的,那么锁的获取顺序就应该符合请求上的绝对时间顺序,满足FIFO。

非公平锁,也是针对获取锁而言的,当多个线程竞争同一个资源时,可能会同一个线程多次抢到资源,而不是按顺序由下个线程获取。

从本质来说,底层都是AQL队列,非公平锁只有第一次线程进入时会进行抢占,如果抢占失败,就会进入队列。

ReetrantReadWriteLock读写锁和RenntrantLock有什么区别?

ReentrantLock有一定的局限性,它的读锁与读锁间也会互斥,但读数据并不会改动数据,没有必要加锁保护,这就降低了程序的性能。

因以上问题,诞生了读写锁,读写锁一种读写分离技术,它的读锁是共享的,写锁是独占的,也就是说,多个线程是可以一起读数据的,只有写数据的时候,才会同步线程。

读写锁ReentrantReadWriteLock有什么特点?

1、公平性可以选择:支持非公平(默认)和公平的锁获取,吞吐量非公平优于公平。

2、重进入:读锁和写锁都支持线程重进入。

3、锁降级:获取写锁,再获取读锁,然后释放写锁,这样写锁就降级为了读锁。(注:Synchronized是不能进行锁降级的,意义不一样)。

多线程

什么是上下文切换?

当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换。

上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。

如何避免线程死锁

我们只要破坏产生死锁的四个条件中的其中一个就可以了。

破坏互斥条件

这个条件我们没有办法破坏,因为我们用锁本来就是想让他们互斥的(临界资源需要互斥访问)。

破坏请求与保持条件

一次性申请所有的资源。

破坏不剥夺条件

占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。

破坏循环等待条件

靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。

我们对线程 2 的代码修改成下面这样就不会产生死锁了。

创建线程有哪几种方式?

创建线程有四种方式:

1、继承 Thread 类;

2、实现 Runnable 接口;

3、实现 Callable接口,创建FutureTask对象(FutureTask 也是Runnable 接口的实现类),与Runnable的区别是有返回值;

4、使用创建线程池

说一下 runnable 和 callable 有什么区别?

相同点:

1、都是接口

2、都可以编写多线程程序

3、都采用Thread.start()启动线程

主要区别:

1、Runnable 接口 run 方法无返回值;Callable 接口 call 方法有返回值,是个泛型,和Future、FutureTask配合可以用来获取异步执行的结果。

2、Runnable 接口 run 方法只能抛出运行时异常,且无法捕获处理;Callable 接口 call 方法允许抛出异常,可以获取异常信息。

注:Callalbe接口支持返回执行结果,需要调用FutureTask.get()得到,此方法会阻塞主进程的继续往下执行,如果不调用不会阻塞。

线程的 run()和 start()有什么区别?

每个线程都是通过某个特定Thread对象所对应的方法run()来完成其操作的,run()方法称为线程体。通过调用Thread类的start()方法来启动一个线程。

start() 方法用于启动线程,run() 方法用于执行线程的运行时代码。run() 可以重复调用,而 start() 只能调用一次。

start()方法来启动一个线程,真正实现了多线程运行。调用start()方法无需等待run方法体代码执行完毕,可以直接继续执行其他的代码; 此时线程是处于就绪状态,并没有运行。 然后通过此Thread类调用方法run()来完成其运行状态, run()方法运行结束, 此线程终止。然后CPU再调度其它线程。

run()方法是在本线程里的,只是线程里的一个函数,而不是多线程的。 如果直接调用run(),其实就相当于是调用了一个普通函数而已,直接待用run()方法必须等待run()方法执行完毕才能执行下面的代码,所以执行路径还是只有一条,根本就没有线程的特征,所以在多线程执行时要使用start()方法而不是run()方法。

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

这是另一个非常经典的 java 多线程面试问题,而且在面试中会经常被问到。很简单,但是很多人都会答不上来!

new 一个 Thread,线程进入了新建状态。调用 start() 方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。

而直接执行 run() 方法,会把 run 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。

总结: 调用 start 方法方可启动线程并使线程进入就绪状态,而 run 方法只是 thread 的一个普通方法调用,还是在主线程里执行。

start()方法为什么能开启多线程?

真正实现开启多线程的是start() 方法中的 start0() 方法。

调用start0()方法后,该线程并不一定会立马执行,只是将线程变成了可运行状态(NEW —-> RUNNABLE);具体什么时候执行,取决于 CPU ,由 CPU 统一调度;我们又知道 Java 是跨平台的,可以在不同系统上运行,每个系统的 CPU 调度算法不一样,所以就需要做不同的处理,这件事情就只能交给 JVM 来实现了,start0() 方法自然就表标记成了 native。

线程的6种状态是什么?

1、新建状态(new):创建线程对象。

2、就绪状态(runnable):start方法。

3、阻塞状态(blocked):无法获得锁对象(线程没抢到)。

4、等待状态(waiting):wait方法。

5、计时状态(timed_waiting):sleep方法。

6、死亡状态(terminated):全部代码运行完毕。

线程的调度模式是什么?

两分时调度和抢占式式调度。

分时调度:轮流获取CPU使用权。

抢占式调度:优先级高的线程占用CPU。

请说出与线程同步以及线程调度相关的方法。

(1)wait():使一个线程处于等待(阻塞)状态,并且释放所持有的对象的锁;

(2)sleep():使一个正在运行的线程处于睡眠状态,是一个静态方法,调用此方法要处理 InterruptedException 异常;

(3)notify():唤醒一个处于等待状态的线程,当然在调用此方法的时候,并不能确切的唤醒某一个等待状态的线程,而是由 JVM 确定唤醒哪个线程,而且与优先级无关;

(4)notityAll():唤醒所有处于等待状态的线程,该方法并不是将对象的锁给所有线程,而是让它们竞争,只有获得锁的线程才能进入就绪状态;

sleep() 和 wait() 有什么区别?

相同点:两者都可以暂停线程的执行。不同点:

sleep方法,不会释放资源(本质是占用线程),如果占具锁资源,则其他线程不可进;wait方法会释放锁资源,即其他线程可进来。

wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify() 或者 notifyAll() 方法。sleep() 方法执行完成后,线程会自动苏醒。或者可以使用wait(long timeout)超时后线程会自动苏醒。

Java 中你怎样唤醒一个阻塞的线程?

首先 ,wait()、notify() 方法是针对对象的,调用任意对象的 wait()方法都将导致线程阻塞,阻塞的同时也将释放该对象的锁,相应地,调用任意对象的 notify()方法则将随机解除该对象阻塞的线程,但它需要重新获取该对象的锁,直到获取成功才能往下执行;

其次,wait、notify 方法必须在 synchronized 块或方法中被调用,并且要保证同步块或方法的锁对象与调用 wait、notify 方法的对象是同一个,如此一来在调用 wait 之前当前线程就已经成功获取某对象的锁,执行 wait 阻塞后当前线程就将之前获取的对象锁释放。

notify() 和 notifyAll() 有什么区别?

如果线程调用了对象的 wait()方法,那么线程便会处于该对象的等待池中,等待池中的线程不会去竞争该对象的锁。

notifyAll() 会唤醒所有的线程,notify() 只会唤醒一个线程。

notifyAll() 调用后,会将全部线程由等待池移到锁池,然后参与锁的竞争,竞争成功则继续执行,如果不成功则留在锁池等待锁被释放后再次参与竞争。而 notify()只会唤醒一个线程,具体唤醒哪一个线程由虚拟机控制。

什么 wait(), notify()和 notifyAll()必须在同步方法或者同步块中被调用?

这是 JDK 强制的,wait()方法和 notify()/notifyAll()方法在调用前都必须先获得对象的锁,也就是synchronized对象锁。

Java 线程数过多会造成什么异常?

1、线程的生命周期开销非常高

2、消耗过多的 CPU
资源如果可运行的线程数量多于可用处理器的数量,那么有线程将会被闲置。大量空闲的线程会占用许多内存,给垃圾回收器带来压力,而且大量的线程在竞争 CPU资源时还将产生其他性能的开销。

3、降低稳定性JVM
在可创建线程的数量上存在一个限制,这个限制值将随着平台的不同而不同,并且承受着多个因素制约,包括 JVM 的启动参数、Thread 构造函数中请求栈的大小,以及底层操作系统对线程的限制等。如果破坏了这些限制,那么可能抛出OutOfMemoryError 异常。

你了解ThreadLocal的原理吗?

threadlocal是一个线程内部的存储类,提供了线程内存储变量的能力,可以在指定线程内存储数据,数据存储以后,只有指定线程可以得到存储数据。这些变量不同之处在于每一个线程读取的变量是对应的互相独立的。

其内部维护了一个ThreadLocalMap,该Map用于存储每一个线程的变量副本。并且key为线程对象,value为对应线程的变量副本。

线程池

Executors类有哪几种常见的线程池?

4种:单例线程池、固定大小线程池、可缓存线程池、大小无限线程池。

(1)newSingleThreadExecutor:创建一个单例线程池。这个线程池只有一个线程在工作,也就是相当于单线程串行执行所有任务。如果这个唯一的线程因为异常结束,那么会有一个新的线程来替代它。此线程池保证所有任务的执行顺序按照任务的提交顺序执行。

(2)newFixedThreadPool:创建固定大小的线程池。每次提交一个任务就创建一个线程,直到线程达到线程池的最大大小。线程池的大小一旦达到最大值就会保持不变,如果某个线程因为执行异常而结束,那么线程池会补充一个新线程。如果希望在服务器上使用线程池,建议使用 newFixedThreadPool方法来创建线程池,这样能获得更好的性能。

(3) newCachedThreadPool:创建一个可缓存的线程池。如果线程池的大小超过了处理任务所需要的线程,那么就会回收部分空闲(60 秒不执行任务)的线程,当任务数增加时,此线程池又可以智能的添加新线程来处理任务。此线程池不会对线程池大小做限制,线程池大小完全依赖于操作系统(或者说 JVM)能够创建的最大线程大小。

(4)newScheduledThreadPool:创建一个大小无限的线程池。此线程池支持定时以及周期性执行任务的需求。

在 Java 中 Executor 和 Executors 的区别?

Executors 工具类的可以直接创建不同的线程池。

Executor 是个接口。

ExecutorService 接口继承了 Executor 接口并进行了扩展,提供了更多的方法我们能获得任务执行的状态并且可以获取任务的返回值。

ThreadPoolExecutor 是Executor接口的实现类,可以创建自定义线程池。

线程池中 submit() 和 execute() 方法有什么区别?

接收参数:execute()只能执行 Runnable 类型的任务。submit()可以执行 Runnable 和 Callable 类型的任务。

返回值:submit()方法可以返回持有计算结果的 Future 对象,而execute()没有。

异常处理:submit()方便Exception处理。

Executors 的弊端是什么?

newFixedThreadPool 和 newSingleThreadExecutor:

主要问题是堆积的请求处理队列可能会耗费非常大的内存,甚至 OOM(内存溢出)。

newCachedThreadPool 和 newScheduledThreadPool:
主要问题是线程数最大数是 Integer.MAX_VALUE,可能会创建数量非常多的线程,甚至 OOM。

线程池之ThreadPoolExecutor

你对ThreadPoolExecutor熟悉吗?

ThreaPoolExecutor可以自定义创建线程池,具体参数可以走它的构造函数。

ThreadPoolExecutor的核心参数有哪些?

七个核心参数:

参数一:核心线程数(不能小于0)

参数二:最大线程数(>=核心线程数)

参数三:临时线程最大存活时间(不能小于0)

参数四:时间单位(参数三的单位)

参数五:等待列队(不能为null)

参数六:创建线程工厂(不能为null,一般用默认线程工厂)

参数七:任务的拒绝策略(不能为null)

ThreadPoolExecutor的拒绝策略有哪些?

4种:

1、ThreadPoolExecutor.AbortPolicy:丢弃任务并抛出异常(默认);

2、ThreadPoolExecutor.DiscardPolicy:丢弃任务,不抛异常(不推荐);

3、ThreadPoolExecutor.DiscardOldestPolicy:丢弃等待最久的线程;

4、ThreadPoolExecutor.CallerRunsPolicy:调用执行自己的线程(main)运行run方法。

当任务过多时,ThreadPoolExeccutor的执行顺序是怎么样的?

1、核心线程满后;

2、阻塞队列满后;

3、临时线程满后(最大线程数 - 核心线程数 = 临时线程数);

4、拒绝策略。

并发工具类

什么是原子操作?在 Java Concurrency API 中有哪些原子类(atomic classes)?

原子操作(atomic operation)意为”不可被中断的一个或一系列操作” 。

处理器使用基于对缓存加锁或总线加锁的方式来实现多处理器之间的原子操作。在 Java 中可以通过锁和循环 CAS 的方式来实现原子操作。 CAS 操作——Compare & Set,或是 Compare & Swap,现在几乎所有的 CPU 指令都支持 CAS 的原子操作。

java.util.concurrent.atomic(JUC包下) 包提供了 int 和long 类型的原子包装类,它们可以自动的保证对于他们的操作是原子的并且不需要使用同步。

原子类:AtomicBoolean,AtomicInteger,AtomicLong,AtomicReference

原子数组:AtomicIntegerArray,AtomicLongArray,AtomicReferenceArray

说一下 atomic 的原理?

Atomic包中的类基本的特性就是在多线程环境下,当有多个线程同时对单个(包括基本类型及引用类型)变量进行操作时,具有排他性,即当多个线程同时对该变量的值进行更新时,仅有一个线程能成功,而未成功的线程可以向自旋锁一样,继续尝试,一直等到执行成功。

有使用过什么并发工具类吗?

CountdownLatch和Semaphore。

CountdownLatch有什么作用?

CountDownLatch(倒计时器)是一个同步工具类,用来协调多个线程之间的同步。这个工具通常用来控制线程等待,它可以让某一个线程等待直到倒计时结束,再开始执行。

Semaphore有什么作用?

Semaphore(信号量/通行令牌)-允许多个线程同时访问: synchronized 和 ReentrantLock 都是一次只允许一个线程访问某个资源,Semaphore(信号量)可以指定多个线程同时访问某个资源。

五、jvm,gc垃圾回收机制

JVM

说一下 JVM 的主要组成部分及其作用?

image.png

JVM包含两个子系统和两个组件,两个子系统为Class loader(类装载)、Execution engine(执行引擎);两个组件为Runtime data area(运行时数据区)、Native Interface(本地接口)。

Class loader(类装载):根据给定的全限定名类名(如:java.lang.Object)来装载class文件到Runtime data area中的method area。

Execution engine(执行引擎):执行classes中的指令。

Native Interface(本地接口):与native libraries交互,是其它编程语言交互的接口。

Runtime data area(运行时数据区域):这就是我们常说的JVM的内存。

顺序 :

1、首先通过编译器把 Java 代码转换成字节码,类加载器(ClassLoader)再把字节码加载到内存中,将其放在运行时数据区(Runtime data area)的方法区内。

2、而字节码文件只是 JVM 的一套指令集规范,并不能直接交给底层操作系统去执行,因此需要特定的命令解析器执行引擎(Execution Engine),将字节码翻译成底层系统指令。

3、再交由 CPU 去执行,而这个过程中需要调用其他语言的本地库接口(Native Interface)来实现整个程序的功能。

下面是Java程序运行机制详细说明

Java程序运行机制步骤:

1、首先利用IDE集成开发工具编写Java源代码,源文件的后缀为.java;

2、再利用编译器(javac命令)将源代码编译成字节码文件,字节码文件的后缀名为.class;

3、运行字节码的工作是由解释器(java命令)来完成的。

image.png

从上图可以看,java文件通过编译器变成了.class文件,接下来类加载器又将这些.class文件加载到JVM中。

其实可以一句话来解释:类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个 java.lang.Class对象,用来封装类在方法区内的数据结构。

说一下 JVM 运行时数据区?【重要】

Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存区域划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有些区域随着虚拟机进程的启动而存在,有些区域则是依赖线程的启动和结束而建立和销毁。Java 虚拟机所管理的内存被划分为如下几个区域:

图片4.png

不同虚拟机的运行时数据区可能略微有所不同,但都会遵从 Java 虚拟机规范, Java 虚拟机规范规定的区域分为以下 5 个部分:

1、程序计数器(Program Counter Register):当前线程所执行的字节码的行号指示器,个人感觉的他就是为多线程准备的,程序计数器是每个线程独有的,所以是线程安全的。它主要用于记录每个线程的执行情况。

2、Java 虚拟机栈(Java Virtual Machine Stacks):线程私有,用于存储局部变量表、操作数栈、动态链接、方法出口等信息;

3、本地方法栈(Native Method Stack):线程私有,与虚拟机栈的作用是一样的,只不过虚拟机栈是服务 Java 方法的,而本地方法栈是为虚拟机调用 Native 方法服务的(Native方法是JVM底层的C语言对其它系统或硬件进行交互);

· 4、Java 堆(Java Heap):Java 虚拟机中内存最大的一块,是被所有线程共享的,几乎所有的对象实例都在这里分配内存;Java堆也叫GC堆,是垃圾收集器管理的主要区域,堆中可以细分为:新生代、老年代;再细致一点,新生代中又分为:Eden Space(伊甸园)、Survivor( /səˈvaɪvər/)空间,Survivor空间又分为From区和to区。

5、方法区(Methed Area):1.8之后方法区用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据。

方法区补充点:

在JDK1.7以前HotSpot虚拟机使用永久代来实现方法区,永久代的大小在启动JVM时可以设置一个固定值(-XX:MaxPermSize),不可变。

在JDK1.7中 存储在永久代的部分数据就已经转移到Java Heap或者Native memory。譬如符号引用(Symbols)转移到了native memory,原本存放在永久代的字符常量池移出。但永久代仍存在于JDK 1.7中,并没有完全移除。

JDK1.8中进行了较大改动:

移除了永久代(PermGen),替换为元空间(Metaspace);

永久代中的 class metadata 转移到了 native memory(本地内存,而不是虚拟机);

永久代中的 interned Strings 和 class static variables 转移到了 Java heap;

永久代参数 (PermSize MaxPermSize) -> 元空间参数(MetaspaceSize MaxMetaspaceSize)

永久代(元空间)

在Java8中,永久代已经被移除,被一个称为“元数据区”(元空间,Metaspace)的区域所取代。
值得注意的是:元空间并不在虚拟机中,而是使用本地内存(之前,永久代是在jvm中)。

这样,解决了以前永久代的OOM问题,元数据和class对象存在永久代中,容易出现性能问题和内存溢出,毕竟是和老年代共享堆空间。java8后,永久代升级为元空间独立后,也降低了老年代GC的复杂度。

深拷贝和浅拷贝

浅拷贝(shallowCopy)只是增加了一个指针指向已存在的内存地址,

深拷贝(deepCopy)是增加了一个指针并且申请了一个新的内存,使这个增加的指针指向这个新的内存,使用深拷贝的情况下,释放内存的时候不会因为出现浅拷贝时释放同一个内存的错误。

浅复制:仅仅是指向被复制的内存地址,如果原地址发生改变,那么浅复制出来的对象也会相应的改变。

深复制:在计算机中开辟一块新的内存地址用于存放复制的对象。

说一下堆栈的区别?

1、物理地址:

堆的物理地址分配对对象是不连续的。因此性能慢些。在GC的时候也要考虑到不连续的分配,所以有各种算法。比如,标记-消除,复制,标记-压缩,分代(即新生代使用复制算法,老年代使用标记——压缩)

栈使用的是数据结构中的栈,先进后出的原则,物理地址分配是连续的。所以性能快。

2、内存分别:

堆因为是不连续的,所以分配的内存是在运行期确认的,因此大小不固定。一般堆大小远远大于栈。

栈是连续的,所以分配的内存大小要在编译期就确认,大小是固定的。

3、存放的内容:

堆存放的是对象的实例和数组。因此该区更关注的是数据的存储。

栈存放:局部变量,操作数栈,返回结果。该区更关注的是程序方法的执行。

程序的可见度:

堆对于整个应用程序都是共享、可见的。

栈只对于线程是可见的。所以也是线程私有。他的生命周期和线程相同。

队列和栈是什么?有什么区别?

队列和栈都是被用来预存储数据的。

操作的名称不同。队列的插入称为入队,队列的删除称为出队。栈的插入称为进栈,栈的删除称为出栈。

可操作的方式不同。队列是在队尾入队,队头出队,即两边都可操作。而栈的进栈和出栈都是在栈顶进行的,无法对栈底直接进行操作。

操作的方法不同。队列是先进先出(FIFO),即队列的修改是依先进先出的原则进行的。新来的成员总是加入队尾(不能从中间插入),每次离开的成员总是队列头上(不允许中途离队)。而栈为后进先出(LIFO),即每次删除(出栈)的总是当前栈中最新的元素,即最后插入(进栈)的元素,而最先插入的被放在栈的底部,要到最后才能删除。

堆里面的分区:Eden,survival (from+ to),老年代,各自的特点是什么?

堆里面分为新生代和老生代(java8 取消了永久代,采用了 Metaspace-元空间),新生代包含 Eden+Survivor 区,survivor 区里面分为 from 和 to 区。

年轻代:

新创建的对象都会被分配到Eden区(如果该对象占用内存非常大,则直接分配到老年代区), 当Eden区内存不够的时候就会触发MinorGC(Survivor满不会引发MinorGC,而是将对象移动到老年代中),

在Minor GC开始的时候,对象只会存在于Eden区和Survivor from区,Survivor to区是空的。

Minor GC操作后,Eden区如果仍然存活(判断的标准是被引用了,通过GC root进行可达性判断)的对象,将会被移到Survivor To区。而From区中,对象在Survivor区中每熬过一次Minor GC,年龄就会+1岁,当年龄达到一定值(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置,默认是15)的对象会被移动到年老代中,否则对象会被复制到“To”区。经过这次GC后,Eden区和From区已经被清空。

“From”区和“To”区互换角色,原Survivor To成为下一次GC时的Survivor From区, 总之,GC后,都会保证Survivor To区是空的。

奇怪为什么有 From和To,2块区域?这就要说到新生代Minor GC的算法了——复制算法。

把内存区域分为两块,每次使用一块,GC的时候把一块中的内容移动到另一块中,原始内存中的对象就可以被回收了,优点是避免内存碎片。

老年代:

随着Minor GC的持续进行,老年代中对象也会持续增长,导致老年代的空间也会不够用,最终会执行Major GC(MajorGC 的速度比 Minor GC 慢很多很多,据说10倍左右)。Major GC使用的算法是:标记清除(回收)算法或者标记压缩算法。

  1. 标记清除(回收):

\1. 首先会从GC root进行遍历,把可达对象(存过的对象)打标记

\2. 再从GC root二次遍历,将没有被打上标记的对象清除掉。

优点:老年代对象一般是比较稳定的,相比复制算法,不需要复制大量对象。之所以将所有对象扫描2次,看似比较消耗时间,其实不然,是节省了时间。举个栗子,数组 1,2,3,4,5,6。删除2,3,4,如果每次删除一个数字,那么5,6要移动3次,如果删除1次,那么5,6只需移动1次。

缺点:这种方式需要中断其他线程(STW),相比复制算法,可能产生内存碎片。

标记压缩:和标记清除算法基本相同,不同的就是,在清除完成之后,会把存活的对象向内存的一边进行压缩,这样就可以解决内存碎片问题。
当老年代也满了装不下的时候,就会抛出OOM(Out of Memory)异常。

Java会存在内存泄漏吗?请简单描述

内存泄漏是指不再被使用的对象或者变量一直被占据在内存中。理论上来说,Java是有GC垃圾回收机制的,也就是说,不再被使用的对象,会被GC自动回收掉,自动从内存中清除。

但是,即使这样,Java也还是存在着内存泄漏的情况,java导致内存泄露的原因很明确:长生命周期的对象持有短生命周期对象的引用就很可能发生内存泄露,尽管短生命周期对象已经不再需要,但是因为长生命周期对象持有它的引用而导致不能被回收,这就是java中内存泄露的发生场景。

有遇到过栈溢出吗?一般是什么问题导致?

栈溢出(StackOverflowError)是指栈内容全部被占用,而数据还要往里放。一般是递归错误或者出现死循环导致。

对象创建方法,对象的内存分配,对象的访问定位。

new 一个对象 。

java字符串常量池、class常量池和运行时常量池的区别是什么?

jvm的方法区里存放着类的版本,字段,方法,接口和常量池。

常量池里存储着字面量和符号引用。

image.png

字符串常量池(string pool)

字符串常量池里的内容是在类加载完成,经过验证,准备阶段之后在堆中生成字符串对象实例,然后将该字符串对象实例的引用值存到string pool中(记住:string pool中存的是引用值而不是具体的实例对象,具体的实例对象是在堆中开辟的一块空间存放的)。string pool在每个HotSpot VM的实例只有一份,被所有的类共享。在jdk1.8后,将String常量池放到了堆中。

class常量池(符号引用)

当java文件被编译成class文件之后,会在class文件中生成我们所说的class常量池,class文件中除了包含类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池表(constant pool table),用于存放编译器生成的各种字面量(文本字符串、被声明为final的常量、基本数据类型的值)和符号引用(类和接口的全限定名、字段的名称和描述符、方法的名称和描述符)。

image.png

常量池中每一项常量都是一个表,常量表中共有(11+4+2=17)种结构不同的表结构数据(11)和动态语言相关的常量(4)以及之后完善的CONSTANT_Module_info和CONSTANT_Package_info两个常量(2)。

17个表的共同特点:表结构起始的第一位是u1类型的标志位(tag),代表当前常量属于哪种常量类型。

运行时常量池(直接引用)

当类加载到内存中后,jvm就会将class常量池中的内容存放到运行时常量池中,由此可知,运行时常量池也是每个类都有一个。在上面我也说了,class常量池中存的是字面量和符号引用,也就是说他们存的并不是对象的实例,而是对象的符号引用值。而经过解析(resolve)之后,也就是把符号引用替换为直接引用,解析的过程会去查询字符串常量池,也就是我们上面所说的string pool,以保证运行时常量池所引用的字符串与字符串常量池中所引用的是一致的。

常量池(符号引用)—>运行时常量池(直接引用)—>字符串池常量池

字面量:比较接近java语言层面的常量概念,如文本字符串、被声明为final的常量值等。

符号引用:属于编译原理方面的概念,主要包括下面几类常量——

被模块导出或者开放的包

类和接口的全限定名

字段的名称和描述符

方法的名称和描述符

方法句柄和方法类型

动态调用点和动态常

直接引用:

直接引用和虚拟机的布局是相关的,不同的虚拟机对于相同的符号引用所翻译出来的直接引用一般是不同的。如果有了直接引用,那么直接引用的目标一定被加载到了内存中。

直接引用可以是:

1:直接指向目标的指针。(个人理解为:指向对象,类变量和类方法的指针)

2:相对偏移量。 (指向实例的变量,方法的指针)

3:一个间接定位到对象的句柄。

相关概念

1、方法区中的运行时常量池

运行时常量池是方法区的一部分。

CLass文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。

运行时常量池相对于CLass文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是并非预置入CLass文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用比较多的就是String类的intern()方法。

2、常量池的好处

常量池是为了避免频繁的创建和销毁对象而影响系统性能,其实现了对象的共享。

例如字符串常量池,在编译阶段就把所有的字符串文字放到一个常量池中。

(1)节省内存空间:常量池中所有相同的字符串常量被合并,只占用一个空间。

(2)节省运行时间:比较字符串时,比equals()快。对于两个引用变量,只用判断引用是否相等,也就可以判断实际值是否相等。

java内存模型

首先,JAVA内存模型是指JMM,而不是指内存结构,内存结构是在物理上的区域划分,而JMM则是抽象概念上的划分。

JMM(内存模型)主要包括两块:主内存+工作内存

主内存:多个线程间通信的共享内存称之为主内存,即,数据是多个线程工共享的,在物理内存结构上通常对应“堆”中的线程共享数据。

工作内存:多个线程各自对应自己的本地内存,即,数据只属于该线程自己的,在物理内存结构上通常对应“本地方法栈”中的线程私有数据。

Java内存模型规定了所有的变量都存储在主内存(Main Memory)中,每条线程还有自己的工作内存(Working Memory),线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量,不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值得传递均需要通过主内存来实现。

JVM优化

说一下 JVM 调优的工具?

JDK 自带了很多监控工具,都位于 JDK 的 bin 目录下,其中最常用的是 jconsole 和 jvisualvm 这两款视图监控工具。

jconsole:用于对 JVM 中的内存、线程和类等进行监控;

jvisualvm:JDK 自带的全能分析工具,可以分析:内存快照、线程快照、程序死锁、监控内存的变化、gc 变化等。

常用的 JVM 调优的参数都有哪些?

-Xms2g:初始化堆大小为 2g;【常用】

-Xmx2g:堆最大内存为 2g;【常用】

-XX:NewRatio=4:设置年轻的和老年代的内存比例为 1:4;

-XX:SurvivorRatio=8:设置新生代 Eden 和 Survivor 比例为 8:2;

–XX:+UseParNewGC:指定使用 ParNew + Serial Old 垃圾回收器组合;

-XX:+UseParallelOldGC:指定使用 ParNew + ParNew Old 垃圾回收器组合;

-XX:+UseConcMarkSweepGC:指定使用 CMS + Serial Old 垃圾回收器组合;

-XX:+PrintGC:开启打印 gc 信息;

-XX:+PrintGCDetails:打印 gc 详细信息。

GC-垃圾收集器

简述Java垃圾回收机制?

在java中,程序员是不需要显示的去释放一个对象的内存的,而是由虚拟机自行执行。在JVM中,有一个垃圾回收线程,它是低优先级的,在正常情况下是不会执行的,只有在虚拟机空闲或者当前堆内存不足时,才会触发执行,扫面那些没有被任何引用的对象,并将它们添加到要回收的集合中,进行回收。

GC是什么?为什么要GC

GC 是垃圾收集的意思(Gabage Collection),内存处理是编程人员容易出现问题的地方,忘记或者错误的内存。

回收会导致程序或系统的不稳定甚至崩溃,Java 提供的 GC 功能可以自动监测对象是否超过作用域从而达到自动。

回收内存的目的,Java 语言没有提供释放已分配内存的显示操作方法。

垃圾回收的优点和原理是什么?并考虑2种回收机制

java语言最显著的特点就是引入了垃圾回收机制,它使java程序员在编写程序时不再考虑内存管理的问题。

由于有这个垃圾回收机制,java中的对象不再有“作用域”的概念,只有引用的对象才有“作用域”。

垃圾回收机制有效的防止内存泄露,可以有效的使用可使用的内存。

垃圾回收器通常作为一个单独的低级别的线程运行,在不可预知的情况下对内存堆中已经死亡的或很长时间没有用过的对象进行清除和回收。

程序员不能实时的对某个对象或所有对象调用垃圾回收器进行垃圾回收。

垃圾回收有分代复制垃圾回收、标记垃圾回收、增量垃圾回收

垃圾回收器的基本原理是什么?垃圾回收器可以马上回收内存吗?有什么办法主动通知虚拟机进行垃圾回收?

对于GC来说,当程序员创建对象时,GC就开始监控这个对象的地址、大小以及使用情况。

通常,GC采用有向图的方式记录和管理堆(heap)中的所有对象。通过这种方式确定哪些对象是”可达的”,哪些对象是”不可达的”。当GC确定一些对象为”不可达”时,GC就有责任回收这些内存空间。

可以。程序员可以手动执行System.gc(),通知GC运行,但是Java语言规范并不保证GC一定会执行。

Java 中都有哪些引用类型?

强引用:发生 gc 的时候不会被回收。

软引用:有用但不是必须的对象,在发生内存溢出之前会被回收。

弱引用:有用但不是必须的对象,在下一次GC时会被回收。

虚引用(幽灵引用/幻影引用):无法通过虚引用获得对象,用 PhantomReference 实现虚引用,虚引用的用途是在 gc 时返回一个通知。

怎么判断对象是否可以被回收?

垃圾收集器在做垃圾回收的时候,首先需要判定的就是哪些内存是需要被回收的,哪些对象是「存活」的,是不可以被回收的;哪些对象已经「死掉」了,需要被回收。

一般有两种方法来判断:

1、引用计数器法:为每个对象创建一个引用计数,有对象引用时计数器 +1,引用被释放时计数 -1,当计数器为 0 时就可以被回收。它有一个缺点不能解决循环引用的问题;

2、可达性分析算法:从 GC Roots 开始向下搜索,搜索所走过的路径称为引用链。当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是可以被回收的。

在Java中,对象什么时候可以被垃圾回收?

当对象对当前使用这个对象的应用程序变得不可触及的时候,这个对象就可以被回收了。

JVM中的永久代中会发生垃圾回收吗?

垃圾回收不会发生在永久代,如果永久代满了或者是超过了临界值,会触发完全垃圾回收(Full GC)。如果你仔细查看垃圾收集器的输出信息,就会发现永久代也是被回收的。这就是为什么正确的永久代大小对避免Full GC是非常重要的原因。请参考下Java8:从永久代到元数据区

(注:Java8中已经移除了永久代,新加了一个叫做元数据区(也叫元空间)的native内存区)

什么是Full GC?什么情况下会触发?

Full GC是指清理整个堆空间——包括年轻代和老年代。

什么时候触发:

1.调用System.gc

  1. 方法区空间不足

3.老年代空间不足,包括:

①新创建的对象都会被分配到Eden区,如果该对象占用内存非常大,则直接分配到老年代区,此时老年代空间不足。

②做minor gc操作前,发现要移动的空间(Eden区、From区向To区复制时,To区的内存空间不足)比老年代剩余空间要大,则触发full gc,而不是minor gc。

GC优化的本质,也是为什么分代的原因:减少GC次数和GC时间,避免全区扫描。

说一下 JVM 有哪些垃圾回收算法?

1、标记-清除算法:标记无用对象,然后进行清除回收。缺点:效率不高,无法清除垃圾碎片。

2、复制算法:按照容量划分二个大小相等的内存区域,当一块用完的时候将活着的对象复制到另一块上,然后再把已使用的内存空间一次清理掉。缺点:内存使用率不高,只有原来的一半。

3、标记-整理算法:标记无用对象,让所有存活的对象都向一端移动,然后直接清除掉端边界以外的内存。

4、分代算法:根据对象存活周期的不同将内存划分为几块,一般是新生代和老年代,新生代基本采用复制算法,老年代采用标记整理算法。

标记-清除算法

标记无用对象,然后进行清除回收。

标记-清除算法(Mark-Sweep)是一种常见的基础垃圾收集算法,它将垃圾收集分为2个阶段

1、标记阶段:标记出可以回收的对象。

2、清除阶段:回收被标记的对象所占用的空间。

标记-清除算法之所以是基础的,是因为后面讲到的垃圾收集算法都是在此算法的基础上进行改进的。

优点:实现简单,不需要对象进行移动。

缺点:标记、清除过程效率低,产生大量不连续的内存碎片,提高了垃圾回收的频率。

标记-清除算法的执行的过程如下图所示

image.png

复制算法

为了解决标记-清除算法的效率不高的问题,产生了复制算法。它把内存空间划为两个相等的区域,每次只使用其中一个区域。垃圾收集时,遍历当前使用的区域,把存活对象复制到另外一个区域中,最后将当前使用的区域的可回收的对象进行回收。

优点:按顺序分配内存即可,实现简单、运行高效,不用考虑内存碎片。

缺点:可用的内存大小缩小为原来的一半,对象存活率高时会频繁进行复制

复制算法的执行过程如下图所示

image.png

标记-整理算法

在新生代中可以使用复制算法,但是在老年代就不能选择复制算法了,因为老年代的对象存活率会较高,这样会有较多的复制操作,导致效率变低。标记-清除算法可以应用在老年代中,但是它效率不高,在内存回收后容易产生大量内存碎片。因此就出现了一种标记-整理算法(Mark-Compact)算法,与标记-整理算法不同的是,在标记可回收的对象后将所有存活的对象压缩到内存的一端,使他们紧凑的排列在一起,然后对端边界以外的内存进行回收。回收后,已用和未用的内存都各自一边。

优点:解决了标记-清理算法存在的内存碎片问题。

缺点:仍需要进行局部对象移动,一定程度上降低了效率。

标记-整理算法的执行过程如下图所示

image.png

分代收集算法

当前商业虚拟机都采用分代收集的垃圾收集算法。分代收集算法,顾名思义是根据对象的存活周期将内存划分为几块。一般包括年轻代、老年代 和 永久代,如图所示:

image.png

说一下 JVM 有哪些垃圾回收器?

如果说垃圾收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。下图展示了7种作用于不同分代的收集器,其中用于回收新生代的收集器包括Serial、PraNew、Parallel Scavenge,回收老年代的收集器包括Serial Old、Parallel Old、CMS,还有用于回收整个Java堆的G1收集器。不同收集器之间的连线表示它们可以搭配使用。

image.png

Serial收集器(复制算法): 新生代单线程收集器,标记和清理都是单线程,优点是简单高效;

ParNew收集器 (复制算法): 新生代收并行集器,实际上是Serial收集器的多线程版本,在多核CPU环境下有着比Serial更好的表现;

Parallel Scavenge收集器 (复制算法): 新生代并行收集器,追求高吞吐量,高效利用 CPU。吞吐量 = 用户线程时间/(用户线程时间+GC线程时间),高吞吐量可以高效率的利用CPU时间,尽快完成程序的运算任务,适合后台应用等对交互相应要求不高的场景;

Serial Old收集器 (标记-整理算法): 老年代单线程收集器,Serial收集器的老年代版本;

Parallel Old收集器 (标记-整理算法): 老年代并行收集器,吞吐量优先,Parallel Scavenge收集器的老年代版本;

CMS(Concurrent Mark Sweep)收集器(标记-清除算法): 老年代并行收集器,以获取最短回收停顿时间为目标的收集器,具有高并发、低停顿的特点,追求最短GC回收停顿时间。

G1(Garbage First)收集器 (标记-整理算法): Java堆并行收集器,G1收集器是JDK1.7提供的一个新收集器,G1收集器基于“标记-整理”算法实现,也就是说不会产生内存碎片。此外,G1收集器不同于之前的收集器的一个重要特点是:G1回收的范围是整个Java堆(包括新生代,老年代),而前六种收集器回收的范围仅限于新生代或老年代。

新生代垃圾回收器和老年代垃圾回收器都有哪些?有什么区别?

新生代回收器:Serial、ParNew、Parallel Scavenge

老年代回收器:Serial Old、Parallel Old、CMS

整堆回收器:G1

新生代垃圾回收器一般采用的是复制算法,复制算法的优点是效率高,缺点是内存利用率低;老年代回收器一般采用的是标记-整理的算法进行垃圾回收。

详细介绍一下 CMS 垃圾回收器?

CMS 是英文 Concurrent Mark-Sweep 的简称,是以牺牲吞吐量为代价来获得最短回收停顿时间的垃圾回收器。对于要求服务器响应速度的应用上,这种垃圾回收器非常适合。在启动 JVM 的参数加上“-XX:+UseConcMarkSweepGC”来指定使用 CMS 垃圾回收器。

CMS 使用的是标记-清除的算法实现的,所以在 gc 的时候回产生大量的内存碎片,当剩余内存不能满足程序运行要求时,系统将会出现 Concurrent Mode Failure,临时 CMS 会采用 Serial Old 回收器进行垃圾清除,此时的性能将会被降低。

简述分代垃圾回收器是怎么工作的?

分代回收器有两个分区:老生代和新生代,新生代默认的空间占比总空间的 1/3,老生代的默认占比是 2/3。

新生代使用的是复制算法,新生代里有 3 个分区:Eden、To Survivor、From Survivor,它们的默认占比是 8:1:1,它的执行流程如下:

把 Eden + From Survivor 存活的对象放入 To Survivor 区;

清空 Eden 和 From Survivor 分区;

From Survivor 和 To Survivor 分区交换,From Survivor 变 To Survivor,To Survivor 变 From Survivor。

每次在 From Survivor 到 To Survivor 移动时都存活的对象,年龄就 +1,当年龄到达 15(默认配置是 15)时,升级为老生代。大对象也会直接进入老生代。

老生代当空间占用到达某个值之后就会触发全局垃圾收回,一般使用标记整理的执行算法。以上这些循环往复就构成了整个分代垃圾回收的整体执行流程。

内存分配策略

简述java内存分配与回收策率以及Minor GC和Major GC?

所谓自动内存管理,最终要解决的也就是内存分配和内存回收两个问题。前面我们介绍了内存回收,这里我们再来聊聊内存分配。

对象的内存分配通常是在 Java 堆上分配(随着虚拟机优化技术的诞生,某些场景下也会在栈上分配,后面会详细介绍),对象主要分配在新生代的 Eden 区,如果启动了本地线程缓冲,将按照线程优先在 TLAB 上分配。少数情况下也会直接在老年代上分配。总的来说分配规则不是百分百固定的,其细节取决于哪一种垃圾收集器组合以及虚拟机相关参数有关,但是虚拟机对于内存的分配还是会遵循以下几种「普世」规则:

对象优先在 Eden 区分配

多数情况,对象都在新生代 Eden 区分配。当 Eden 区分配没有足够的空间进行分配时,虚拟机将会发起一次 Minor GC。如果本次 GC 后还是没有足够的空间,则将启用分配担保机制在老年代中分配内存。

这里我们提到 Minor GC,如果你仔细观察过 GC 日常,通常我们还能从日志中发现 Major GC/Full GC。

Minor GC 是指发生在新生代的 GC,因为 Java 对象大多都是朝生夕死,所有 Minor GC 非常频繁,一般回收速度也非常快;

Major GC/Full GC 是指发生在老年代的 GC,出现了 Major GC 通常会伴随至少一次 Minor GC。Major GC 的速度通常会比 Minor GC 慢 10 倍以上。

大对象直接进入老年代

所谓大对象是指需要大量连续内存空间的对象,频繁出现大对象是致命的,会导致在内存还有不少空间的情况下提前触发 GC 以获取足够的连续空间来安置新对象。

前面我们介绍过新生代使用的是标记-清除算法来处理垃圾回收的,如果大对象直接在新生代分配就会导致 Eden 区和两个 Survivor 区之间发生大量的内存复制。因此对于大对象都会直接在老年代进行分配。

长期存活对象将进入老年代

虚拟机采用分代收集的思想来管理内存,那么内存回收时就必须判断哪些对象应该放在新生代,哪些对象应该放在老年代。因此虚拟机给每个对象定义了一个对象年龄的计数器,如果对象在 Eden 区出生,并且能够被 Survivor 容纳,将被移动到 Survivor 空间中,这时设置对象年龄为 1。对象在 Survivor 区中每「熬过」一次 Minor GC 年龄就加 1,当年龄达到一定程度(默认 15) 就会被晋升到老年代。

六、mysql及索引优化

什么是数据库三范式?

第一范式:需要满足列字段的原子性(每个单元只有一个数据,比如产品编码列中,每个单元只能有一个编码)

第二范式:在满足第一范式的基础上列字段需要跟主键有直接关联关系(比如订单号、产品编码、产品名称,产品名称与编码是依赖关系,和订单号无依赖关系,有双主键,需要拆)

第三范式:在满足第一,第二范式的基础上,不能有依赖传递(比如用户ID跟用户名称就有依赖传递,需要拆)

数据库的乐观锁和悲观锁是什么?怎么实现的?**

数据库管理系统(DBMS)中的并发控制的任务是确保在多个事务同时存取数据库中同一数据时不破坏事务的隔离性和统一性以及数据库的统一性。乐观并发控制(乐观锁)和悲观并发控制(悲观锁)是并发控制主要采用的技术手段。

悲观锁:假定会发生并发冲突,屏蔽一切可能违反数据完整性的操作。在查询完数据的时候就把事务锁起来,直到提交事务。实现方式:使用数据库中的锁机制

乐观锁:假设不会发生并发冲突,只在提交操作时检查是否违反数据完整性。在修改数据的时候把事务锁起来,通过version的方式来进行锁定。实现方式:乐一般会使用版本号机制或CAS算法实现。

两种锁的使用场景

从上面对两种锁的介绍,我们知道两种锁各有优缺点,不可认为一种好于另一种,像乐观锁适用于写比较少的情况下(多读场景),即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。

但如果是多写的情况,一般会经常产生冲突,这就会导致上层应用会不断的进行retry,这样反倒是降低了性能,

所以一般多写的场景下用悲观锁就比较合适。

事务的四大特性是什么?

——ACID(只有InnoDB才支持事务)。

原子性(undo log回滚保证事务的原子性):指同一对的事务操作,要么全成功,要么全失败,操作失败不能对数据库有影响。

一致性(undo log+redo log保证事务一致性):事务操作之后,数据库内的数据总量保持一致。(能量守恒)

隔离性(锁保证事务隔离性):相同的表,不同事务之间不能互相干扰。

持久性(redo log重做日志用来保证事务持久性):事务一旦被提交,就需要在数据永久化存储,即便故障也不会丢失提交事务的操作。

事务的隔离级别有哪几种?

4种。

读未提交(RU:read uncommitted):可能存在【脏读+不可重复读+幻读】的问题。

读已提交(RC:read committed):可能存在【不可重复读+幻读】问题。

可重复读(RR:repeatable read):可能存在【幻读】问题。

串行化(serializable):无以上问题,但效率低,一务在分布式事务的情况下用该级别。

什么是脏读?幻读?不可重复读?

脏读(Drity Read):是指在一个事务处理过程中读取了另一个未提交的事务中的数据 , 导致两次查询结果不一致。

不可重复读(Non-repeatable read)【MySQL默认】:事务开启后关闭前,多次读取同一条记录,结果却不能保证一致,所以叫不可重复读。主要问题不在同一个数据库的问题,而在不同的服务器,不同数据库时会出现的问题,因为两台电脑之间要保证数据相同,是需要时间进行复制的,从表在复制主表的过程中,很可能因为修改数据过快而导致复制到错误数据。

幻读(Phantom Read):select 某记录是否存在,不存在,准备插入此记录,但执行 insert 时发现此记录已存在,无法插入。或不存在执行delete删除,却发现删除成功。

事务的隔离级别是怎么解决以上三种问题的?

解决脏读:修改时加排他锁(写锁),直到事务提交后才释放,读取时加共享锁(读锁),其他事务只能读取,不能再有更新操作。防止脏读。

解决不可重复读:innodb引擎采用了MVCC(多版本并发控制)来解决不可重复读问题。mvcc是利用在每条数据后面加了隐藏的两列(创建版本号和删除版本号)当执行查询的时, 当前查询版本号>= 创建版本号 并且 >删除版本号 , MVCC可以在大多数情况下代替行级锁,使用MVCC,能降低其系统开销。

解决幻读:采用next-key锁解决幻读问题,next-key锁包含两部分:记录锁(行锁)+间隙锁,就是在索引和索引之间上面加锁。

行级锁,表级锁和页级锁对比?

行级锁:行级锁是Mysql中锁定粒度最细的一种锁,表示只针对当前操作的行进行加锁。行级锁能大大减少数据库操作的冲突。其加锁粒度最小,但加锁的开销也最大。行级锁分为共享锁(读锁)排他锁(写锁)

特点:开销大,加锁慢;会出现死锁;锁定粒度最小,发生锁冲突的概率最低,并发度也最高。

表级锁:表级锁是MySQL中锁定粒度最大的一种锁,表示对当前操作的整张表加锁,它实现简单,资源消耗较少,被大部分MySQL引擎支持。最常使用的MYISAM与INNODB都支持表级锁定。表级锁定分为表共享读锁(共享锁)与表独占写锁(排他锁)。

特点:开销小,加锁快;不会出现死锁;锁定粒度大,发出锁冲突的概率最高,并发度最低。

页级锁:页级锁是MySQL中锁定粒度介于行级锁和表级锁中间的一种锁。表级锁速度快,但冲突多,行级冲突少,但速度慢。所以取了折衷的页级,一次锁定相邻的一组记录。

特点:开销和加锁时间界于表锁和行锁之间;会出现死锁;锁定粒度界于表锁和行锁之间,并发度一般。

什么是死锁?怎么解决?

死锁是指两个或多个事务在同一资源上相互占用,并请求锁定对方的资源,从而导致恶性循环的现象。

表级锁不会产生死锁,行级锁和页级锁会产生死锁。

常见的解决死锁的方法:

1、如果不同程序会并发存取多个表,尽量约定以相同的顺序访问表,可以大大降低死锁机会。

2、在同一个事务中,尽可能做到一次锁定所需要的所有资源,减少死锁产生概率;

3、对于非常容易产生死锁的业务部分,可以尝试使用升级锁定颗粒度,通过表级锁定来减少死锁产生的概率;

如果业务处理不好可以用分布式事务锁或者使用乐观锁。

索引的设计原则是什么?

1、一张表的索引数量尽量不超过5个。在实际开发中,要不停去维护和取舍。

2、最左匹配原则(适用组合索引):要注意的是,如果使用了模糊查询,只要左边有通配符,就会直接全表扫描。

3、索引列的值要尽可能简短

o 如果索引列的值越小,那么在构建索引数的时候,非叶子节点的大小就会越小,16kb一页就能包含更多的非叶子节点。

o 如果有一列必须构建索引,但是这列的值又很长,此时我们可以构架一个‘前缀索引’(取列值中前面几个字符构建,符合最左匹配原则)。此时需要考虑这个前缀的重复率(即,选择性),重复率越高,选择性越低,索引的意义越小。0代表全部重复,1代表完全不重复。

o 计算重复率:

— SUBSTR获取name列的前8个字符

— COUNT(DISTINCT SUBSTR(NAME,1,8)) 获取去除重复后的总记录数

4、尽可能创建“覆盖索引”,减少回表操作。

创建索引需要注意什么?

字段越小越好:数据库的数据存储以而立为单位,字段越小,每页能存储的数据越多,一次IO获取数据越大,效率越高。

非空字段:含有空值的列很难进行查询优化,因为它们使索引和数据统计在运算上更复杂,应用特殊值或空串替代。

什么是最左匹配原则?

联合索引进扫B+tree扫描时,会最左边的索引条件起点优先,任何连续的索引都能匹配的上。比如有一个联合索引为(name,price,address),那么它的匹配索引有三个,按照优先级分别是:name、name+price、name+price+address(注:必须是从最左开始,且连续)

什么是InnoDB回表?

即第一次B+tree的叶节点上没能直接获取数据,还需要通过叶节点上的数据做为新的索引在另一张表上进行第二次B+tree的扫描,直到获取最终想要的数据。

如何优化数据库?

1)提升硬盘配置,换SSD固态硬盘,硬件直接决定了磁盘读写的快慢。

2)设计索引的时候,尽量保证主键足够小。

3)对数据库结构优化,索引可以适当冗余,可以减少多表联查。

4)尽量保证数据的有序性,提升批量插入的性能。

5)分库分表。

5)模糊查询和海量数据查询使用ES:倒排索引,近实时查询。

6)数据迁移使用XXL-Job:只保留当天数据,庞大的历史数据放到历史表。

7)SQL语句的选用:比如固定长度用char更快,比如用not exist(子查询仍然可以用索引查找是否存在)替代not in(索引失效,因为需要遍历判断)性能更好。

如何定位及优化SQL语句的性能问题?创建的索引有没有被使用到?或者说怎么才可以知道这条语句运行很慢的原因?

使用explain命令来查看语句的执行计划,它可以显示数据库引擎对于SQL语句的执行的详细情况,包含是否使用索引,使用什么索引,使用的索引的相关信息等,具体的判断参数如下:

image.png

Id:表示查询中各个子查询的执行顺序,id值越大,优先级越高,越先被执行。

Select_type:每个子查询的查询类型。

SIMPLE:不包含任何子查询或union(全链接)等查询。

PRIMARY:包含子查询的最外层查询显示为PRIMARY。

SUBQUERY:在select或where字句中包含的查询。

DERIVED:from字句中包含的查询。

UNION:出现在union后的查询语句中。

UNION RESULT:从UNION中获取结果集。

Table:显示具体对应的表。

Type(很重要):查看有没有走索引,及访问类型。

ALL 扫描全表数据

index 遍历索引

range 索引范围查找

index_subquery 在子查询中使用 ref

unique_subquery 在子查询中使用 eq_ref

ref_or_null 对Null进行索引的优化的 ref

fulltext 使用全文索引

ref 使用非唯一索引查找数据

eq_ref 在join查询中使用PRIMARY KEYorUNIQUE NOT NULL索引关联。

possible_keys :可能使用的索引,注意不一定会使用。查询涉及到的字段上若存在索引,则该索引将被列出来。当该列为 NULL时就要考虑当前的SQL是否需要优化了。

key :显示MySQL在查询中实际使用的索引,若没有使用索引,显示为NULL。

TIPS:查询中若使用了覆盖索引(覆盖索引:索引的数据覆盖了需要查询的所有数据),则该索引仅出现在key列表中。

key_length:索引长度

Ref:表示上述表的连接匹配条件,即哪些列或常量被用于查找索引列上的值

Rows:返回估算的结果集数目,并不是一个准确的值。

如何优化慢SQL?

慢SQL的常见类型:

1、索引问题

查询没有走索引。如果一个表的数据量比较大,但是没有索引,会造成慢查询。这种类型的问题往往是某些场景没有考虑到,或者项目初期没有这么大的数据量,对查询的影响不大。一般这种问题可以通过工具来辅助发现问题sql。

索引的区分度不高。索引设计的不好,没办法有效的过滤掉不符合查询条件的记录。此时数据库仍然需要扫描大量的行。性别字段、表示开关状态的字段,加了索引也没有太大的作用。

问题索引。添加了索引,查询不一定就会变快。重复的索引。索引合并的情况。

索引的选择问题。MySQL没有选择最优的索引。此时可以选择强制走某个可能最优的索引,或者忽略某个最坏的索引。

排序。

索引无法命中的问题。类型不匹配,导致隐式类型转换。MySQL不支持函数索引,某个字段使用了函数导致该字段的索引无法使用。

2、分页查询

查询结果一次返回。随着业务量的增长,一次返回的数据量太大会造成性能问题。

数据量太大时,分页的查询也很慢。分页查询可以减少数据库单次查询的数据量,从而缓解性能压力。但是,MySQL的分页机制下,即使是分页数据库也会把符合条件的数据都拉取出来,然后返回指定分页区间的数据。随着分页的增长,查询依然会越来越慢。此时,可以先查询符合条件的记录主键,然后根据主键查询相应的行记录。

3、子查询

子查询会创建临时表,一定程度上会降低数据库实例的性能。

使用IN、EXISTS语法时,参与查询的行数不宜过多,100以内。多的话分页查询。

在应用层拆分。在代码层面先获取子查询的结果,然后再将其作为外层SQL的条件,分步实现。

将子查询改为连表查询。

4、连表查询

连接多个表的问题。

用数据量较小的表驱动数据量较多的表。

移除掉没必要的表。在select和where语句中都没有涉及到的表,是不需要连接的。

拆分为多个SQL。先查询主表,然后再根据连接字段分别查询对应的表的数据。

避免join操作。在对应的表上将某些常用字段冗余一份,可以减少不必要的连接。或者保存一个json字符串,将所需的结果都放入其中。换句话说,就是使用NoSQL的方式聚合对象信息。

常见的优化策略

SQL优化只是性能优化的一个环节。应用层的优化、MySQL实例的配置调优等方面也是一个提升性能的点。

如果一个SQL已经没有优化空间了,那该如何处理?

将难以优化的SQL隔离开,在从库中执行。

时间段上将相关任务异步化,优先级降低。

监控慢SQL执行进程,超过一定时长,杀死该进程。

另一方面,有些离线分析的业务,不应该使用MySQL。这些逻辑应该在数据分析套件中去实现。

MySQL的复制原理以及流程?

基本原理流程,3个线程以及之间的关联:

主:binlog线程——记录下所有改变了数据库数据的语句,放进master上的binlog中;

从:io线程——在使用start slave 之后,负责从master上拉取 binlog 内容,放进自己的relay log(中继日志)中;

从:sql执行线程——执行relay log中的语句;

大表怎么优化?某个表有近千万数据,CRUD比较慢,如何优化?分库分表了是怎么做的?分表分库了有什么问题?有用到中间件么?他们的原理知道么?

1、限定数据范围:查询时带上范围条件。

2、读/写分离:通过主从复制进行读写分离,主库负责写,从库负责读。

3、缓存:配合redis使用。

4、通过Sharding-JDKB进行分库分表。

如果在实际工作中发现MySQL数据库CPU飙升到500%,该怎么处理?

1、先用操作系统命令top命令查看情况;

2、如果是mysql造成的,就用show processlist语句看一下MySQL实例的连接情况,其中state列可以显示使用当前连接的SQL语句的状态,time显示这个状态持续的时间,info显示正在执行的语句;

3、找到可能有问题的SQL语句后,再通过explain执行计划看看它的索引情况。

4、针对具体情况做相应的优化。