1、Spring 中的 bean 是线程安全的吗?

1.1、结论

结论:不是线程安全的

Spring 容器本身并没有提供 Bean 的线程安全策略,因此可以说 Spring 容器中的 Bean 本身不具备线程安全的特性,但是具体还是要结合具体scope的Bean去研究。

1.2、Spring 中的 bean 作用域

  • singleton:单例,默认作用域。

对于单例Bean,所有线程都共享一个单例实例Bean,因此是存在资源的竞争。

  • prototype:原型,每次创建一个新对象。

如果单例Bean,是一个无状态 Bean,也就是线程中的操作不会对Bean的成员执行查询以外的操作,那么这个单例Bean是线程安全的。比如Spring mvc 的 Controller、Service、Dao等,这些Bean大多是无状态的,只关注于方法本身。

  • request:请求,每次Http请求创建一个新对象,适用于WebApplicationContext环境下。
  • session:会话,同一个会话共享一个实例,不同会话使用不用的实例。
  • global-session:全局会话,所有会话共享一个实例。

1.3、为什么 Spring 中 Controller 、Service 和 Dao 能保证线程安全?

因为上面提到的三个 bean 一般都是无状态(不保存数据)的,我们一般都只是调用其中的方法,而不用其存储数据,所以在多线程共享时能保证 Bean 是线程安全的。

如果 Bean 是有状态的,那么就需要开发人员自己来保证线程安全,对于将数据存储在成员变量的 bean 而言,我们可以通过改变其作用域来保证其线程安全,将作用域修改为 prototype 后,每次请求 bean 都会创建一个新的对象,这样就可以保证线程安全。

注意:如果使用以上提到的三个 bean 来存储数据(有状态),那么就是线程不安全的

2、TCP 的三次握手和四次挥手

2.1、三次握手(建立连接)

TCP 是7层网络协议中的传输层协议,负责数据的可靠传输。

在建立连接时,需要通过三次握手来连接,过程是

  • 客户端向服务端发送一个 SYN
  • 服务端收到 SYN 后,向客户端发送一个 SYN_ACK ,此时服务端明确自己的接收功能、客户端的发送功能没有问题。
  • 客户端接收到 SYN_ACK 后,再给服务端发送一个 ACK ,此时客户端明确自己的发送能力、接收能力没有问题,且服务端的发送和接收工作也可以正常进行,服务端接收到 ACK 后,双方都明确自己的首发功能正常,通信开始。

2.2、四次挥手

在断开连接时,需要通过四次挥手

  • 客户端向服务端发送一个 FIN 标志,表示即将断开连接
  • 服务端收到客户端发送的 FIN 后,向客户端发送一个 ACK ,表示收到断开连接的请求,客户端可以不再输送数据,而服务端需要处理可能存在的剩余数据。
  • 服务端向客户端发送一个 FIN ,表示服务端数据处理完毕,可以断开连接
  • 客户端收到服务端的 FIN 后,发送 ACK ,此时连接断开。

3、Spring 事务机制

  • Spring 事务底层基于数据库事务与 AOP 机制
  • 对于使用了 @Transactional 注解的 Bean ,Spring 会创建一个代理对象作为 bean
  • 当调用代理对象的方法时,会先判断该方法上是否添加了 @Transactional 注解
  • 如果添加了 @Transactional 注解,那么会利用事务管理器创建一个数据库连接对象,然后利用该数据库连接对象修改数据库连接的 autocommit 属性为 false ,禁止该连接的自动提交,这是实现 Spring 事务非常重要的一步。
  • 执行当前方法,如果执行过程中没有出现异常,那么直接提交事务。
  • 如果执行过程中出现异常且这个异常是需要进行回滚的异常,那么回滚事务,否则依然提交事务。
  • Spring 事务的隔离级别对应的就是数据库的隔离级别。
  • Spring 事务的传播机制是基于数据库连接来做的。

4、什么时候 @Transactional 失效?

4.1、 @Transactional 可以作用在哪些地方?

  • 作用于类

当把@Transactional 注解放在类上时,表示所有该类的public方法都配置相同的事务属性信息。

  • 作用于方法

当类配置了@Transactional,方法也配置了@Transactional,方法的事务会覆盖类的事务配置信息。

  • 作用于接口(不推荐)

不推荐这种使用方法,因为一旦标注在Interface上并且配置了Spring AOP 使用CGLib动态代理,将会导致@Transactional注解失效

4.2、 @Transactional 注解的属性

  • propagation属性

propagation 代表事务的传播行为,默认值为 Propagation.REQUIRED,其他的属性信息如下:

  1. Propagation.REQUIRED:如果当前存在事务,则加入该事务,如果当前不存在事务,则创建一个新的事务。**( 也就是说如果A方法和B方法都添加了注解,在默认传播模式下,A方法内部调用B方法,会把两个方法的事务合并为一个事务)**
  2. Propagation.SUPPORTS:如果当前存在事务,则加入该事务;如果当前不存在事务,则以非事务的方式继续运行。
  3. Propagation.MANDATORY:如果当前存在事务,则加入该事务;如果当前不存在事务,则抛出异常。
  4. Propagation.REQUIRES_NEW:重新创建一个新的事务,如果当前存在事务,暂停当前的事务。**( 当类A中的 a 方法用默认Propagation.REQUIRED模式,类B中的 b方法加上采用 Propagation.REQUIRES_NEW模式,然后在 a 方法中调用 b方法操作数据库,然而 a方法抛出异常后,b方法并没有进行回滚,因为Propagation.REQUIRES_NEW会暂停 a方法的事务 )**
  5. Propagation.NOT_SUPPORTED:以非事务的方式运行,如果当前存在事务,暂停当前的事务。
  6. Propagation.NEVER:以非事务的方式运行,如果当前存在事务,则抛出异常。
  7. Propagation.NESTED :和 Propagation.REQUIRED 效果一样。
  • isolation 属性

isolation :事务的隔离级别,默认值为 Isolation.DEFAULT (使用底层数据库默认的隔离级别。)

  • timeout 属性

timeout :事务的超时时间,默认值为 -1。如果超过该时间限制但事务还没有完成,则自动回滚事务。

  • readOnly 属性

readOnly :指定事务是否为只读事务,默认值为 false;为了忽略那些不需要事务的方法,比如读取数据,可以设置 read-only 为 true。

  • rollbackFor 属性

rollbackFor :用于指定能够触发事务回滚的异常类型,可以指定多个异常类型。

  • noRollbackFor属性

noRollbackFor:抛出指定的异常类型,不回滚事务,也可以指定多个异常类型。

4.3、@Transactional失效场景

  • @Transactional 应用在非 public 修饰的方法上

如果 Transactional 注解应用在非public修饰的方法上,Transactional将会失效。

protectedprivate 修饰的方法上使用 @Transactional 注解,虽然事务无效,但不会有任何报错,这是我们很容犯错的一点

  • @Transactional 注解属性 propagation 设置错误

如果 @Transactional 注解中的 propagation 设置为以下三个值,那么不会使用事务

  1. TransactionDefinition.PROPAGATION_SUPPORTS:如果当前存在事务,则加入该事务;如果当前没有事务,则以非事务的方式继续运行。
  2. TransactionDefinition.PROPAGATION_NOT_SUPPORTED:以非事务方式运行,如果当前存在事务,则把当前事务挂起。
  3. TransactionDefinition.PROPAGATION_NEVER:以非事务方式运行,如果当前存在事务,则抛出异常。
  • @Transactional 注解属性 rollbackFor 设置错误

rollbackFor 可以指定能够触发事务回滚的异常类型。Spring默认抛出了未检查 unchecked异常(继承自 RuntimeException 的异常)或者 Error 才回滚事务;

对于 checked 异常, Spring 不会回滚事务。

  • catch 异常导致 @Transactional 失效

  • 没有使用代理对象来调用添加了 @Transactional 注解的方法

Spring 事务是通过动态代理和数据库事务实现的,所以加了 @Transactional 注解的方法只有是被代理对象调用时,注解才会生效

如果没有使用代理对象,而是使用被代理对象来调用这个方法,那么 @Transactional 不会生效。

5、线程有几种状态?

5.1、线程的几种状态

线程通常有物种状态

  • 创建(NEW):新创建了一个线程对象

  • 就绪(Runnable):线程对象创建后,其他线程调用了该对象的 start() 方法,Runnable 的线程处于可运行线程池中,等待获取 CPU 的使用权

  • 运行(Running):就绪状态的线程获取了 CPU 的使用权,执行程序代码

  • 阻塞(Blocked):阻塞状态是线程因为某种原因放弃 CPU 使用权,暂时停止运行。直到线程进入就绪状态后才有机会转换到运行状态

  • 死亡(Dead):线程执行完了或因为异常退出了 run 方法,该线程结束生命周期

5.2、阻塞情况

线程的阻塞状态又可以分为三种情况

  • 等待阻塞:在运行中的线程执行 wait 方法后,该线程会释放占用的所有资源, JVM 会将该线程丢入 等待池 中。进入这个状态后的线程无法自动唤醒,必须依赖其他线程调用 notify 或者 notifyAll 方法才能被唤醒,wait 是 Object 类中的方法
  • 同步阻塞:运行的线程在获取对象的同步锁时,如果该同步锁被别的线程占用,那么 JVM 会将该线程放入 锁池
  • 其他阻塞:运行的线程执行 sleep 或者 join 方法时,或者发出 I/O 请求时, JVM 会把该线程置为阻塞状态,当 sleep 状态超时,join 等待线程终止或者超时,或者 IO 请求处理完毕时,线程重新转入就绪状态,sleep 方法在 Thread 类中。

5.3、锁池和等待池

  • 锁池

所有需要竞争同步锁的线程都会被放到锁池之中,比如当前对象的锁已经被其中一个线程得到,那么其他参与竞争该对象锁的线程会被放入锁池中进行等待,当前面获得锁的线程释放同步锁后,锁池中的线程会去竞争同步锁,当某个线程得到锁后就进行就绪队列等待 CPU 分配资源。

  • 等待池

当线程调用 wait() 方法后,该线程会被放入等待池中,在等待池中的线程不会去竞争同步锁,只有当其他线程调用了 notify 或者 notifyAll 方法后,等待池中的线程才会去竞争同步锁,需要注意的是,notify 是从等待池中随机唤醒一个线程,然后将其放到锁池,而 notifyAll 是将所有等待池中的线程一起拿出来放入锁池中。

5.4、sleep()、wait()、join()、yield() 方法的区别

  • sleep

sleep 方法是属于 Thread 类中的,sleep 过程中线程不会释放锁,只会阻塞线程,让出cpu给其他线程,但是他的监控状态依然保持着,当指定的时间到了又会自动恢复运行状态,可中断,sleep 给其他线程运行机会时不考虑线程的优先级,因此会给低优先级的线程以运行的机会

  • wait

wait 方法是属于 Object 类中的,wait 过程中线程会释放对象锁,只有当其他线程调用 notify 才能唤醒此线程。wait 使用时必须先获取对象锁,即必须在 synchronized 修饰的代码块中使用,那么相应的 notify 方法同样必须在 synchronized 修饰的代码块中使用,如果没有在 synchronized 修饰的代码块中使用时运行时会抛出 IllegalMonitorStateException 的异常

  • yield

和 sleep 一样都是 Thread 类的方法,都是暂停当前正在执行的线程对象不会释放资源锁,和 sleep 不同的是 yield方法并不会让线程进入阻塞状态,而是让线程重回就绪状态,它只需要等待重新获取CPU执行时间,所以执行 yield() 的线程有可能在进入到可执行状态后马上又被执行。还有一点和 sleep 不同的是 yield 方法只能使同优先级或更高优先级的线程有执行的机会

  • join

它是Thread类的对象实例的方法。join()方法会使当前线程等待调用join()方法的线程结束后才能继续执行。

如果在 main 线程中存在两行代码,即 t1.join(); 与 t2.join(); 且此时 t1 先抢到了时间片,那么只有等 t1 执行完全部代码后,才会执行 t2 ,最后再执行 main 线程中的剩下代码。

join 底层调用 wait()

6、Redis 为什么比 MySQL 快

  • Redis 是存储在内存中,而 MySQL 实际上存储在磁盘中。
  • Redis 存储的是 key-value 键值对,查找的事件复杂度是 O(n) ,而 MySQL 存储数据依靠数据引擎,常见的 MySQL 引擎(如 MyIsam、InnoDB) 底层使用了 B-TREE 来进行数据的存放或者查找,查找的事件复杂度为 O(logn)
  • MySQL 数据存储是存储在表中,查找数据时要先对表进行全局扫描或者根据索引查找,这涉及到磁盘的查找,磁盘查找如果是按条点查找可能会快点,但是顺序查找就比较慢;而Redis不用这么麻烦,本身就是存储在内存中,会根据数据在内存的位置直接取出。
  • Redis 是单线程的多路复用 IO,单线程避免了线程切换的开销,而多路复用 IO 避免了 IO 等待的开销,在多核处理器下提高处理器的使用效率可以对数据进行分区,然后每个处理器处理不同的数据。

7、分布式系统中常用的缓存方案有哪些?

7.1、客户端缓存

  • 页面和浏览器缓存
  • APP 缓存
  • H5 缓存
  • localStorage 和 sessionStorage

7.2、CDN 缓存

  • 内存存储:数据缓存

  • 内容分发:负载均衡

7.3、Nginx 缓存

静态资源(如页面静态化)

7.4、服务端缓存

  • 本地缓存
  • 外部缓存

7.5、服务端缓存

  • 数据库缓存

如 MyBatis 、 Hibernate 缓存

  • NoSQL 缓存

Redis、Memcached 等

  • 本地缓存

如 Java 程序中定义的 Map

8、缓存过期的策略

8.1、定时过期

每个设置过期时间的 key 都需要创建一个定时器,到过期时间就会立即清除

该策略可以立即清除过期的数据,对内存很友好;但会占用大量 CPU 资源去处理过期的数据,从而影响缓存的响应时间和吞吐量。

8.2、惰性过期

只有当访问一个 key 时,才会判断这个 key 是否过期,过期则清除

该策略可以最大化地节省 CPU 资源,但十分消耗内存,许多的过期数据都会存活在内存中,极端情况下可能出现大量过期 key 没有被再次访问,从而不会清除,占用大量内存地情况。

8.3、定期过期

每隔一段时间,会扫描一定数量的数据库的 expires 字典中一定数量的 key (随机),并且清除其中已经过期的 keys 。

该策略是定时过期和惰性过期的折中方案,通过调整定时扫描的时间间隔和每次扫描的限定耗时,可以在不同情况下使得 CPU 和内存资源达到最优的平衡效果。

8.4、Redis 的缓存过期策略

Redis 采用了后面两种缓存过期策略,即惰性过期和定期过期

9、如何保证数据库与缓存的一致性?

9.1、问题说明

由于缓存和数据库是分离开的,无法做到原子性的同时进行数据修改,可能出现缓存更新失败,或者数据库更新失败的情况,这个时候就会导致数据不一致。

9.2、几种常见的解决思路

  • 先更新数据库,然后更新缓存。

不足之处:缓存可能更新失败,导致读取到老数据

  • 先删除缓存,然后更新数据库。

不足之处:在并发时,读操作可能还是会将旧数据读回缓存。

  • 先更新数据库,再删除缓存

最经典的缓存 + 数据库读写模式:Cache Aside Pattern,在进行数据读取的时候,先读缓存,如果缓存没有,那么读数据库,同时取出数据放入缓存中;

更新时,先更新数据库然后删除缓存

不足之处:可能存在缓存删除失败的情况

10、Spring 中后置处理器的作用

Spring 中的后置处理器分为两种,即 BeanFactory 后置处理器Bean 后置处理器,它们是 Spring 底层源码架构中非常重要的一种机制,同时开发者也可以利用这两种后置处理器来进行扩展。

BeanFactory 后置处理器 表示针对 BeanFactory 的后置处理器,在创建一个 BeanFactory 后,使用该后置处理器来加工该 BeanFactory,比如说 Spring 的扫描就是基于 BeanFactory 后置处理器来实现的,而 Bean 后置处理器也类似,Spring 在创建一个 Bean 的过程中,会先实例化得到一个对象,然后再利用 Bean 后置处理器来对实例对象进行加工,比如说依赖注入就是基于 Bean 的后置处理器来实现的。

除依赖注入外, AOP 也是基于 Bean 的后置处理器来实现的。

11、如何破坏双亲委派机制

11.1、父子加载器之间的关系是继承吗?

JVM 中有三个重要的类加载器,分别是 Bootstrap ClassLoader 、 Ext ClassLoader 和 Application ClassLoader,在双亲委派机制中,类加载器之间的父子关系一般不会以 继承 的关系来实现,而是以组合的关系来复用父加载器中的代码。

1
2
3
4
public abstract class ClassLoader {
// The parent class loader for delegation
private final ClassLoader parent;
}

11.2、双亲委派机制是如何实现的?

双亲委派模型对于保证Java程序的稳定运作很重要,但它的实现并不复杂。

实现双亲委派的代码都集中在java.lang.ClassLoader的loadClass()方法之中

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
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// 1 检查类是否已经被加载过
Class<?> c = findLoadedClass(name);
// 2 如果没有加载
if (c == null) {
long t0 = System.nanoTime();
try {
// 判断这个类加载器是否有父加载器
if (parent != null) {
// 如果有就委托父加载器加载
c = parent.loadClass(name, false);
} else {
// 如果没有那么就默认使用BootStrap类加载器作为父加载器
c = findBootstrapClassOrNull(name);
}

} catch (ClassNotFoundException e) {
// 如果父类加载器加载失败,那么抛出 ClassNotFoundException 异常
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}

if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
// 然后调用自己的 findClass 方法进行查找
c = findClass(name);

// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}

11.3、如何破坏双亲委派机制?

从 11.2 可知,类加载过程是在 loadClass 方法中实现的,我们可以自定义一个类加载器,然后重写其中的 loadClass 方法,使其不遵守双亲委派规则即可破坏。

ClassLoader中和类加载有关的方法有很多,前面提到了loadClass,除此之外,还有findClass和defineClass等,那么这几个方法有什么区别呢?

  • loadClass() 就是主要进行类加载的方法,默认的双亲委派机制就实现在这个方法中。
  • findClass() 根据名称或位置加载 .class 字节码
  • definclass() 把字节码转化为Class

如果我们向自定义的类加载器不破坏双亲委派机制,那么可以只重写 findClass 方法

11.4、为什么 Tomcat 要破坏双亲委派机制?

  • Tomcat 是一个 web 容器,一个 web 容器中可能需要部署多个 应用。

不同的应用程序可能会依赖同一个第三方类库的不同版本,但是不同版本的类库中某一个类的全路径名可能是一样的。

比如说 AB 两个应用都要依赖 yilai.jar ,而 A 依赖的版本是 1.0,而 B 依赖的是 1.1 ,这两个版本的 yilai.jar 都有一个全路径全类名相同的类 com.yilai.Test.class

此时如果采用默认的双亲委派类加载机制,那么是无法加载多个相同的类。

Tomcat 破坏双亲委派机制,提供隔离的机制,为每个 web 容器单独创建一个 WebAppClassLoader 加载器。

  • Tomcat 的类加载机制?

为了实现隔离性,优先加载 Web 应用自己定义的类,所以没有遵照双亲委派的约定,每一个应用自己的类加载器——WebAppClassLoader负责加载本身的目录下的class文件,加载不到时再交给CommonClassLoader加载,这和双亲委派刚好相反。(遇到类先自己尝试加载,加载不到再交由 CommonClassLoader 加载)

12、锁的概念

12.1、不持有锁的线程会进入什么状态?

  • 忙等待(自旋)

这种状态对应的是自旋锁,即轻量级锁。

  • 进入等待池,在锁空闲时由操作系统调度等待池中的线程苏醒去持有锁

这种状态对应的是重量级锁,轻量级锁和重量级锁是相对的,重量级锁需要经过操作系统调度线程去持有锁。

12.2、轻量级锁效率一定比重量级锁高吗?

不一定,轻量级锁中线程自旋是需要消耗 CPU 资源的,当等待的线程数、线程执行的代码非常高时,那么会将绝大部分的 CPU 资源都消耗在自旋上,此时重量级锁的效率要高于轻量级锁,这是因为在等待池中的线程是不需要消耗 CPU 资源的。

不同场景使用不同的锁

13、synchronized

13.1、JDK 早期

在 JDK 早期,synchronized 直接使用重量级锁,将线程交由操作系统托管,所以效率非常低。

13.2、后续优化

在 JDK 6 后,为了提高性能,synchronized 引入了轻量级锁和偏向锁。

13.3、锁升级过程

锁的状态总共有四种:无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁(但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级)。

14、AtomicInteger 原子类

14.1、简介

  • 是什么?

JDK1.5之后的java.util.concurrent.atomic包里,多了一批原子处理类。AtomicBoolean、AtomicInteger、AtomicLong、AtomicReference。主要用于在高并发环境下的高效程序处理,来帮助我们简化同步处理.

AtomicInteger,一个提供原子操作的Integer的类。在Java语言中,++i和i++操作并不是线程安全的,在使用的时候,不可避免的会用到synchronized关键字。而AtomicInteger则通过一种线程安全的加减操作接口。

  • API
1
2
3
4
5
public final int get() //获取当前的值
public final int getAndSet(int newValue)//获取当前的值,并设置新的值
public final int incrementAndGet()//在现有值的基础上进行自增
public final int getAndDecrement() //获取当前的值,并自减
public final int getAndAdd(int delta) //获取当前的值,并加上预期的值

14.2、底层

  • 查看 AtomInteger 原子类的 incrementAndGet 方法
1
2
3
4
5
6
7
8
/**
* Atomically increments by one the current value.
*
* @return the updated value
*/
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}

可以看到其底层调用了 Unsafe 对象的 getAndAddInt 方法

  • unsafe.getAndAddInt
1
2
3
4
5
6
7
8
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

return var5;
}

可以看到 AtomInteger 原子类的 incrementAndGet 方法底层使用了自旋锁(CAS)来保证变量的原子自增。

Unsafe 类中的 compareAndSwapInt 方法是一个使用 native 关键字修饰的方法。