面试题 进阶版

MySQL 锁概述

相对其他数据库而言,MySQL 的锁机制比较简单,其最显著的特点是不同的存储
引擎支持不同的锁机制。
比如:
. MyISAM 和 MEMORY 存储引擎采用的是表级锁(table-level locking);
. InnoDB 存储引擎既支持行级锁( row-level locking,也支持表级锁,
但默认情况下是采用行级锁。

MySQL 主要的两种锁的特性可大致归纳如下:

表级锁: 开销小,加锁快;不会出现死锁(因为 MyISAM 会一次性获得 SQL所需的全部锁);锁定粒度大,发生锁冲突的概率最高,并发度最低。
行级锁: 开销大,加锁慢;会出现死锁;锁定粒度最小,发生锁冲突的概率最低,并发度也最高。
页锁:开销和加锁速度介于表锁和行锁之间;会出现死锁;锁定粒度介
于表锁和行锁之间,并发度一般

行锁 和 表锁

1. 主要是针对锁粒度划分的,一般分为: 行锁、表锁、库锁
(1)行锁:访问数据库的时候,锁定整个行数据,防止并发错误。
(2)表锁:访问数据库的时候,锁定整个表数据,防止并发错误。
2.行锁 和 表锁 的区别:
表锁 : 开销小,加锁快,不会出现死锁;锁定力度大,发生锁冲突概率
高,并发度最低
行锁 : 开销大,加锁慢, 会出现死锁 ;锁定粒度小,发生锁冲突的概率
低,并发度高

悲观锁 和 乐观锁

(1)悲观锁: 顾名思义,就是很悲观,每次去拿数据的时候都认为别人会修改,
所以 每次在拿数据的时候都会上锁,这样别人想拿这个数据就会 block 直到它
拿到锁。
传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,
写锁等,都是在做操作之前先上锁。
(2)乐观锁: 顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修
改,所以不会上锁,但是在 更新的时候会判断一下 在此期间别人有没有去更新这
个数据,可以使用版本号等机制。
乐观锁适用于多读的应用类型,这样可以提高吞吐量 ,像数据库如果提供类似于
write_condition 机制的其实都是提供的乐观锁。
(3)悲观锁 和 乐观锁的区别:
两种锁各有优缺点,不可认为一种好于另一种,像 乐观锁适用于写比较少的情况
,即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个
吞吐量。但 如果经常产生冲突 ,上层应用会不断的进行 retry,这样反倒是降低
了性能, 所以这种情况下用悲观锁就比较合适

共享锁

共享锁指的就是对于多个不同的事务,对同一个资源共享同一个锁。相当于对于
同一把门,它拥有多个钥匙一样。就像这样,你家有一个大门,大门的钥匙有好
几把,你有一把,你女朋友有一把,你们都可能通过这把钥匙进入你们家,这个
就是所谓的共享锁。
刚刚说了,对于悲观锁,一般数据库已经实现了, 共享锁也属于悲观锁的一种
那么共享锁在 mysql 中是通过什么命令来调用呢。通过查询资料,了解到通过在
执行语句后面加上 lock in share mode 就代表对某些资源加上共享锁了。

什么时候使用表锁

对于 InnoDB 表,在绝大部分情况下都应该使用行级锁,因为事务和行锁往往是
我们之所以选择 InnoDB 表的理由。但在个别特殊事务中,也可以考虑使用表级
锁。
第一种情况是:事务需要更新大部分或全部数据,表又比较大,如果使
用默认的行锁,不仅这个事务执行效率低,而且可能造成其他事务长时间锁
等待和锁冲突,这种情况下可以考虑使用表锁来提高该事务的执行速度。
第二种情况是:事务涉及多个表,比较复杂,很可能引起死锁,造成大
量事务回滚。这种情况也可以考虑一次性锁定事务涉及的表,从而避免死锁、
减少数据库因事务回滚带来的开销。
当然,应用中这两种事务不能太多,否则,就应该考虑使用 MyISAM 表了。

表锁和行锁应用场景:

表级锁使用与并发性不高,以查询为主,少量更新的应用 ,比如小型的web 应用;
行级锁适用于高并发环境下,对事务完整性要求较高的系统 ,如在线事务处理系统。

乐观锁 VS 悲观锁

乐观锁与悲观锁是一种广义上的概念,体现了看待线程同步的不同角度,在 Java
和数据库中都有此概念对应的实际应用。

1.乐观锁

顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上
锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使
用版本号等机制。
乐观锁适用于多读的应用类型 ,乐观锁在 Java 中是通过使用无锁编程来实现,
最常采用的是 CAS 算法 ,Java 原子类中的递增操作就通过 CAS 自旋实现的。
CAS 全称 Compare And Swap(比较与交换),是一种无锁算法。在不使用锁(没
有线程被阻塞)的情况下实现多线程之间的变量同步。java.util.concurrent
包中的原子类就是通过 CAS 来实现了乐观锁。
简单来说,CAS 算法有 3 个三个操作数:
        需要读写的内存值 V。
        进行比较的值 A。
        要写入的新值 B。
当且仅当预期值 A 和内存值 V 相同时,将内存值 V 修改为 B,否则返回 V 。这是
一种乐观锁的思路,它相信在它修改之前,没有其它线程去修改它;而
Synchronized 是一种悲观锁,它认为在它修改之前,一定会有其它线程去修改
它,悲观锁效率很低

2.悲观锁

总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数
据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。
传统的 MySQL 关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,
读锁,写锁等,都是在做操作之前先上锁。详情可以参考: 阿里 P8 架构师谈:
MySQL 行锁、表锁、悲观锁、乐观锁的特点与应用
再比如上面提到的 Java 的同步 synchronized 关键字的实现就是典型的悲观锁。

3.总之:

悲观锁适合写操作多的场景 ,先加锁可以保证写操作时数据正确。
乐观锁适合读操作多的场景 ,不加锁的特点能够使其读操作的性能大幅提升。

公平锁 VS 非公平锁

1.公平锁

就是很公平,在并发环境中,每个线程在获取锁时会先查看此锁维护的等待队列,
如果为空,或者当前线程是等待队列的第一个,就占有锁,否则就会加入到等待
队列中,以后会按照 FIFO 的规则从队列中取到自己。
公平锁的优点 是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要
低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU 唤醒阻塞线程的开
销比非公平锁大。

2.非公平锁

上来就直接尝试占有锁,如果尝试失败,就再采用类似公平锁那种方式。
非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几
率不阻塞直接获得锁,CPU 不必唤醒所有线程。缺点是处于等待队列中的线程可
能会饿死,或者等很久才会获得锁。

3.典型应用

java jdk 并发包中的 ReentrantLock 可以指定构造函数的 boolean 类型来创建
公平锁和 非公平锁(默认) ,比如:公平锁可以使用 new ReentrantLock(true)
实现。

独享锁 VS 共享锁

1.独享锁

是指该锁一次只能被一个线程所持有。

2.共享锁

是指该锁可被多个线程所持有。

3.比较

对于 Java ReentrantLock 而言,其是独享锁。但是对于 Lock 的另一个实现类
ReadWriteLock,其读锁是共享锁,其写锁是独享锁。
读锁的共享锁可保证并发读是非常高效的 ,读写,写读 ,写写的过程是互斥的。
独享锁与共享锁也是通过 AQS 来实现的 ,通过实现不同的方法,来实现独享或者
共享。

4.AQS

抽象队列同步器(AbstractQueuedSynchronizer,简称 AQS)是用来构建锁或者
其他同步组件的基础框架,它使用一个整型的 volatile 变量(命名为 state)
来维护同步状态,通过内置的 FIFO 队列来完成资源获取线程的排队工作。
concurrent 包的实现结构如上图所示,AQS、非阻塞数据结构和原子变量类等基
础类都是基于 volatile 变量的读/写和 CAS 实现,而像 Lock、同步器、阻塞队
列、Executor 和并发容器等高层类又是基于基础类实现。

分段锁

分段锁其实是一种锁的设计,并不是具体的一种锁,对于 ConcurrentHashMap
而言,其并发的实现就是通过分段锁的形式来实现高效的并发操作。
我们以 ConcurrentHashMap 来说一下分段锁的含义以及设计思想,
ConcurrentHashMap 中的分段锁称为 Segment,它即类似于 HashMap(JDK7 与 JDK8
中 HashMap 的实现)的结构,即内部拥有一个 Entry 数组,数组中的每个元素又
是一个链表;同时又是一个 ReentrantLock(Segment 继承了 ReentrantLock)。
当需要 put 元素的时候,并不是对整个 hashmap 进行加锁,而是先通过 hashcode
来知道他要放在那一个分段中,然后对这个分段进行加锁,所以当多线程 put
的时候,只要不是放在一个分段中,就实现了真正的并行的插入。
但是,在统计 size 的时候,可就是获取 hashmap 全局信息的时候,就需要获取
所有的分段锁才能统计。
分段锁的设计目的是细化锁的粒度,当操作不需要更新整个数组的时候,就仅仅
针对数组中的一项进行加锁操作。

什么场景需要 JVM 调优

OutOfMemoryError,内存不足
内存泄露
线程死锁
锁争用(Lock Contention)
Java 进程消耗 CPU 过高
这些问题出现的时候常常通过重启服务器或者调大内存来临时解决,实际情况,
还需要尽量还原当时的业务场景,并分析内存、线程等数据,通过分析找到最终
的解决方案,这就会涉及到性能分析工具。

JVM 性能监控分析工具

JDK 本身提供了很丰富的性能监控工具,除了集成式的 visualVM 和 jConsole 外,
还有 jstat,jstack,jps,jmap,jhat 小工具,这些都是性能调优的常用工具。
Jconsole : jdk 自带,功能简单,但是可以在系统有一定负荷的情况下
使用。对垃圾回收算法有很详细的跟踪。
JProfiler :商业软件,功能强大。
VisualVM :JDK 自带,功能强大,与 JProfiler 类似。
MAT: MAT(Memory Analyzer Tool),一个基于 Eclipse 的内存分析工具。

VisualVM

VisualVM 是 javajdk 自带的牛逼的调优工具,也是平时使用最多调优工具,几
乎涉及了 jvm 调优的方方面面。启动起来后和 jconsole 一样同样可以选择本地
和远程,如果需要监控远程同样需要配置相关参数。
VisualVM 可以根据需要安装不同的插件,每个插件的关注点都不同,有的主要
监控 GC,有的主要监控内存,有的监控线程等。

Jconsole

JConsole 是一个 JMX(Java Management Extensions,即 Java 管理扩展)的 JVM 监控与管理工具,监控主要体现在:堆 栈内存、线程、CPU、类、VM 信息这几个方面,而管理主要是对 JMX MBean(managed beans,被管理的 beans,是一系列资源,包含对象、接口、设备等)的管理, 不仅能查看 bean 的属性和方法信息,还能够在运行时修改属性或调用方法。

JVM 内存泄漏分析

造成 OutOfMemoryError 内存泄露典型原因:对象已经死了,无法通过垃圾收集
器进行自动回收,需要通过找出泄露的代码位置和原因,才好确定解决方案。
分析步骤:
1. 用工具生成 java 应用程序的 heap dump(如 jmap)
2. 使用 Java heap 分析工具(如 MAT),找出内存占用超出预期的嫌疑对象
3. 根据情况,分析嫌疑对象和其他对象的引用关系。
4. 分析程序的源代码,找出嫌疑对象数量过多的原因。

堆内存(Heap)

java 堆(Java Heap)是 Java 虚拟机所管理的内存中最大的一块。 堆是被所有
线程共享 的区域,实在虚拟机启动时创建的。堆里面存放的都是 对象的实例 (new
出来的对象都存在堆中)。
此内存区域的唯一目的就是存放对象实例(new 的对象),几乎所有的对象实例
都在这里分配内存。
堆内存分为两个部分: 年轻代和老年代 我们平常所说的垃圾回收,主要回收的
就是堆区。更细一点划分新生代又可划分为 Eden 区和 2 个 Survivor 区(From
Survivor 和 To Survivor)。
下图中的 Perm 代表的是永久代,但是注意永久代并不属于堆内存中的一部分,
同时 jdk1.8 之后永久代已经被移除。
新生代 ( Young ) 与老年代 ( Old ) 的比例的值为 1:2 ( 该值可以通过参数
–XX:NewRatio 来指定 )
默认的,Eden : from : to = 8 : 1 : 1 ( 可以通过参数 –XX:SurvivorRatio
来设定 ),即: Eden = 8/10 的新生代空间大小,from = to = 1/10 的新生代
空间大小。

方法区(Method Area)

方法区也称” 永久代 “,它用于 存储虚拟机加载的类信息、常量、静态变量 、是
各个 线程共享的内存区域
在 JDK8 之前的 HotSpot JVM,存放这些”永久的”的区域叫做“永久代(permanent
generation)”。永久代是一片连续的堆空间,在 JVM 启动之前通过在命令行设
置参数-XX:MaxPermSize 来设定永久代最大可分配的内存空间, 默认大小是 64M
(64 位 JVM 默认是 85M)。
随着 JDK8 的到来,JVM 不再有 永久代(PermGen) 。但类的元数据信息(metadata)
还在,只不过不再是存储在连续的堆空间上,而是移动到叫做“Metaspace”的
本地内存(Native memory。
方法区或永生代相关设置
-XX:PermSize=64MB 最小尺寸,初始分配
-XX:MaxPermSize=256MB 最大允许分配尺寸,按需分配
XX:+CMSClassUnloadingEnabled -XX:+CMSPermGenSweepingEnabled 设置垃圾不回收
默认大小
-server 选项下默认 MaxPermSize 为 64m
-client 选项下默认 MaxPermSize 为 32m

虚拟机栈(JVM Stack)

java 虚拟机栈是 线程私有 ,生命周期与线程相同。创建线程的时候就会创建一
个 java 虚拟机栈。
虚拟机执行 java 程序的时候,每个方法都会创建一个栈帧,栈帧存放在 java
虚拟机栈中,通过压栈出栈的方式进行方法调用。
栈帧又分为一下几个区域: 局部变量表、操作数栈、动态连接、方法出口 等。
平时我们所说的变量存在栈中,这句话说的不太严谨,应该说局部变量存放在
java 虚拟机栈的局部变量表中。
java 的 8 中基本类型的局部变量的值存放在虚拟机栈的局部变量表中,如果是
引用型的变量,则只存储对象的引用地址。

本地方法栈(Native Stack)

本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,
其区别不过是虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而 本地
方法栈则是为虚拟机使用到的 Native 方法服务。

程序计数器(PC Register)

程序计数器就是记录当前线程执行程序的位置,改变计数器的值来确定执行的下
一条指令,比如循环、分支、方法跳转、异常处理,线程恢复都是依赖程序计数
器来完成。
Java 虚拟机多线程是通过线程轮流切换并分配处理器执行时间的方式实现的。
为了线程切换能恢复到正确的位置,每条线程都需要一个独立的程序计数器,所
以它是 线程私有 的。

直接内存

直接内存并不是虚拟机内存的一部分,也不是 Java 虚拟机规范中定义的内存区
域。jdk1.4 中新加入的 NIO,引入了通道与缓冲区的 IO 方式,它可以调用 Native
方法直接分配堆外内存,这个堆外内存就是本机内存,不会影响到堆内存的大小。

垃圾回收算法

1.标记清除

标记-清除算法将垃圾回收分为两个阶段: 标记阶段和清除阶段
在标记阶段首先通过 根节点(GC Roots) ,标记所有从根节点开始的对象,未被标
记的对象就是未被引用的垃圾对象。然后,在清除阶段,清除所有未被标记的对
象。

2.复制算法

从根集合节点进行扫描,标记出所有的存活对象,并将这些存活的对象复制到一
块儿新的内存(图中下边的那一块儿内存)上去,之后将原来的那一块儿内存(图
中上边的那一块儿内存)全部回收掉

3.标记整理

复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。
这种情况在新生代经常发生,但是在老年代更常见的情况是大部分对象都是存活
对象。如果依然使用复制算法,由于存活的对象较多,复制的成本也将很高。

4.分代收集算法

分代收集算法就是目前虚拟机使用的回收算法 ,它解决了标记整理不适用于老年
代的问题,将内存分为各个年代。一般情况下将堆区划分为老年代(Tenured
Generation)和新生代(Young Generation),在堆区之外还有一个代就是永久
代(Permanet Generation)。
在不同年代使用不同的算法,从而使用最合适的算法,新生代存活率低,可以使
用复制算法。而老年代对象存活率搞,没有额外空间对它进行分配担保,所以只
能使用标记清除或者标记整理算法。

垃圾回收机制

jvm 内存结构

1.新产生的对象优先分配在 Eden 区(除非配置了-XX:PretenureSizeThreshold,
大于该值的对象会直接进入年老代);
2.当 Eden 区满了或放不下了,这时候其中存活的对象会复制到 from 区。
这里,需要注意的是,如果存活下来的对象 from 区都放不下,则这些存活下来的对象全部进入年老代。之后 Eden 区的内存全部回 收掉。
3.之后产生的对象继续分配在 Eden 区,当 Eden 区又满了或放不下了,这时候 将会把 Eden 区和 from 区存活下来的对象复制到 to 区(同理,如果存活下来的 对象 to 区都放不下,则这些存活下来的对象全部进入年老代),之后回收掉 Eden 区和 from 区的所有内存。
4.如上这样,会有很多对象会被复制很多次(每复制一次,对象的年龄就+1),
默认情况下,当对象被复制了 15 次(这个次数可以通过:-XX:MaxTenuringThreshold 来配置),就会进入年老代了。
5.当年老代满了或者存放不下将要进入年老代的存活对象的时候,就会发生一
次 Full GC(这个是我们最需要减少的,因为耗时很严重)。

垃圾回收有两种类型:Minor GC 和 Full GC。

1.Minor GC

对新生代进行回收,不会影响到年老代。因为新生代的 Java 对象大多死亡频繁,
所以 Minor GC 非常频繁,一般在这里使用速度快、效率高的算法,使垃圾回收
能尽快完成。

2.Full GC

也叫Major GC,对整个堆进行回收,包括新生代和老年代。由于 Full GC 需要对整个
堆进行回收,所以比 MinorGC 要慢,因此应该尽可能减少 Full GC 的次数,导致 Full
GC 的原因包括:老年代被写满、永久代(Perm)被写满和 System.gc()被显式调
用等。

垃圾回收算法总结

1.年轻代:复制算法

1.所有新生成的对象首先都是放在年轻代的。年轻代的目标就是尽可能快速的
收集掉那些生命周期短的对象。 
2.新生代内存按照 8:1:1 的比例分为一个 eden 区和两个 survivor(survivor0,survivor1)区。一个 Eden 区,两个 Survivor 区(一般而言)。大部分对象在 Eden 区中生成。回收时先将 eden 区存
活对象复制到一个 survivor0 区,然后清空 eden 区,当这个 survivor0 区也存放满了时,则将 eden 区和 survivor0 区存活对象复制到另一个 survivor1 区, 然后清空eden和这个survivor0区,此时survivor0区是空的,然后将survivor0 区和 survivor1 区交换,即保持 survivor1 区为空,
如此往复。
3.当 survivor1 区不足以存放 eden 和 survivor0 的存活对象时,就将存活对
象直接存放到老年代。若是老年代也满了就会触发一次 Full GC(Major GC),也
就是新生代、老年代都进行回收。
4. 新生代发生的 GC 也叫做 Minor GC,MinorGC 发生频率比较高(不一定等 Eden
区满了才触发)。

2.年老代:标记-清除或标记-整理

1.在年轻代中经历了 N 次垃圾回收后仍然存活的对象,就会被放到年老代中。
因此,可以认为年老代中存放的都是一些生命周期较长的对象。
2.内存比新生代也大很多(大概比例是 1:2),当老年代内存满时触发 Major GC
即 Full GC,Full GC 发生频率比较低,老年代对象存活时间比较长,存活率标
记高。
以上这种年轻代与年老代分别采用不同回收算法的方式称为”分代收集算法”,
这也是当下企业使用的一种方式
3. 每一种算法都会有很多不同的垃圾回收器去实现,在实际使用中,根据自己
的业务特点做出选择就好。

你可能感兴趣的:(面试小抄,面试)