今天要不来聊聊HTTP吧

今天要不来聊聊HTTP吧?

  • HTTP「协议」是客户端和服务器「交互」的一种通迅的格式
    • 所谓的「协议」实际上就是双方约定好的「格式」,让双方都能看得懂的东西而已
    • 所谓的交互实际上就是「请求」和「响应」

那你知道HTTP各个版本之间的区别吗?

  • HTP1.0默认是短连接,每次与服务器交互,都需要新开一个连接
  • HTTP1.1版本最主要的是「默认持久连接」。只要客户端服务端没有断开TCP连接,就一直保持连接,可以发送多次HTTP请求
  • 其次就是「断点续传」( Chunked transfer-coding)。利用HTTP消息头使用分块传输编码,将实体主体分块进行传输
  • HTTP/2不再以文本的方式传输,采用「二进制分帧层」,对头部进行了「压缩」,支持「流控」,最主要就是HTTP/2是支持「多路复用」的(通过单一的TCP连接「并行」发起多个的请求和响应消息)
  • HTTP/3跟前面版本最大的区别就是:HTTP1.x和HTTP/2底层都是TCP,而HTTP/3底层是UDP。使用HTTP/3能够減少RTT「往返时延」(TCP三次握手,TLS握手)

嗯,稍微打断下。我知道HTTP1.1版本有个管线化( pipelining)理论,但默认是关闭的。管线化这个跟HTTP/2的「多路复用」是很类似的,它们有什么区别呀?

  • HTTP1.1提出的「管线化」只能「串行」(一个响应必须完全返回后,下个请求才会开始传输)

  • HTTP/2多路复用则是利用「分帧」数据流,把HTTP协议分解为「互不依赖」的帧(为每个帧「标序」发送,接收回来的时候按序重组),进而可以「乱序」发送避免「一定程度上」的队首阻塞问题

  • 但是,无论是HTTP1.1还是HTTP/2,respanel响应的「处理顺序」总是需要跟request请求顺序保持一致的。假如某个请求的 response响应慢了,还是同样会有阻塞的问题

  • 这受限于HTTP底层的传输协议是TCP,没办法完全解決「线头阻塞」的问题

那你了解HTPS的过程吗?

  • 对于HTTPS,我的理解下:就是「安全」的HTTP协议(客户端与服务端的传输链路中进行加密)

  • HTPS首先要解決的是:认证的问题

    • 客户端是需要确切地知道服务端是不是「真实」,所以在HTPS中会有一个角色:CA(公信机构)
    • 服务端在使用HTTPSI前,需要去认证的CA机构申请一份「数字证书」。数字证书里包含有证书持有者、证书有效期「服务器公钥」等信息
    • CA机构也有自己的一份公私钥,在发布数字证书之前,会用自己的「私钥」对这份数字证书进行加密
    • 等到客户端请求服务器的时候,服务端返回证书给客户端。客户端用CA的公钥对证书解密(因为CA是公信机构,会内置到浏览器或操作系统中,所以客户端会有公钥)。这个时候,客户端会判断这个「证书是否可信有无被簒改」
    • 私钥加密,公钥解密我们叫做「数字签名」(这种方式可以查看有无被簒改)
    • 到这里,就解决了「认证」的问题,至少客户端能保证是在跟「真实的服务器」进行通信。

  • 保密问题

    • 客户端与服务器的通讯内容在传输中不会泄露给第三方
    • 客户端从CA拿到数字证书后,就能拿到服务端的公钥
    • 客户端生成一个Key作为「对称加密」的秘钥,用服务端的「公钥加密」传给服务端
    • 服务端用自己的「私钥解密」客户端的数据,得到对称加密的秘钥
    • 之后客户端与服务端就可以使用「对称加密的秘钥」愉快地发送和接收消息

l

3、【对线面试官】Java NIO

这次咱们就来聊聊Java的NIO呗?你对NIO有多少了解?

  1. 嗯,我对Java NIO还是有一定的了解的,NIO是JDK1.4开始有的,其目的是为了提高速度。NIO翻译成no-blockingo或者newio都无所谓啦,反正都说得通

在真实项目中写过NIO相关

  1. 这块在我所负责的系统中,一般用不上N10,要不我跟你讲讲NIO相关的知识点呗?

可以吧,你先来讲讲NIO和传统IO有什么区别吧

  1. 传统IO是一次一个字节地处理数据,NIO是以块(缓冲区)的形式处理数据。最主要的是,NIO可以实现非阻塞,而传统IO只能是阻塞的。
  2. IO的实际场景是文件IO和网络IO,NIO 在网络IO场景下提升就尤其明显了。
  3. 在Java NIO有三个核心部分组成。分别是Buffer(缓冲区)、Channel(管道)以及Selector(选择器)
  4. 可以简单的理解为:Buffer是存储数据的地方,Channel是运输数据的载体,而Selector用于检查多个Channel的状态变更情况,

有写过相关的Demo代码吗?

  1. 我曾经写过一个NIO Demo,面试官可以看看。

  2. 大概的实现就是:服务端接收图片后保存,能够通知客户端已经收到图片。而客户端发送图片给客户端,并接收服务端的响应

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    //服务端
    public class NoBlockServer {

    public static void main(String[] args) throws IOException {

    // 1.获取通道
    ServerSocketChannel server = ServerSocketChannel.open();

    // 2.切换成非阻塞模式
    server.configureBlocking(false);

    // 3. 绑定连接
    server.bind(new InetSocketAddress(6666));

    // 4. 获取选择器
    Selector selector = Selector.open();

    // 4.1将通道注册到选择器上,指定接收“监听通道”事件
    server.register(selector, SelectionKey.OP_ACCEPT);

    // 5. 轮训地获取选择器上已“就绪”的事件--->只要select()>0,说明已就绪
    while (selector.select() > 0) {
    // 6. 获取当前选择器所有注册的“选择键”(已就绪的监听事件)
    Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();

    // 7. 获取已“就绪”的事件,(不同的事件做不同的事)
    while (iterator.hasNext()) {

    SelectionKey selectionKey = iterator.next();

    // 接收事件就绪
    if (selectionKey.isAcceptable()) {

    // 8. 获取客户端的链接
    SocketChannel client = server.accept();

    // 8.1 切换成非阻塞状态
    client.configureBlocking(false);

    // 8.2 注册到选择器上-->拿到客户端的连接为了读取通道的数据(监听读就绪事件)
    client.register(selector, SelectionKey.OP_READ);

    } else if (selectionKey.isReadable()) { // 读事件就绪

    // 9. 获取当前选择器读就绪状态的通道
    SocketChannel client = (SocketChannel) selectionKey.channel();

    // 9.1读取数据
    ByteBuffer buffer = ByteBuffer.allocate(1024);

    // 9.2得到文件通道,将客户端传递过来的图片写到本地项目下(写模式、没有则创建)
    FileChannel outChannel = FileChannel.open(Paths.get("2.png"), StandardOpenOption.WRITE, StandardOpenOption.CREATE);

    while (client.read(buffer) > 0) {
    // 在读之前都要切换成读模式
    buffer.flip();

    outChannel.write(buffer);

    // 读完切换成写模式,能让管道继续读取文件的数据
    buffer.clear();
    }
    }
    // 10. 取消选择键(已经处理过的事件,就应该取消掉了)
    iterator.remove();
    }
    }

    }
    }

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    //客户端
    public class NoBlockClient {

    public static void main(String[] args) throws IOException {

    // 1. 获取通道
    SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("127.0.0.1", 6666));

    // 1.1切换成非阻塞模式
    socketChannel.configureBlocking(false);

    // 1.2获取选择器
    Selector selector = Selector.open();

    // 1.3将通道注册到选择器中,获取服务端返回的数据
    socketChannel.register(selector, SelectionKey.OP_READ);

    // 2. 发送一张图片给服务端吧
    FileChannel fileChannel = FileChannel.open(Paths.get("X:\\Users\\ozc\\Desktop\\面试造火箭\\1.png"), StandardOpenOption.READ);

    // 3.要使用NIO,有了Channel,就必然要有Buffer,Buffer是与数据打交道的呢
    ByteBuffer buffer = ByteBuffer.allocate(1024);

    // 4.读取本地文件(图片),发送到服务器
    while (fileChannel.read(buffer) != -1) {

    // 在读之前都要切换成读模式
    buffer.flip();

    socketChannel.write(buffer);

    // 读完切换成写模式,能让管道继续读取文件的数据
    buffer.clear();
    }


    // 5. 轮训地获取选择器上已“就绪”的事件--->只要select()>0,说明已就绪
    while (selector.select() > 0) {
    // 6. 获取当前选择器所有注册的“选择键”(已就绪的监听事件)
    Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();

    // 7. 获取已“就绪”的事件,(不同的事件做不同的事)
    while (iterator.hasNext()) {

    SelectionKey selectionKey = iterator.next();

    // 8. 读事件就绪
    if (selectionKey.isReadable()) {

    // 8.1得到对应的通道
    SocketChannel channel = (SocketChannel) selectionKey.channel();

    ByteBuffer responseBuffer = ByteBuffer.allocate(1024);

    // 9. 知道服务端要返回响应的数据给客户端,客户端在这里接收
    int readBytes = channel.read(responseBuffer);

    if (readBytes > 0) {
    // 切换读模式
    responseBuffer.flip();
    System.out.println(new String(responseBuffer.array(), 0, readBytes));
    }
    }

    // 10. 取消选择键(已经处理过的事件,就应该取消掉了)
    iterator.remove();
    }
    }
    }

    }

    就考考相关的概念原理呗

    你知道IO模型有几种吗

    1. 在Unix下IO模型分别有:阻塞IO、非阻塞IO、IO复用、信号驱动以及异步I/0。在开发中碰得最多的就是阻塞I0、非阻塞IO以及IO复用。

    来重点讲讲|O复用模型吧

    1. 我就以Linux系统为例好了,我们都知道Linux对文件的操作实际上就是通过文件描述符(fd)
    2. 1O复用模型指的就是:通过一个进程监听多个文件描述符,一旦某个文件描述符准备就绪,就去通知程序做相对应的处理
    3. 这种以通知的方式,优势并不是对于单个连接能处理得更快,而是在于它能处理更多的连接。
    4. 在Linux下IO复用模型用的函数有select/poll和epoll

    那你来讲讲这select和epll函数的区别呗?

    • select

      1. select函数它支持最大的连接数是1024或 2048,因为在select函数下要传入fd_set参数,这个fd_set的大小要么1024或2048(其实就看操作系统的位数)
      2. fd_set就是bitmap的数据结构,可以简单理解为只要位为0,那说明还没数据到缓冲区,只要位为1,那说明数据已经到缓冲区。
      3. 而select函数做的就是每次将fd_set遍历,判断标志位有没有发现变化,如果有变化则通知程序做中断处理。
    • epoll

      1. epoll是在Linux2.6内核正式提出,完善了select的一些缺点。
      2. 它定义了epoll_event结构体来处理,不存在最大连接数的限制。
      3. 并且它不像select函数每次把所有的文件描述符(fd)都遍历,简单理解就是epoll把就绪的文件描述符(fd)专门维护了一块空间,每次从就绪列表里边拿就好了,不再进行对所有文件描述符(fd)进行遍历。

嗯,了解了,另外你知道什么叫做零拷贝吗?

  1. 知道的。我们以读操作为例,假设用户程序发起一次读请求。
  2. 其实会调用read相关的「系统函数」,然后会从用户态切换到内核态,随后CPU会告诉DMA去磁盘把数据拷贝到内核空间。
  3. 等到「内核缓冲区」真正有数据之后,CPU会把「内核缓存区」数据拷贝到「用户缓冲区」,最终用户程序才能获取到。
  4. 稍微解释一下上面的意思~
  5. 为了保证内核的安全,操作系统将虚拟空间划分为「用户空间」和「内核空间」,所以在读系统数据的时候会有状态切换
  6. 因为应用程序不能直接去读取硬盘的数据。从上面描述可知读写需要依赖「内核缓冲区」
  7. 一次读操作会让DMA拷贝(direct memory access 直接内存拷贝,不使用cpu)将磁盘数据拷贝到内核缓冲区,CPU将内核缓冲区数据拷贝到用户缓冲区。
  8. 所谓的零拷贝就是将「CPU将内核缓冲区数据拷贝到用户缓冲区」这次CPU拷贝给省去,来提高效率和性能
  9. 常见的零拷贝技术有mmap(内核缓冲区与用户缓冲区的共享) 、sendfile(系统底层函数支持)。
  10. 零拷贝可以提高数据传输的性能,这块在Kafka等框架也有相关的实践。
l

4、【对线面试官】Java反射 & 动态代理.md

今天要不来聊聊Java反射?你对Java反射了解多少?

  1. 嗯,Java反射在JavaSE基础中还是很重要的。
  2. 简单来说,反射就是Java可以给我们在运行时获取类的信息
    在初学的时候可能看不懂、又或是学不太会反射,因为初学的时候往往给的例子都是用反射创建对象,用反射去获取对象上的方法/属性什么的,感觉没多大用
  3. 但毕竟我已经不是以前的我了,跟以前的看法就不一样了。
  4. 理解反射重点就在于理解什么是「运行时」,为什么我们要在「运行时」获取类的信息
  5. 在当时学注解的时候,我们可以发现注解的生命周期有三个枚举值(当时我还告诉过面试官你呢~)
  6. 分别是SOURCE、CLASS和RUNTIME,其实一样的,RUNTIME就是对标着运行时
  7. 我们都知道:我们在编译器写的代码是j ava文件,经过javac编译会变成.class文件,class文件会被JVM装载运行(这里就是真正运行着我们所写的代码(虽然是被编译过的),也就所谓的运行时。

嗯,你说了那么多,就讲述了什么是运行时,还是快点进入重点吧

  1. 在运行时获取类的信息,其实就是为了让我们所写的代码更具有「通用性」和「灵活性」
  2. 要理解反射,需要抛开我们日常写的业务代码。以更高的维度或者说是抽象的思维去看待我们所写的“工具”
  3. 所谓的“工具”:在单个系统使用叫做“Utils”、被多个系统使用打成jar包叫做“组件”、组件继续发展壮大就叫做“框架”
  4. 一个好用的“工具”是需要兼容各种情况的。
  5. 你肯定是不知道用该“工具“的用户传入的是什么对象,但你需要帮他们得到需要的结果。
  6. 例如SpringMVC你在方法上写上对象,传入的参数就会帮你封装到对象上
  7. Mybatis可以让我们只写接口,不写实现类,就可以执行SQL
  8. 你在类上加上@Component注解,Sprin g就帮你创建对象
  9. 这些统统都有反射的身影:约定大于配置,配置大于硬编码。
  10. 通过”约定“使用姿势,使用反射在运行时获取相应的信息(毕竟作为一个”工具“是真的不知道你是怎么用的),实现代码功能的「通用性」和「灵活性」

结合之前说的泛型,想问下:你应该知道泛型是会擦除的,那为什么反射能获取到泛型的信息呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 抽象类,定义泛型<T>
public abstract class BaseDao<T> {
public BaseDao(){
Class clazz = this.getClass();
ParameterizedType pt = (ParameterizedType) clazz.getGenericSuperclass();
clazz = (Class) pt.getActualTypeArguments()[0];
System.out.println(clazz);
}
}

// 实现类
public class UserDao extends BaseDao<User> {
public static void main(String[] args) {
BaseDao<User> userDao = new UserDao();

}
}
// 执行结果输出
class com.entity.User
  1. 嗯,这个问题我在学习的时候也想过
  2. 其实是这样的,可以理解为泛型擦除是有范围的,定义在类上的泛型信息是不会被擦除的。
  3. Java编译器仍在class文件以Signature 属性的方式保留了泛型信息
  4. Type作为顶级接口,Type下还有几种类型,比如TypeVariable、 ParameterizedT ype、 WildCardType、 GenericArrayType、以及Class。通过这些接口我们就可以在运行时获取泛型相关的信息。

你了解动态代理吗?

  1. 嗯,了解的。动态代理其实就是代理模式的一种,代理模式是设计模式之一。
  2. 代理模型有静态代理和动态代理。静态代理需要自己写代理类,实现对应的接口,比较麻烦。
  3. 在Java中,动态代理常见的又有两种实现方式:JDK动态代理和CGLIB代理
  4. JDK动态代理其实就是运用了反射的机制,而CGLIB代理则用的是利用ASM框架,通过修改其字节码生成子类来处理。
  5. JDK动态代理会帮我们实现接口的方法,通过invokeHandler对所需要的方法进行增强。
  6. 动态代理这一技术在实际或者框架原理中是非常常见的
  7. 像上面所讲的Mybatis不用写实现类,只写接口就可以执行SQL,又或是SpringAOP等等好用的技术,实际上用的就是动态代理。
l

5、【对线面试官】多线程基础

首先你来讲讲进程和线程的区别吧?

  1. 进程是系统进行资源分配和调度的独立单位,每一个进程都有它自己的内存空间和系统资源
  2. 进程实现多处理机环境下的进程调度,分派,切换时,都需要花费较大的时间和空间开销
  3. 为了提高系统的执行效率,减少处理机的空转时间和调度切换的时间,以及便于系统管理,所以有了线程,线程取代了进程了调度的基本功能
  4. 简单来说,进程作为资源分配的基本单位,线程作为资源调度的基本单位

那我们为什么要用多线程呢?你平时工作中用得多吗?

  1. 使用多线程最主要的原因是提高系统的资源利用率。
  2. 现在CPU基本都是多核的,如果你只用单线程,那就是只用到了一个核心,其他的核心就相当于空闲在那里了。
  3. 在平时工作中多线程是随时都可见的。
  4. 比如说,我们系统Web服务器用的是Tomcat,Tomcat处理每一个请求都会从线程连接池里边用一个线程去处理。
  5. 又比如说,我们用连接数据库会用对应的连接池Druid/C3P0/DBCP等等
  6. 等等这些都用了多线程的。
  7. 上面这些框架已经帮我们屏蔽掉「手写」多线程的问题

嗯,了解,那你实际开发中有用过吗?

  1. 当然有了,在我所负责的系统也会用到多线程的。

  2. 比如说,现在要跑一个定时任务,该任务的链路执行时间和过程都非常长,我这边就用一个线程池将该定时任务的请求进行处理。

  3. 这样做的好处就是可以及时返回结果给调用方,能够提高系统的吞吐量。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // 请求直接交给线程池来处理
    public void push(PushParam pushParam) {
    try {
    pushServiceThreadExecutor.submit(() -> {
    handler(pushParam);
    });
    } catch (Exception e) {
    logger.error("pushServiceThreadExecutor error, exception{}:", e);
    }
    }
  4. 还有就是我的系统中用了很多生产者与消费者模式,会用多个线程去消费队列的消息,来提高并发度

你如果在项目中用到了多线程,那肯定得考虑线程安全的问题的吧

  1. 在我的理解下,在Java世界里边,所谓线程安全就是多个线程去执行某类,这个类始终能表现出正确的行为,那么这个类就是线程安全的。
  2. 比如我有一个count变量,在service方法不断的累加这个count变量。
  3. 假设相同的条件下,count变量每次执行的结果都是相同,那我们就可以说是线程安全的干
  4. 显然下面的代码肯定不是线程安全的
1
2
3
4
5
6
7
8
9
10
11
12
13
public class UnsafeCountingServlet extends GenericServlet implements Servlet {
private long count = 0;

public long getCount() {
return count;
}

public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException {

++count;
// To something else...
}
}

那你平时是怎么解决,或者怎么思考线程安全问题的呢?

  1. 其实大部分时间我们在代码里边都没有显式去处理线程安全问题,因为这大部分都由框架所做了。
  2. 正如上面提到的Tomcat、Druid、SpringMVC等等。
  3. 很多时候,我们判断是否要处理线程安全问题,就看有没有多个线程同时访问一个共享变量。
  4. 像SpringMVC这种,我们日常开发时,不涉及到操作同一个成员变量,那我们就很少需要考虑线程安全问题。
  5. 我个人解决线程安全问题的思路有以下:
    • 能不能保证操作的原子性,考虑atomi c包下的类够不够我们使用。
    • 能不能保证操作的可见性,考虑volatil e关键字够不够我们使用
    • 如果涉及到对线程的控制(比如一次能使用多少个线程,当前线程触发的条件是否依赖其他线程的结果),考虑CountDownLatch/Semaphore等等。
    • 如果是集合,考虑java.util.concurrent 包下的集合类。
    • 如果synchronized无法满足,考虑lock 包下的类
  6. 总的来说,就是先判断有没有线程安全问题,如果存在则根据具体的情况去判断使用什么方式去处理线程安全的问题。
  7. 虽然synchronized很牛逼,但无脑使用synchronized会影响我们程序的性能的。

死锁你了解吗?什么情况会造成死锁?要是你能给我讲清楚死锁,我就录取你了

  1. 要是你录取我,我就给你讲清楚死锁
  2. 造成死锁的原因可以简单概括为:当前线程拥有其他线程需要的资源,当前线程等待其他线程已拥有的资源,都不放弃自己拥有的资源。
  3. 避免死锁的方式一般有以下方案:
    • 固定加锁的顺序,比如我们可以使用Hash值的大小来确定加锁的先后
    • 尽可能缩减加锁的范围,等到操作共享变量的时候才加锁。
    • 使用可释放的定时锁(一段时间申请不到锁的权限了,直接释放掉)

价值体现

嗯,其实我想问,就是我要是..去到贵公司是做什么内容?还有就是..

l

7、【对线面试官】synchronized

今天我们来聊聊synchronized吧?

  1. synchronized是一种互斥锁,一次只能允许一个线程进入被锁住的代码块
  2. synchronized是Java的一个关键字,它能够将代码块/方法锁起来
  3. 如果synchronized修饰的是实例方法,对应的锁则是对象实例
  4. 如果synchronized修饰的是静态方法,对应的锁则是当前类的Class实例
  5. 如果synchronized修饰的是代码块,对应的锁则是传入synchronized的对象实例

嗯,要不你来讲讲synchronized的原理呗?

  1. 通过反编译可以发现
  2. 当修饰方法时,编译器会生成ACC_SYNCHRONIZED关键字用来标识
  3. 当修饰代码块时,会依赖monitorenter和monitorexit指令
  4. 但前面已经说了,无论synchronized修饰的是方法还是代码块,对应的锁都是一个实例(对象)
  5. 在内存中,对象一般由三部分组成,分别是对象头、对象实际数据和对齐填充
  6. 重点在于对象头,对象头又由几部分组成,但我们重点关注对象头Mark Word的信息就好了
  7. Mark Word会记录对象关于锁的信息
  8. 又因为每个对象都会有一个与之对应的monitor对象,monitor对象中存储着当前持有锁的线程以及等待锁的线程队列
  9. 了解Mark Word和monitor对象是理解synchronized原理的前提

嗯,听说synchronized锁在JDK1.6之后做了很多的优化,这块你了解多少呢?

  1. 其实是这样的,在JDK1.6之前是重量级锁,线程进入同步代码块/方法时
  2. monitor对象就会把当前进入线程的Id进行存储,设置Mark Word的monitor对象地址,并把阻塞的线程存储到monitor的等待线程队列中
  3. 它加锁是依赖底层操作系统的mutex相关指令实现,所以会有用户态和内核态之间的切换,性能损耗十分明显
  4. 而JDK1.6以后引入偏向锁和轻量级锁在JVM层面实现加锁的逻辑,不依赖底层操作系统,就没有切换的消耗
  5. 所以,Mark Word对锁的状态记录一共有4种:无锁、偏向锁、轻量级锁和重量级锁

简单来说说偏向锁、轻量级锁和重量级锁吧

  1. 偏向锁指的就是JVM会认为只有某个线程才会执行同步代码(没有竞争的环境)
  2. 所以在Mark Word会直接记录线程ID,只要线程来执行代码了,会比对线程ID是否相等,相等则当前线程能直接获取得到锁,执行同步代码
  3. 如果不相等,则用CAS来尝试修改当前的线程ID,如果CAS修改成功,那还是能获取得到锁,执行同步代码
  4. 如果CAS失败了,说明有竞争环境,此时会对偏向锁撤销,升级为轻量级锁。
  5. 在轻量级锁状态下,当前线程会在栈帧下创建Lock Record,LockRecord会把 Mark Word的信息拷贝进去,且有个Owner指针指向加锁的对象由由
  6. 线程执行到同步代码时,则用CAS试图将Mark Word的指向到线程栈帧的LockRecord,假设CAS修改成功,则获取得到轻量级锁
  7. 假设修改失败,则自旋(重试),自旋一定次数后,则升级为重量级锁
  8. 简单总结一下
    • synchronized锁原来只有重量级锁,依赖操作系统的mutex指令,需要用户态和内核态切换,性能损耗十分明显
    • 重量级锁用到monitor对象而偏向锁则在Mark Word记录线程ID进行比对、轻量级锁则是拷贝Mark Word到Lock Record,用CAS+自旋的方式获取。
  9. 引入了偏向锁和轻量级锁,就是为了在不同的使用场景使用不同的锁,进而提高效率。
    锁只有升级,没有降级
    • 只有一个线程进入临界区,偏向锁
    • 多个线程交替进入临界区,轻量级锁
    • 多线程同时进入临界区,重量级锁
l

8、【对线面试官】AQS & ReentrantLock

今天我们来聊聊lock锁吧?

你知道什么叫做公平和非公平锁吗

  1. 公平锁指的就是:在竞争环境下,先到临界区的线程比后到的线程一定更快地获取得到锁
  2. 那非公平就很好理解了:先到临界区的线程未必比后到的线程更快地获取得到锁

如果让你实现的话,你怎么实现公平和非公平锁?

  1. 公平锁可以把竞争的线程放在一个先进先出的队列上
  2. 只要持有锁的线程执行完了,唤醒队列的下一个线程去获取锁就好了
  3. 非公平锁的概念上面已经提到了:后到的线程可能比前到临界区的线程获取得到锁
  4. 那实现也很简单,线程先尝试能不能获取得到锁,如果获取得到锁了就执行同步代码了
  5. 如果获取不到锁,那就再把这个线程放到队列呗
  6. 所以公平和非公平的区别就是:线程执行同步代码块时,是否会去尝试获取锁。
  7. 如果会尝试获取锁,那就是非公平的。如果不会尝试获取锁,直接进队列,再等待唤醒,那就是公平的。

为什么要进队列呢?线程一直尝试获取锁不就行了么?

  1. 一直尝试获取锁,专业点就叫做自旋,需要耗费资源的。
  2. 多个线程一直在自旋,而且大多数都是竞争失败的,哪有人会这样实现的
  3. 不会吧,不会吧,你不会就是这样实现的吧

那上次面试所问的synchronized锁是公平的还是非公平的?

  1. 非公平的。
  2. 偏向锁很好理解,如果当前线程ID与markword存储的不相等,则CAS尝试更换线程ID,CAS成功就获取得到锁了
  3. CAS失败则升级为轻量级锁
  4. 轻量级锁实际上也是通过CAS来抢占锁资源(只不过多了拷贝Mark Word到Lock Record的过程)
  5. 抢占成功到锁就归属给该线程了,但自旋失败一定次数后升级重量级锁
  6. 重量级锁通过monitor对象中的队列存储线程,但线程进入队列前,还是会先尝试获取得到锁,如果能获取不到才进入线程等待队列中
  7. 综上所述,synchronized无论处理哪种锁,都是先尝试获取,获取不到才升级||放到队列上的,所以是非公平的

嗯,讲得挺仔细的。AQS你了解吗?

  1. 嗯嗯,AQS全称叫做AbstractQueuedSynchronizer
  2. 是可以给我们实现锁的一个 「框架」,内部实现的关键就是维护了一个先进先出的队列以及state状态变量
  3. 先进先出队列存储的载体叫做Node节点,该节点标识着当前的状态值、是独占还是共享模式以及它的前驱和后继节点等等信息
  4. 简单理解就是:AQS定义了模板,具体实现由各个子类完成。
  5. 总体的流程可以总结为:会把需要等待的线程以Node的形式放到这个先进先出的队列上,state变量则表示为当前锁的状态。
  6. 像ReentrantLock、 ReentrantReadWrite Lock、 CountDownLatch、 Semaphore 这些常用的实现类都是基于AQS实现的
  7. AQS支持两种模式:独占(锁只会被一个线程独占)和共享(多个线程可同时执行)

你以ReentrantLock来讲讲加锁和解锁的过程呗

  • 以非公平锁为了,我们在外界调用lock方法的时候,源码是这样实现的
    1. CAS尝试获取锁,获取成功则可以执行同步代码
    2. CAS获取失败,则调用acquire方法acquire方法实际上就是AQS的模板方法
    3. acquire首先会调用子类的tryAcquire 方法(又回到了ReentrantLock中)
    4. tryAcquire方法实际上会判断当前的state是否等于0,等于0说明没有线程持有锁,则又尝试CAS直接获取锁
    5. 如果CAS获取成功,则可以执行同步代码
    6. 如果CAS获取失败,那判断当前线程是否就持有锁,如果是持有的锁,那更新state的值,获取得到锁(这里其实就是处理可重入的逻辑)
    7. CAS失败&&非重入的情况,则回到try Acquire方法执行「入队列」的操作
    8. 将节点入队列之后,会判断「前驱节点」是不是头节点,如果是头结点又会用CAS尝试获取锁
    9. 如果是「前驱节点」是头节点并获取得到锁,则把当前节点设置为头结点,并且将前驱节点置空(实际上就是原有的头节点已经释放锁了)
    10. 没获取得到锁,则判断前驱节点的状态是否为SIGNAL,如果不是,则找到合法的前驱节点,并使用CAS将状态设置为SIGNAL
    11. 最后调用park将当前线程挂起

你说了一大堆,麻烦使用压缩算法压缩下加锁的过程。

压缩后:当线程CAS获取锁失败,将当前线程入队列,把前驱节点状态设置为SIGNAL状态,并将自己挂起。

为什么要设置前驱节点为 SIGNAL状态,有啥用?

  1. 其实就是表示后继节点需要被唤醒,你咋啥都不知道啊?跟你沟通有点烦.我先把解锁的过程说下吧
    • 外界调用unlock方法时,实际上会调用AQS的release方法,而release方法会调用子类tryRelease方法(又回到了ReentrantLock中)
    • tryRelease会把state一直减(锁重入可使state>1),直至到0,说明当前线程已经把锁释放了
    • 随后从队尾往前找节点状态需要<0,并离头节点最近的节点进行唤醒
  2. 唤醒之后,被唤醒的线程则尝试使用CAS获取锁,假设获取锁得到则把头节点给干掉,把自己设置为头节点成
  3. 解锁的逻辑非常简单哈
  4. 压缩一下:把state置0,唤醒头结点下一个合法的节点,被唤醒的节点线程自然就会去获取锁
  5. 回到上一个问题,为什么要设置前驱节点为SIGNAL状态
  6. 其实归终结底就是为了判断节点的状态,去做些处理。
  7. Node中节点的状态有4种,分别是:CA NCELLED(1)、 SIGNAL(-1)、 CONDITI ON(-2)、 PROPAGATE(-3)和0。
  8. 在ReentrantLock解锁的时候,会判断节点的状态是否小于0,小于等于0才说明需要被唤醒
  9. 另外一提的是:公平锁的实现与非公平锁是很像的,只不过在获取锁时不会直接尝试使用CAS来获取锁。
  10. 只有当队列没节点并且state为0时才会去获取锁,不然都会把当前线程放到队列中

流程图

l

6、【对线面试官】CAS

今天我们来聊聊CAS吧?你对CAS了解多少?

  1. CAS的全称为compare and swap,比较并交换
  2. 虽然翻译过来是「比较并交换」,但它是一个原子性的操作,对应到CPU指令为cmpxchg
  3. cpu指令你都知道?->这没什么,都是背的。
  4. 回到CAS上吧,CAS的操作其实非常简单。
  5. CAS有三个操作数:当前值A、内存值V、要修改的新值B
  6. 假设当前值A跟内存值V相等,那就将内存值V改成B
  7. 假设当前值A跟内存值V不相等,要么就重试,要么就放弃更新
  8. 将当前值与内存值进行对比,判断是否有被修改过,这就是CAS的核心

确实,那为什么要用CAS呢?

  1. 嗯,要讲到CAS就不得不说synchronized锁了,它是Java锁..然后..
  2. ok,其实就是synchronized锁每次只会让一个线程去操作共享资源
  3. 而CAS相当于没有加锁,多个线程都可以直接操作共享资源,在实际去修改的时候才去判断能否修改成功
  4. 在很多的情况下会synchronized锁要高效很多
  5. 比如,对一个值进行累加,就没必要使用synchronized锁,使用juc包下的Atomic类就足以。

了解,那你知道CAS会有什么缺点吗?

  1. CAS有个缺点就是会带来ABA的问题
  2. 从CAS更新的时候,我们可以发现它只比对当前值和内存值是否相等,这会带来个问题,下面我举例说明下:
  3. 假设线程A读到当前值是10,可能线程B把值修改为100,然后线程C又把值修改为10。
  4. 等到线程A拿到执行权时,因为当前值和内存值是一致的,线程A是可以修改的!
  5. 站在线程A的角度来说,这个值是从未被修改的(:
  6. 这是不合理的,因为我们从上帝的角度来看,这个变量已经被线程B和线程C修改过了。
  7. 这就是所谓的ABA问题
  8. 要解决ABA的问题,Java也提供了AtomicStampedReference类供我们用,说白了就是加了个版本,比对的就是内存值+版本是否一致

阿里巴巴开发手册提及到推荐使用LongAdder对象,比AtomicLong性能更好(减少乐观锁的重试次数)

  1. AtomicLong做累加的时候实际上就是多个线程操作同一个目标资源
  2. 在高并发时,只有一个线程是执行成功的,其他的线程都会失败,不断自旋(重试),自旋会成为瓶颈
  3. 而LongAdder的思想就是把要操作的目标资源「分散」到数组Cell中
  4. 每个线程对自己的Cell变量的value进行原子操作,大大降低了失败的次数
  5. 这就是为什么在高并发场景下,推荐使用LongAdder的原因
l

9、【对线面试官】线程池

今天来聊聊线程池呗,你对Java线程池了解多少?

或者换个问法:为什么需要线程池?

  1. JVM在HotSpot的线程模型下,Java线程会一对一映射为内核线程
  2. 这意味着,在Java中每次创建以及回收线程都会去内核创建以及回收
  3. 这就有可能导致:创建和销毁线程所花费的时间和资源可能比处理的任务花费的时间和资源要更多
  4. 线程池的出现是为了提高线程的复用性以及固定线程的数量!!!

你在项目中用到了线程池吗?

  1. 嗯,用到的。我先说下背景吧
  2. 我所负责的项目是消息管理平台,提供其中一个功能就是:运营会圈定人群,然后群发消息
  3. 主要流程大致就是:创建模板-》定时-》群发消息-》用户收到消息
  4. 运营圈定的人群实际上在模板上只是一个ID,我这边要通过ID去获取到HDFS文件
  5. 对HDFS文件进行遍历,然后继续往下发
  6. 「接收到定时任务,再对HDFS进行遍历」这里的处理,我用的就是线程池处理

为什么选择用线程池呢?

  1. HDFS遍历其实就是IO的操作,我把这个过程给异步化,为了提高系统的吞吐量,于是我这里用的线程池。
  2. 即便遍历HDFS出现问题,我这边都有完备的监控和告警可以及时发现。

那你是怎么用线程池的呢?用Executors去创建的吗?

  1. 不是的,我这边用的ThreadPoolExecutor去创建线程池
  2. 其实看阿里巴巴开发手册就有提到,不要使用Executors去创建线程。
  3. 最主要的目的就是:使用ThreadPoolExecutor创建的线程你是更能了解线程池运行的规则,避免资源耗尽的风险
  4. ThreadPoolExecutor在构造的时候有几个重要的参数,分别是:
    corePoolSize (核心线程数量) 、maxim umPoolSize(最大线程数量)、keepAli veTime(线程空余时间) 、workQueue(阻塞队列)、handler(任务拒绝策略)
  5. 这几个参数应该很好理解哈,我就说下任务提交的流程,分别对应着几个参数的作用吧。
    • 首先会判断运行线程数是否小于corePoolSize,如果小于,则直接创建新的线程执行任务
    • 如果大于corePoolSize,判断workQueue阻塞队列是否已满,如果还没满,则将任务放到阻塞队列中
    • 如果workQueue阻塞队列已经满了,则判断当前线程数是否大于maximumPoolSize,如果没大于则创建新的线程执行任务
    • 如果大于maximumPoolSize,则执行任务拒绝策略(具体就是你自己实现的handler)
  6. 这里有个点需要注意下,就是workQueu e阻塞队列满了,但当前线程数小于maximumPoolSize,这时候会创建新的线程执行任务
  7. 源码就是这样实现的
  8. 不过一般我们都是将corePoolSize和maximumPoolSize设置相同数量
  9. keepAliveTime指的就是,当前运行的线程数大于核心线程数了,只要空闲时间达到了,就会对线程进行回收

那我再问一个问题,你创建线程池肯定会指定线程数的嘛,你这块是怎么考量的。

  1. 线程池指定线程数这块,首先要考量自己的业务是什么样的
  2. 是cpu密集型的还是io密集型的,假设运行应用的机器CPU核心数是N
  3. 那cpu密集型的可以先给到N+1,io密集型的可以给到2N去试试
  4. 上面这个只是一个常见的经验做法,具体究竟开多少线程,需要压测才能比较准确地定下来
  5. 线程不是说越大越好,在之前的面试我也提到过,多线程是为了充分利用CPU的资源
  6. 如果设置的线程过多,线程大量有上下文切换,这一部分也会带来系统的开销,这就得不偿失了

ThreadPoolExecutor你看过源码吗?

  1. 看过的,其实上面说的ThreadPoolExecutor几个参数,在源码的顶部注释都有
  2. 在执行的时候,重点就在于它维护了一个ctl参数,这个ctl参数的用高3位表示线程池的状态,低29位来表示线程的数量
  3. 里边用到了大量的位运算符操作,具体细节我就忘了,但是流程还是上面所讲的
l

面试官:“谈谈Spring中都用到了那些设计模式?”。

JDK 中用到了那些设计模式?Spring 中用到了那些设计模式?这两个问题,在面试中比较常见。我在网上搜索了一下关于 Spring 中设计模式的讲解几乎都是千篇一律,而且大部分都年代久远。所以,花了几天时间自己总结了一下,由于我的个人能力有限,文中如有任何错误各位都可以指出。另外,文章篇幅有限,对于设计模式以及一些源码的解读我只是一笔带过,这篇文章的主要目的是回顾一下 Spring 中的常见的设计模式。

Design Patterns(设计模式) 表示面向对象软件开发中最好的计算机编程实践。 Spring 框架中广泛使用了不同类型的设计模式,下面我们来看看到底有哪些设计模式?

控制反转(IoC)和依赖注入(DI)

IoC(Inversion of Control,控制翻转) 是Spring 中一个非常非常重要的概念,它不是什么技术,而是一种解耦的设计思想。它的主要目的是借助于“第三方”(Spring 中的 IOC 容器) 实现具有依赖关系的对象之间的解耦(IOC容易管理对象,你只管使用即可),从而降低代码之间的耦合度。IOC 是一个原则,而不是一个模式,以下模式(但不限于)实现了IoC原则。

图片

ioc-patterns

Spring IOC 容器就像是一个工厂一样,当我们需要创建一个对象的时候,只需要配置好配置文件/注解即可,完全不用考虑对象是如何被创建出来的。 IOC 容器负责创建对象,将对象连接在一起,配置这些对象,并从创建中处理这些对象的整个生命周期,直到它们被完全销毁。

在实际项目中一个 Service 类如果有几百甚至上千个类作为它的底层,我们需要实例化这个 Service,你可能要每次都要搞清这个 Service 所有底层类的构造函数,这可能会把人逼疯。如果利用 IOC 的话,你只需要配置好,然后在需要的地方引用就行了,这大大增加了项目的可维护性且降低了开发难度。关于Spring IOC 的理解,推荐看这一下知乎的一个回答:https://www.zhihu.com/question/23277575/answer/169698662 ,非常不错。

控制翻转怎么理解呢? 举个例子:”对象a 依赖了对象 b,当对象 a 需要使用 对象 b的时候必须自己去创建。但是当系统引入了 IOC 容器后, 对象a 和对象 b 之前就失去了直接的联系。这个时候,当对象 a 需要使用 对象 b的时候, 我们可以指定 IOC 容器去创建一个对象b注入到对象 a 中”。 对象 a 获得依赖对象 b 的过程,由主动行为变为了被动行为,控制权翻转,这就是控制反转名字的由来。

DI(Dependecy Inject,依赖注入)是实现控制反转的一种设计模式,依赖注入就是将实例变量传入到一个对象中去。

工厂设计模式

Spring使用工厂模式可以通过 BeanFactoryApplicationContext 创建 bean 对象。

两者对比:

  • BeanFactory :延迟注入(使用到某个 bean 的时候才会注入),相比于BeanFactory来说会占用更少的内存,程序启动速度更快。
  • ApplicationContext :容器启动的时候,不管你用没用到,一次性创建所有 bean 。BeanFactory 仅提供了最基本的依赖注入支持,ApplicationContext 扩展了 BeanFactory ,除了有BeanFactory的功能还有额外更多功能,所以一般开发人员使用ApplicationContext会更多。

ApplicationContext的三个实现类:

  1. ClassPathXmlApplication:把上下文文件当成类路径资源。
  2. FileSystemXmlApplication:从文件系统中的 XML 文件载入上下文定义信息。
  3. XmlWebApplicationContext:从Web系统中的XML文件载入上下文定义信息。

Example:

1
2
3
4
5
6
7
8
9
10
11
12
import org.springframework.context.ApplicationContext;
import org.springframework.context.support.FileSystemXmlApplicationContext;

public class App {
public static void main(String[] args) {
ApplicationContext context = new FileSystemXmlApplicationContext(
"C:/work/IOC Containers/springframework.applicationcontext/src/main/resources/bean-factory-config.xml");

HelloApplicationContext obj = (HelloApplicationContext) context.getBean("helloApplicationContext");
obj.getMsg();
}
}

单例设计模式

在我们的系统中,有一些对象其实我们只需要一个,比如说:线程池、缓存、对话框、注册表、日志对象、充当打印机、显卡等设备驱动程序的对象。事实上,这一类对象只能有一个实例,如果制造出多个实例就可能会导致一些问题的产生,比如:程序的行为异常、资源使用过量、或者不一致性的结果。

使用单例模式的好处:

  • 对于频繁使用的对象,可以省略创建对象所花费的时间,这对于那些重量级对象而言,是非常可观的一笔系统开销;
  • 由于 new 操作的次数减少,因而对系统内存的使用频率也会降低,这将减轻 GC 压力,缩短 GC 停顿时间。

Spring 中 bean 的默认作用域就是 singleton(单例)的。 除了 singleton 作用域,Spring 中 bean 还有下面几种作用域:

  • prototype : 每次请求都会创建一个新的 bean 实例。
  • request : 每一次HTTP请求都会产生一个新的bean,该bean仅在当前HTTP request内有效。
  • session : 每一次HTTP请求都会产生一个新的 bean,该bean仅在当前 HTTP session 内有效。
  • global-session: 全局session作用域,仅仅在基于portlet的web应用中才有意义,Spring5已经没有了。Portlet是能够生成语义代码(例如:HTML)片段的小型Java Web插件。它们基于portlet容器,可以像servlet一样处理HTTP请求。但是,与 servlet 不同,每个 portlet 都有不同的会话

Spring 实现单例的方式:

  • xml:``
  • 注解:@Scope(value = "singleton")

Spring 通过 ConcurrentHashMap 实现单例注册表的特殊方式实现单例模式。Spring 实现单例的核心代码如下:

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
// 通过 ConcurrentHashMap(线程安全) 实现单例注册表
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<String, Object>(64);

public Object getSingleton(String beanName, ObjectFactory<?> singletonFactory) {
Assert.notNull(beanName, "'beanName' must not be null");
synchronized (this.singletonObjects) {
// 检查缓存中是否存在实例
Object singletonObject = this.singletonObjects.get(beanName);
if (singletonObject == null) {
//...省略了很多代码
try {
singletonObject = singletonFactory.getObject();
}
//...省略了很多代码
// 如果实例对象在不存在,我们注册到单例注册表中。
addSingleton(beanName, singletonObject);
}
return (singletonObject != NULL_OBJECT ? singletonObject : null);
}
}
//将对象添加到单例注册表
protected void addSingleton(String beanName, Object singletonObject) {
synchronized (this.singletonObjects) {
this.singletonObjects.put(beanName, (singletonObject != null ? singletonObject : NULL_OBJECT));

}
}
}

代理设计模式

代理模式在 AOP 中的应用

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

Spring AOP 就是基于动态代理的,如果要代理的对象,实现了某个接口,那么Spring AOP会使用JDK Proxy,去创建代理对象,而对于没有实现接口的对象,就无法使用 JDK Proxy 去进行代理了,这时候Spring AOP会使用Cglib ,这时候Spring AOP会使用 Cglib 生成一个被代理对象的子类来作为代理,如下图所示:

图片

SpringAOPProcess

当然你也可以使用 AspectJ ,Spring AOP 已经集成了AspectJ ,AspectJ 应该算的上是 Java 生态系统中最完整的 AOP 框架了。

使用 AOP 之后我们可以把一些通用功能抽象出来,在需要用到的地方直接使用即可,这样大大简化了代码量。我们需要增加新功能时也方便,这样也提高了系统扩展性。日志功能、事务管理等等场景都用到了 AOP 。

Spring AOP 和 AspectJ AOP 有什么区别?

Spring AOP 属于运行时增强,而 AspectJ 是编译时增强。 Spring AOP 基于代理(Proxying),而 AspectJ 基于字节码操作(Bytecode Manipulation)。

Spring AOP 已经集成了 AspectJ ,AspectJ 应该算的上是 Java 生态系统中最完整的 AOP 框架了。AspectJ 相比于 Spring AOP 功能更加强大,但是 Spring AOP 相对来说更简单,

如果我们的切面比较少,那么两者性能差异不大。但是,当切面太多的话,最好选择 AspectJ ,它比Spring AOP 快很多。

模板方法

模板方法模式是一种行为设计模式,它定义一个操作中的算法的骨架,而将一些步骤延迟到子类中。 模板方法使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤的实现方式。

图片

模板方法UML图
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
public abstract class Template {
//这是我们的模板方法
public final void TemplateMethod(){
PrimitiveOperation1();
PrimitiveOperation2();
PrimitiveOperation3();
}

protected void PrimitiveOperation1(){
//当前类实现
}

//被子类实现的方法
protected abstract void PrimitiveOperation2();
protected abstract void PrimitiveOperation3();

}
public class TemplateImpl extends Template {

@Override
public void PrimitiveOperation2() {
//当前类实现
}

@Override
public void PrimitiveOperation3() {
//当前类实现
}
}

Spring 中 jdbcTemplatehibernateTemplate 等以 Template 结尾的对数据库操作的类,它们就使用到了模板模式。一般情况下,我们都是使用继承的方式来实现模板模式,但是 Spring 并没有使用这种方式,而是使用Callback 模式与模板方法模式配合,既达到了代码复用的效果,同时增加了灵活性。

观察者模式

观察者模式是一种对象行为型模式。它表示的是一种对象与对象之间具有依赖关系,当一个对象发生改变的时候,这个对象所依赖的对象也会做出反应。Spring 事件驱动模型就是观察者模式很经典的一个应用。Spring 事件驱动模型非常有用,在很多场景都可以解耦我们的代码。比如我们每次添加商品的时候都需要重新更新商品索引,这个时候就可以利用观察者模式来解决这个问题。

Spring 事件驱动模型中的三种角色

事件角色

ApplicationEvent (org.springframework.context包下)充当事件的角色,这是一个抽象类,它继承了java.util.EventObject并实现了 java.io.Serializable接口。

Spring 中默认存在以下事件,他们都是对 ApplicationContextEvent 的实现(继承自ApplicationContextEvent):

  • ContextStartedEventApplicationContext 启动后触发的事件;
  • ContextStoppedEventApplicationContext 停止后触发的事件;
  • ContextRefreshedEventApplicationContext 初始化或刷新完成后触发的事件;
  • ContextClosedEventApplicationContext 关闭后触发的事件。

图片

ApplicationEvent-Subclass

事件监听者角色

ApplicationListener 充当了事件监听者角色,它是一个接口,里面只定义了一个 onApplicationEvent()方法来处理ApplicationEventApplicationListener接口类源码如下,可以看出接口定义看出接口中的事件只要实现了 ApplicationEvent就可以了。所以,在 Spring中我们只要实现 ApplicationListener 接口实现 onApplicationEvent() 方法即可完成监听事件

1
2
3
4
5
6
package org.springframework.context;
import java.util.EventListener;
@FunctionalInterface
public interface ApplicationListener<E extends ApplicationEvent> extends EventListener {
void onApplicationEvent(E var1);
}

事件发布者角色

ApplicationEventPublisher 充当了事件的发布者,它也是一个接口。

1
2
3
4
5
6
7
8
@FunctionalInterface
public interface ApplicationEventPublisher {
default void publishEvent(ApplicationEvent event) {
this.publishEvent((Object)event);
}

void publishEvent(Object var1);
}

ApplicationEventPublisher 接口的publishEvent()这个方法在AbstractApplicationContext类中被实现,阅读这个方法的实现,你会发现实际上事件真正是通过ApplicationEventMulticaster来广播出去的。具体内容过多,就不在这里分析了,后面可能会单独写一篇文章提到。

Spring 的事件流程总结

  1. 定义一个事件: 实现一个继承自 ApplicationEvent,并且写相应的构造函数;
  2. 定义一个事件监听者:实现 ApplicationListener 接口,重写 onApplicationEvent() 方法;
  3. 使用事件发布者发布消息: 可以通过 ApplicationEventPublisherpublishEvent() 方法发布消息。

Example:

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
// 定义一个事件,继承自ApplicationEvent并且写相应的构造函数
public class DemoEvent extends ApplicationEvent{
private static final long serialVersionUID = 1L;

private String message;

public DemoEvent(Object source,String message){
super(source);
this.message = message;
}

public String getMessage() {
return message;
}


// 定义一个事件监听者,实现ApplicationListener接口,重写 onApplicationEvent() 方法;
@Component
public class DemoListener implements ApplicationListener<DemoEvent>{

//使用onApplicationEvent接收消息
@Override
public void onApplicationEvent(DemoEvent event) {
String msg = event.getMessage();
System.out.println("接收到的信息是:"+msg);
}

}
// 发布事件,可以通过ApplicationEventPublisher 的 publishEvent() 方法发布消息。
@Component
public class DemoPublisher {

@Autowired
ApplicationContext applicationContext;

public void publish(String message){
//发布事件
applicationContext.publishEvent(new DemoEvent(this, message));
}
}

当调用 DemoPublisherpublish() 方法的时候,比如 demoPublisher.publish("你好") ,控制台就会打印出:接收到的信息是:你好

适配器模式

适配器模式(Adapter Pattern) 将一个接口转换成客户希望的另一个接口,适配器模式使接口不兼容的那些类可以一起工作,其别名为包装器(Wrapper)。

spring AOP中的适配器模式

我们知道 Spring AOP 的实现是基于代理模式,但是 Spring AOP 的增强或通知(Advice)使用到了适配器模式,与之相关的接口是AdvisorAdapter 。Advice 常用的类型有:BeforeAdvice(目标方法调用前,前置通知)、AfterAdvice(目标方法调用后,后置通知)、AfterReturningAdvice(目标方法执行结束后,return之前)等等。每个类型Advice(通知)都有对应的拦截器:MethodBeforeAdviceInterceptorAfterReturningAdviceAdapterAfterReturningAdviceInterceptor。Spring预定义的通知要通过对应的适配器,适配成 MethodInterceptor接口(方法拦截器)类型的对象(如:MethodBeforeAdviceInterceptor 负责适配 MethodBeforeAdvice)。

spring MVC中的适配器模式

在Spring MVC中,DispatcherServlet 根据请求信息调用 HandlerMapping,解析请求对应的 Handler。解析到对应的 Handler(也就是我们平常说的 Controller 控制器)后,开始由HandlerAdapter 适配器处理。HandlerAdapter 作为期望接口,具体的适配器实现类用于对目标类进行适配,Controller 作为需要适配的类。

为什么要在 Spring MVC 中使用适配器模式? Spring MVC 中的 Controller 种类众多,不同类型的 Controller 通过不同的方法来对请求进行处理。如果不利用适配器模式的话,DispatcherServlet 直接获取对应类型的 Controller,需要的自行来判断,像下面这段代码一样:

1
2
3
4
5
6
7
if(mappedHandler.getHandler() instanceof MultiActionController){  
((MultiActionController)mappedHandler.getHandler()).xxx
}else if(mappedHandler.getHandler() instanceof XXX){
...
}else if(...){
...
}

假如我们再增加一个 Controller类型就要在上面代码中再加入一行 判断语句,这种形式就使得程序难以维护,也违反了设计模式中的开闭原则 – 对扩展开放,对修改关闭。

装饰者模式

装饰者模式可以动态地给对象添加一些额外的属性或行为。相比于使用继承,装饰者模式更加灵活。简单点儿说就是当我们需要修改原有的功能,但我们又不愿直接去修改原有的代码时,设计一个Decorator套在原有代码外面。其实在 JDK 中就有很多地方用到了装饰者模式,比如 InputStream家族,InputStream 类下有 FileInputStream (读取文件)、BufferedInputStream (增加缓存,使读取文件速度大大提升)等子类都在不修改InputStream 代码的情况下扩展了它的功能。

图片

装饰者模式示意图

Spring 中配置 DataSource 的时候,DataSource 可能是不同的数据库和数据源。我们能否根据客户的需求在少修改原有类的代码下动态切换不同的数据源?这个时候就要用到装饰者模式(这一点我自己还没太理解具体原理)。Spring 中用到的包装器模式在类名上含有 Wrapper或者 Decorator。这些类基本上都是动态地给一个对象添加一些额外的职责

总结

Spring 框架中用到了哪些设计模式:

  • 工厂设计模式 : Spring使用工厂模式通过 BeanFactoryApplicationContext 创建 bean 对象。
  • 代理设计模式 : Spring AOP 功能的实现。
  • 单例设计模式 : Spring 中的 Bean 默认都是单例的。
  • 模板方法模式 : Spring 中 jdbcTemplatehibernateTemplate 等以 Template 结尾的对数据库操作的类,它们就使用到了模板模式。
  • 包装器设计模式 : 我们的项目需要连接多个数据库,而且不同的客户在每次访问中根据需要会去访问不同的数据库。这种模式让我们可以根据客户的需求能够动态切换不同的数据源。
  • 观察者模式: Spring 事件驱动模型就是观察者模式很经典的一个应用。
  • 适配器模式 :Spring AOP 的增强或通知(Advice)使用到了适配器模式、spring MVC 中也是用到了适配器模式适配Controller
  • ……

参考

l

个人博客系统设计(支持hexo和halo同步)

  1. 本文主要介绍自己的博客系统是如何设计的,并使用Halo博客同步器 将hexo(git pages: https://linshenkx.github.io )文章自动同步到halo( http://linshenkx.cn )。
    实现一次编写、两套博客系统并存、多个网址访问的效果。

一 总览

达到效果

个人博客网址 介绍 对应git仓库/管理界面
https://linshenkx.gitee.io hexo next gitee pages https://gitee.com/linshenkx/linshenkx
https://linshenkx.github.io hexo next github pages https://github.com/linshenkx/linshenkx.github.io
https://linshen.netlify.app netlify加速,文章同步自blog源码仓库 https://app.netlify.com/teams/linshenkx
https://linshenkx.cn halo个人网站,文章同步自blog源码仓库 https://linshenkx.cn/admin/index.html#/dashboard

blog博客源码仓库(核心,私有):https://github.com/linshenkx/blog

博客发布流程

  1. 编写博客
    在blog工程下写博客,工程为标准hexo,博客为markdown文件放在source/_posts目录下,使用多层级分类存放
  2. 发布到git pages
    完成博客的增删改后,在工程目录下执行hexo clean && hexo d -g部署到git pages。
    这里我配置了同时发布到github和gitee,需要注意的是,gitee的git pages需要手动去触发更新才能生效。
  3. 提交并推送工程
    提交并推送blog工程的修改。
    netlify将自动获取blog工程,并执行hexo部署脚本(效果和git pages一样,只是用netlify访问据说会快一点)
    自己开发的Halo博客同步器也会检测到blog工程更新,根据更新情况将变化同步到halo博客系统中。

二 设计思路

1 起因

本来我一直是在使用csdn的,但是网页端写作确实不方便,而且还可能受网络情况限制。
所以我后面一般都是用印象笔记做记录,在印象笔记写好再看心情整理到csdn上去。
但是悄不注意的,在21年初csdn改版,同时也改变了排名和引流规则。
之前一个星期2500到3000的访问量现在只剩1500到2000了。

嗯,不可忍。换。

2 调研

市面上的博客系统可根据对Git Pages的支持(即是否支持生成静态网站)分为两大类:

一是以hexo为代表的静态网站生成器:如hexo、hugo、jekyll,较成熟,有较多第三方主题和插件,可与git pages搭配使用,也可自行部署。

二是以halo为代表的五花八门的个人博客系统,功能更加强大,自由度更高,通常带后台管理,但不支持git pages,需自行部署。

3 分析

个人博客的话使用git pages比较稳定,网址固定,可以永久使用,而且可以通过搭配不同的git服务商来保证访问速度。
但是git pages的缺点也很明显,是静态网站,虽然可以搭配第三方插件增强,但说到底还是个静态网站。

而如果自己买服务器,买域名,用第三方个人博客系统,就可以玩得比较花里胡哨了,但谁知道会用多久呢。
服务器、域名都要自己负责,三五年之后还能不能访问就比较难说了。
但是年轻人嘛,总还是花里胡哨点才香。

那我就全都要。

git pages作为专业性较强的个人网站可以永久访问,
然后再弄个服务器放个博客系统自己玩。

4 选型

静态网站生成器选的是hexo,传统一点,支持的插件和主题比较多。
hugo虽然也不错,但似乎国内用的不多,支持可能还不够完善。

然后hexo的主题用的最经典的next,比较成熟,功能也很完善
虽然整体比较严肃压抑,但可以自己加个live2d增添点活力,
作为一个展示专业性的博客网站这样也就够了

自定义博客系统的话我选的是halo,最主要原因是它是java写的,利于二次开发(事实上后面用着也确实有问题,还提交了一个issue)
而且功能比较强大,生态比较完善,虽然第三方主题少且基本都没更新,但是…实在是找不出其他一个能打的了
另外halo支持导入markdown,且功能基本都通过rest接口放开,适合开发者使用

三 设计实现

1 hexo

hexo本身只是静态网站生成器,你可以把hexo项目本身发布成为git pages项目,
像github、gitee这些会识别出这是一个hexo项目,然后进行编译,得到静态资源供外部访问。
这也是最简单的用法。

但是不推荐。

因为git pages项目一般都要求是public的(且名称固定,一个git账号只有一个git pages仓库),
hexo项目包含你的博客markdown源文件和其他的个人信息。
我们只是想把必要的生成后的静态网页放出去而已,至于项目的配置信息和markdown源文件应该藏起来。

所以需要使用 hexo-deployer-git 插件进行git pages的部署。
即放到git公开的文件只有生成后的网页文件而已,git只是把你生成后的index.html进行直接展示,不会再去编译了
(需要在source目录下添加.nojekyll文件表明为静态网页,无须编译)

而项目本身为了更好地进行管理和记录,还是要发布到git上面的,作为一个普通的私有仓库,名称可以任意(如 blog)

这样,每次要增删改完文章只需要执行hexo clean && hexo d -g即可发布到git仓库上
注意,不同git服务商git pages规则不一样。
比方说我gitee和github的用户名都是linshenkx
但是gitee要求的仓库名是linshenkx,而github的仓库名就必须是linshenkx.github.io了
而github的git pages仓库在接收到推送后就自动(编译)部署
gitee则需要到仓库web界面手动触发更新

截至到这一步是大多数人的做法,即git上两个仓库并存,一(或多)个git pages公有仓库做展示,一个blog仓库存放博客源码
注意:如果git pages仓库允许私有,则可以使用一个仓库多个分支来实现相同效果。
但还是推荐使用两个仓库,因为这样更通用,设计上也更合理。

工程总体结构如下,为普通hexo工程:
img
博客源码目录结构如下,为多层级结构:
img

2 halo

halo的使用看官方文档一般就够了,这里需要补充的是其代理配置。
因为halo的在线下载更新主题功能通常需要连接到github,我习惯通过代理访问
这里提供一下配置方法
即在容器启动时添加JVM参数即可

1
2
docker run -it -d --name halo --network host -e JVM_OPTS="-Dhttp.proxyHost=127.0.0.1 -Dhttp.proxyPort=7890 -Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=7890" -v /opt/halo/workspace:/root/.halo --restart=always halohub/halo

3 markdown图片

markdown图片的存放一直是个麻烦的问题。
最害怕遇到就是图链的失效,而且往往自己还不能发现。
理想状态下就是markdown一张图片支持配置多个图床链接,第一个图床链接超时就使用下一个。
这种服务端的处理思想很明显不适合放到客户端。
退而求其次,配置一个链接,访问这个链接会触发对多个图床的访问,然后那个快用那个。
这个效果技术上不难实现,也有个商业产品(聚合图床)是这样的,缺点是收费。
然后我又在github、gitee上找了各个图床软件,都不怎么样(这个时间成本都够给聚合图床开几年会员了)
最终还是妥协,用云存储吧,选了阿里
七牛、腾讯也都试了,其实都差不多,看个人爱好,没有太特别的理由

如果你用typora写markdown的话很方便,它支持picgo插件

但我习惯在idea里面编写,idea也有一些markdown-image插件,基本都不好用
所以我还是安装了picgo,开了快捷键,复制图片直接快捷键粘贴体验也还是比较舒服的
picgo的特点是插件多,不过插件质量一般,有很多bug

花了两天时间纠结、测试,最后的方案是:idea编辑+阿里云存储+picgo上传

4 同步

这才是重点

1 同步的方向

即在哪里写文章,同步到哪里

我还是习惯用idea写markdown文档而不是在网页上。
所以确定是流向为 hexo->halo

2 技术支撑

halo支持导入markdown文件,所以主要问题为hexo的markdown博客源码文件的获取
hexo文章存储路径为 source/_posts ,有多层级文件夹,可以简单地理解成文件IO操作获取文章内容。
但关键是存储在git上,这里可以用JGit进行操作。
同时,JGit支持获取两次commit之间的文件变化情况。
即可以捕获到文章的增删改操作,而不用每次都全量地同步。

3 成果

又处理了一些细节问题,最终还是自己做了个haloSyncServer同步程序,
封装成docker,放服务器上跑,实现同步。
待整理后开源。

2021年11月更新
开源地址为:https://github.com/linshenkx/haloSyncServer
效果
img

l