面经
- Java基础
- 集合都有哪些
- 面向对象的三大特点
- ArrayList和LinkedList的区别?
- ArrayList底层扩容是怎么实现的?
- 讲一讲HashMap、以及put方法的过程
- 讲一讲HashMap的扩容过程
- Hashmap为什么要用红黑树而不用其他的树?
- Java8新特性有哪些
- LoadFactor负载因子参数怎么调?什么时候调?
- Object的hashCode和equals为什么要重写,假如没有重写hashCode会有什么问题?
- 阻塞队列怎么实现,用哪些变量实现,入队出队函数是什么样的
- JVM
- 在java怎么确保一个类不被重复加载
- 有哪些类加载器
- Class对象能够被GC吗
- 方法区在JDK8是怎么实现的
- 线上项目发生内存溢出原因,如何定位,怎么解决
- 什么是堆存储文件
- 虚拟机的内存结构
- 虚拟机堆分为哪些结构
- 垃圾回收器有哪些
- 介绍G1垃圾回收器
- 介绍垃圾回收算法
- 某个对象不想让JVM进行垃圾回收,怎么办?
- JUC
- CAS是什么
- AQS是什么
- 什么情况会发生线程阻塞
- AQS怎么实现线程阻塞
- AQS用到了哪些设计模式
- AQS实现有哪些
- 介绍一下ReentrantLock
- ReentrantLock怎么实现的非公平锁?
- 线程池核心线程数应该怎么设置
- 线程池的拒绝策略
- 怎么停止一个线程池
- 调用shutdown的过程
- 介绍一下线程的sleep方法
- 线程睡眠怎么打断
- Synchronized锁的升级过程
- volatile有什么用
- 开启多线程的方式
- Synchronized 和 Lock级别的锁的区别
- Synchronized 实现并发控制的底层实现是什么
- Mysql
- Mysql 主从复制
- SQL 优化
- Mysql 索引类型
- Mysql 事务隔离级别
- 什么是MVCC
- Mysql范式
- MySql分页查询后面越来越慢,怎么优化?
- Mysql的事务特性,分别代表什么,脏读,不可重复读,幻读是什么
- Mysql日志有哪些,分别有什么作用
- char 和 varchar 的区别
- Redis
- RDB和AOF持久化的区别
- 缓存穿透、击穿、雪崩的原因,解决方案
- Spring
- 讲一讲AOP,是怎么实现的
- JDK动态代理,CGLIb动态代理
- 怎么自定义SpringBoot Starter
- Spring 事务是什么,传播机制是什么
- Spring 注入Bean的几种方式
- Spring 注入Bead方式的先后顺序
- Spring中用到了哪些设计模式
- Spring事务失效的原因
- Mybatis
- Mybatis缓存
- Mybatis中${}和#{}有什么区别
- MyBatis中的resultMap与resultType是什么?如何使用?
- Linux
- Linux 常用命令
- 计算机网络
- TCP三次握手、四次挥手
- 为什么三次握手,两次四次会导致什么
- HTTP通信过程
- Get和Post方法区别
- HTTP常见的请求头、响应头有哪些
- HTTP常见状态码
- HTTPS加密过程
- HTTP 和 HTTPS的区别
- TCP、UDP区别
- OSI网络模型
- 应用层都有哪些协议
- 输入url之后到返回结果的过程
- TCP/IP模型每一层实现了什么功能
- 数据到达网卡之后的流程
- 操作系统
- 线程和进程的区别
- 协程和线程的区别
- 协程的使用场景
- 进程的通信方式
- 操作系统内存管理机制
- 其他
- 介绍IO多路复用
- Cookie 和 Session有什么区别?
- 重定向和转发的区别?
- 重定向和转发的响应状态码
- 对称加密和非对称加密算法和使用场景
- 死锁场景、死锁解决
- fail-fast机制问题
- 微服务和云原生了解多少,策略或者使用场景?
- 项目
- 技术选型怎么做的
- 为什么做这个项目,有合作吗?
- Redis 在项目中的使用
- 实现分布式锁的方式有哪些
- 项目的部署
- 项目难点是什么
- 算法
- 回文数
- 链表反转
- 计算时针与分针的角度
- 最长无重复子字符串
- 三个线程顺序打印ABC
- 冒泡排序
- 反转链表II
- 从无序的数组里找第K大的元素
- 快排
- 归并排序
- 最大子数组和
- 最长回文子串
- 最长无重复子串
- 零钱兑换
- 合并两个有序链表
- lru
- lfu
- 链表展开
- 排列2
Java基础
集合都有哪些
答:
Java集合主要分为两类,一类是实现了Collection
接口的,另一类是实现Map
接口的。
Collection
集合可以分为:
Set
:存储的元素无序,不能重复- HashSet:底层实现是基于HashMap,只使用了HashMap的Key。
- LinkedHashSet:底层实现是基于HashMap,并且将各个节点通过双向链表连接。
- TreeSet:底层实现是基于红黑树,并且可以自定义排序规则,不允许存储NULL元素。
List
:存储的元素有序,可以重复- ArrayList:底层实现是基于数组,内存空间连续,可以实现随机访问。
- LinkedList :底层实现是基于双向链表。
Queue
:存储的元素先进先出,可以重复- PriorityQueue:底层实现是基于堆,默认是小顶堆,不允许存储NULL元素
Deque
:双端队列- ArrayDeque:底层实现是基于数组,使用两个指针指向首尾,不允许存储NULL元素
- LinkedList:同上
BlockingQueue
:阻塞队列- ArrayBlockingQueue:底层实现是基于数组的有界阻塞队列
- LinkedBlockingQueue:底层实现是基于单向链表的可选有界阻塞队列
- PriorityBlockingQueue:底层实现是基于堆的有界阻塞队列,支持按元素优先级排序
- SynchronousQueue:同步队列,每个插入操作都必须等待对应的删除操作,反之删除操作也必须等待插入操作。不存储元素。
- DelayQueue:延迟队列,其中的元素只有到了其指定的延迟时间,才能够从队列中出队。
Map
集合可以分为:
- HashMap:不是线程安全,key 和 value 可以存储 null 值
JDK 8之前
,底层实现是基于数组 + 链表JDK 8之后
,当链表长度大于阈值(默认8),则将链表转化为红黑树。
- Hashtable:线程安全,key 和 value 不可以存储 null 值
- 底层实现是基于数组 + 链表
- TreeMap:底层实现是基于红黑树,并且可以自定义排序规则。
面向对象的三大特点
答:
- 封装: 把属性和方法封装到对象的内部,对外只暴露获取和修改的方法。
- 继承: 类之间的一种扩展关系,子类拥有父类对象的属性和方法(包括私有属性和私有方法,但无法访问)。可以重写父类的方法。
- 多态: 具体表现为父类的引用指向子类的实例。(
编译看左边,运行看右边
)
ArrayList和LinkedList的区别?
答:
ArrayList:
- 基于动态数组实现的,默认初始容量为
10
,当元素数量超过当前容量时会进行自动扩容。因为是基于数组,所以可以实现随机访问。
LinkedList:
- 基于双向链表实现的,每个节点不仅要存储数据,还要存储指向前一个节点和后一个节点的指针。不支持随机访问,插入和删除效率高。
都不是线程安全的。
ArrayList底层扩容是怎么实现的?
答:
- ArrayList在添加一个元素之前,首先会检查数组容量是否足够,如果元素数量超过了数组容量的大小,则会进行扩容。
- 扩容:调用
grow(int)
方法进行扩容。扩容的大小为原数组容量的1.5
倍, - 复制:扩容后,会调用
Arrays.copyOf(elementData, newCapacity)
方法,创建一个容量为扩容后大小的数组,并把原数组的元素复制进去。 - 添加:再将一开始要添加的元素加入到数组中。
讲一讲HashMap、以及put方法的过程
答:
- HashMap是一个用于存储键值对类型数据的集合,
JDK1.8之前
,使用的是数组 + 链表实现的。 - HashMap存储元素的过程(put方法过程):
- 首先会对key进行
hash
,得到要存储在数组中的哪个位置(因为hash后得到的值非常大,超过了数组的范围,所以hash值要对数组长度求余
),知道要存储的位置后,首先判断该位置是否有元素存在,如果没有元素存在,则在该位置创建一个node节点
,并将key,value保存到node节点内部
。 - 如果该位置已经存在元素了,意味着产生了
哈希冲突
。然后遍历由node节点组成的链表,判断其中有没有节点中key的hash和值是否与插入的key相等的
,如果有,则将其value进行覆盖。如果遍历到链表末尾都没有,则在尾部插入一个新的节点。 - 插入新节点后,同时会判断
链表的长度是否大于了阈值(8)
,如果大于了则将链表转化为红黑树
,提高查询效率。
讲一讲HashMap的扩容过程
答:
- 当数组中元素个数大于
数组容量
乘以负载因子
(0.75
)的值时,会对数组容量进行扩容。 - 新创建一个数组,大小为原数组的
2倍
。将原数组中所有的元素重新哈希,并放到新数组的对应位置。
Hashmap为什么要用红黑树而不用其他的树?
答:
红黑树: 红黑树是一种平衡二叉树,其插入、删除、查找的时间复杂度都为O(logn)
相比于其他树:
- 普通二叉树: 避免了二叉树在最坏情况下O(n)的时间复杂度。
- 其他平衡二叉树: 其他平衡二叉树是比红黑树更严格的平衡树,为了保持平衡,需要旋转的次数更多。
- b树/b+树: 用B/B+树的话,在数据量不是很多的情况下,数据都会挤在一个结点里面,这时候就退化成了链表。B和B+树主要用于数据存储在磁盘上的场景
Java8新特性有哪些
答:
- 支持
Lambda
表达式- 就是对匿名实现类的表现形式进行简写
- 支持方法引用
- 新增函数式接口
- 在一个接口中,只声明了
一个抽象方法
,则此接口就称为函数式接口
- 在一个接口中,只声明了
- 新增了
Stream API
LoadFactor负载因子参数怎么调?什么时候调?
答:
- 负载因子:用于衡量HashMap内部存储空间的充满程度。比如说0.4,那么表示当容器
填满40%
的时候,HashMap就会进行扩容,扩充为原来的2倍大小。 - 负载因子越小:冲突的几率就越低,但是会消耗更多的空间。
- 负载因子越大:冲突的几率就越大,但是会更节省空间。
- 如果内存资源充足,希望提高查询效率: 负载因子就可以调低一点。
- 如果内存资源紧张,查询效率不那么重要: 负载因子就可以调高一点。
- 默认负载因子:
0.75
Object的hashCode和equals为什么要重写,假如没有重写hashCode会有什么问题?
答:
重写equals: 是为了判断当两个对象的属性值相同时,才认为是相同的对象。
重写hashCode: 为了根据对象的属性值来生成哈希码,与equals保持一致。
没有重写hashCode: 当向HashSet集合添加元素时,两个对象即使属性值一样,但也会添加进去。
阻塞队列怎么实现,用哪些变量实现,入队出队函数是什么样的
- 双指针,控制头和尾指针,记得对size取余,使用wait和notifyall通知
JVM
在java怎么确保一个类不被重复加载
答:
- 依靠双亲委派机制
- 当一个类加载器要加载字节码文件时,首先向上查找父类加载器是否加载过,
- 如果加载过,则直接返回。
- 如果一直到顶级类加载器(
Bootstrap
)也没有加载过,则再从上至下尝试加载。 - 好处:避免类的重复加载、保证JDK的核心类库不会被替换。
有哪些类加载器
答:
- 启动类加载器(Bootstrap):默认加载
Java安装目录/jre/lib
下的类文件,比如rt.jar,tools.jar,resources.jar等。 - 扩展类加载器:默认加载
Java安装目录/jre/lib/ext
下的类文件 - 应用程序类加载器:默认加载为
应用程序classpath
下的类文件。 - 自定义类加载器:继承
ClassLoader
抽象类,重写findClass
方法。在findClass方法中,定义从哪里读取字节码文件,然后调用defineClass
方法,在方法区和堆区创建对象。
Class对象能够被GC吗
答:
满足以下3个条件,就可以被回收
- 此类所有实例对象以及子类对象都已经被回收
- 加载该类的类加载器已经被回收。
- 该类对应的 java.lang.Class 对象没有在任何地方被引用。
方法区在JDK8是怎么实现的
答:
JDK7
及之前的版本将方法区存放在堆区域中的永久代空间。JDK8
及之后的版本将方法区存放在元空间中,元空间位于操作系统维护的直接内存中。会发生内存溢出。
线上项目发生内存溢出原因,如何定位,怎么解决
答:
内存溢出原因:
- 一次性申请的对象太多。例如:一次性将数据库的千万级数据查询出来放到内存中。解决方法:分页查询
- 内存资源耗尽未释放。例如:高并发场景下,不断的使用资源信息例如
jdbc connection
没有释放。解决方法:池化技术 - 本身资源不够。例如:分配的堆内存太小,不足以支撑业务。解决方法:使用
jmap -heap
查看堆信息。
如何定位:
1、程序已经OOM挂了
- 提前设置了JVM参数:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=保存路径
(如果没有设置,提桶跑路吧)
2、系统运行中还未OOM
- 使用命令
jmap -dump:format=b,file=demo.hprof java进程号
在线导出dump文件。(缺点:会进行一次Full GC
)
如何解决:
- 使用
jvisualvm
工具(JVM诊断工具)导入保存的dump文件,查看跟业务有关的对象,找到根对象,查看根对象的线程栈。定位到内存泄漏点。进行代码修复或者JVM参数调优。
什么是堆存储文件
答:
- 堆转储文件(Heap Dump File):记录的是整个堆内存中所有对象的详细信息。通常用于进行故障诊断或性能优化
- 通过JVM参数:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=保存路径
命令可以在发生堆内存溢出时,保存堆存储文件。
虚拟机的内存结构
答:
- 类加载子系统:负责将字节码文件中的内容加载到内存中。
- 运行时数据区:JVM管理的内存,创建出来的对象、类的信息等都会放在这块区域中。
- 线程不共享:程序计数器、Java虚拟机栈、本地方法栈
- 线程共享:堆、方法区
- 执行引擎:包含了即时编译器、解释器、垃圾回收器,执行引擎使用解释器将字节码指令解释成机器码,使用即时编译器优化性能,使用垃圾回收器回收不再使用的对象。
- 本地接口:调用使用C/C++编译好的方法,本地方法在Java中声明时,都会带上
native
关键字。
虚拟机堆分为哪些结构
答:
- 新生代: 新创建的对象首先分配在新生代。其中新生代分为Eden、幸存区From、幸存区To
- 老年代: 长时间存活的对象会被放到老年代,新生代中当对象的年龄达到15就会晋升到老年代。
- 永久代: JDK8之前,永久代是方法区的实现,用来存储类的元数据信息、常量池等。
垃圾回收器有哪些
答:
-
单线程的垃圾回收器:
Serial
回收新生代、采用复制算法SerialOld
回收老年代、采用标记-整理算法- 缺点:单核CPU优异,多核CPU吞吐量不如其他垃圾回收器。
-
多线程的垃圾回收器:
ParNew
回收新生代、采用复制算法CMS(Concurrent Mark Sweep)
回收老年代、采用标记-清除算法- 会产生内存碎片
- G1垃圾回收器
- JDK 9之后,默认的垃圾回收器
- 回收年轻代、老年代 采用复制算法
介绍G1垃圾回收器
答:
- G1垃圾回收器将堆内存划分成多个大小相等的区域,分为
Eden
、Survivor
、Old
- G1垃圾回收可以分为三个阶段:
新生代回收
(stw)、并发标记
(重新标记stw)、混合回收
- 新生代回收: 新创建的对象首先会被分配到Eden区。Eden区满了,就会触发新生代回收。将Eden、Survivor存活的对象复制到一个新的Survivor区,或者Old区(前提年龄达到阈值)。
- 并发标记: G1会启动一个后台线程进行并发标记,确定Old区中哪些对象需要被清理。并发标记阶段不需要暂停用户的工作线程。(堆占用率达到一定阈值会触发并发标记)
- 混合回收: G1会清理Eden、Survivor、以及Old区域(垃圾对象多的Old区域优先被回收)(并发标记完成之后)
介绍垃圾回收算法
答:
1、标记清除算法
根据可达性分析算法,将所有存活的对象进行标记。
在清除阶段,将未被标记的对象进行清除
缺点: 容易产生大量的内存碎片
2、复制算法
将堆内存空间划分成两部分,from区和to区
新创建的对象会被放入到from区。进行垃圾回收的时候,将from区中存活的对象复制到to区,清空from区
然后将from区和to区互相换个名字
缺点: 堆内存空间利用低
3、标记整理算法
根据可达性分析算法,将所有存活的对象进行标记
整理阶段,将所有存活的对象放到堆的一端,之后清理掉这些对象的内存。
缺点: 整理的效率低
4、分代垃圾回收
将堆内存分为新生代、老年代
新生代又分为:伊甸园、幸存区from、幸存区to
新创建的对象会被放到伊甸园中。
如果伊甸园满了,则会进行Minor GC。
将伊甸园和幸存区from中的存活对象复制到幸存区to中。
清理伊甸园和幸存区from。之后幸存区from、幸存区to互换名字
每次发生MInor GC时,存活的对象年龄 + 1,当到达15时,则会被放到老年代中。
如果老年代满了,首先会触发Minor GC,如果新生代还是放不下,则会触发Full GC。
如果Full GC之后,老年代还放不下,则会爆出OOM。
某个对象不想让JVM进行垃圾回收,怎么办?
答:
- 可以使用静态变量指向该对象。因为静态变量生命周期是与类的生命周期一致的。
- 使用软引用指向,但是在内存不足时,会被回收。
JUC
CAS是什么
答:
- 是在多线程的环境下,为了保证数据的安全,实现的一种原子性操作。
- CAS的思想就是:用一个预期值和要更新的值进行比较,两值相等才会进行更新。
- 在Java中,CAS操作由
Unsafe
类提供支持,Unsafe类能够对操作系统的底层功能进行控制。 CAS + volatile 关键字
可以实现无锁并发。适用于竞争不激烈、多核 CPU 的场景下。
AQS是什么
答:
AQS
是并发包下的一个抽象类,为构建锁和同步器提供了一些通用功能实现。ReentrantLock
、CountDownLatch
都是基于AQS实现的。- 在AQS中,主要维护了一个同步状态
state
和一个FIFO线程等待队列
。线程通过CAS的操作对同步状态state
进行修改,同时把修改失败的线程放到线程等待队列中,并使用本地方法park()
将线程阻塞。之后同步状态state
变为0后,会把线程等待队列的首个结点唤醒unpark()
,使其再次修改同步状态state
。 - AQS的核心思想是利用了模板方法模式,它定义了一套多线程访问共享资源的规范,它同时把线程的排队,阻塞,唤醒这些都实现好了,我们只需要关注如何改变同步状态即可。
- 比如说现在要自定义一个锁。首先要继承
AQS
抽象类,重写它模板方法中没有实现的方法,在方法中使用CAS操作同步状态state
即可。
什么情况会发生线程阻塞
答:
- 等待阻塞: 通过
synchronized
关键字实现的同步中,如果同步代码块正在被其他线程访问,那么当前线程就会被阻塞。 - 睡眠阻塞: 线程调用
Thread.sleep()
方法会使当前线程进入休眠阻塞状态,直到指定的时间到达,线程才会被唤醒。 - IO阻塞: 线程等待外部数据的时候,如果
数据尚未就绪
,则线程就会被阻塞。例如:网络IO、磁盘IO
AQS怎么实现线程阻塞
答:
- AQS内部维护了一个
FIFO的线程等待队列
。如果线程的请求未能立即得到满足,会将当前线程以及请求类型封装成一个节点(Node)
,并将其加入到队列的尾部
,同时调用LockSupport.park(this)
本地方法将线程阻塞。
AQS用到了哪些设计模式
答:
- 模板方法模式:AQS中的
acquire
、release
等方法就是模板方法,这些方法中定义了一些基本的流程,而具体的实现则需要由子类完成。
AQS实现有哪些
答:
- ReentrantLock:可重入互斥锁,ReentrantLock会使用AQS的
state
字段表示锁的占用状态,0代表未被任何线程占用,大于0则表示锁已经被某个线程占用。 - CountDownLatch:同步器,允许一个线程一直等待其他线程的执行操作结束。
- Semaphore:信号量,用于控制同时访问某个特定资源的操作数量。
- FutureTask:获取线程异步执行的结果。
介绍一下ReentrantLock
答:
- ReentrantLock:是一个
可重入独占式
的锁。和synchronized
关键字类似。不过,ReentrantLock 增加了轮询、超时、中断、公平锁和非公平锁等高级功能。 - ReentrantLock 里面有一个内部类
Sync
,Sync 继承AQS
(AbstractQueuedSynchronizer),添加锁和释放锁的大部分操作实际上都是在Sync
中实现的。Sync 有公平锁FairSync
和非公平锁NonfairSync
两个子类。
ReentrantLock怎么实现的非公平锁?
答:
- 比如:线程1获取了锁,线程2此时尝试获取锁失败,被放入到了请求等待队列。
- 线程3来了,尝试获取锁,在此期间,线程1释放了锁将state变为了0,那么此时线程3经过CAS比较操作,就获取到了锁。
- 尽管有线程在等待队列中,但新来的线程仍有机会获取到锁。
- 与公平锁的区别: 公平锁:假设线程1此时已经释放了锁,线程3来了,在请求锁时,系统会先检查等待队列是否为空,判断是否有其他线程已经在等待这个锁。由于线程2已经在等待队列中,系统会强制线程3去排队,而不是尝试抢占锁,即使锁现在是空闲状态。因此,线程3的锁请求会失败,会被加入到等待队列中,变成等待状态。
线程池核心线程数应该怎么设置
答:
- 首先要判断任务是什么类型:
CPU密集型
、IO密集型
CPU密集型
:此时任务需要进行大量的计算操作,核心线程数可以设置成CPU核数 + 1
。如果设置太多,只会导致线程上下文切换频繁,加重CPU负担。IO密集型
:任务主要是进行网络IO或者是磁盘IO,并不会占用太多CPU,核心线程数可以设置成2 * CPU核数
。- 这只是一个一般性的设置原则,具体的设置值还需要根据系统的实际工作负载来进行调整。
线程池的拒绝策略
答:
- 抛出
RejectedExecutionException
来拒绝新任务的处理。 - 使用主线程运行
- 不处理新任务,直接丢弃掉。
- 丢弃最早的未处理的任务请求。
怎么停止一个线程池
答:
shutdown()
方法:如果有提交但尚未开始的任务,会等到这些执行完成后,停止线程池。shutdownNow()
方法:直接关闭正在执行和等待执行的任务。对于正在执行的任务,shutdownNow()
会发送中断信号尝试中断它们。
调用shutdown的过程
答:
- 当调用
shutdown()
方法时,线程池首先不再接收新任务,但是会处理完队列中的任务 - 等所有任务执行完毕后,则会停止线程池。
- 可以使用
awaitTermination()
,阻塞当前进程,直到线程池结束再进行操作。
介绍一下线程的sleep方法
答:
- sleep方法是Thread类的一个静态方法,用来阻塞当前线程到执行时间,如果线程持有锁,在该期间不会释放锁。
- 如果睡眠的线程被打断,则会抛出
InterruptedException
异常。
线程睡眠怎么打断
答:
- 通过调用线程的
interrupt
方法,将睡眠或者等待的线程进行打断。被打断的线程则会抛出InterruptedException
异常。
Synchronized锁的升级过程
答:
- 无锁:
- 偏向锁:
- 轻量级锁:
- 重量级锁:
volatile有什么用
答:
volatile
关键字可以保证变量的可见性。即每次使用它都到主存中进行读取。- 防止 JVM 的指令重排序。插入特定的 内存屏障 的方式来禁止指令重排序。
开启多线程的方式
答:
- 继承
Thread
类,重写run方法 - 实现
Runnable
接口 - 实现
Callable
接口 (可以获得线程的返回结果) - 使用线程池
Synchronized 和 Lock级别的锁的区别
答:
- 锁的获取和释放方式:
- synchronized自动获取锁、释放锁
- Lock需要手动获取和释放
- 等待是否可中断:
- 等待synchronized锁的线程不可被中断。
- 等待Lock锁的线程可以响应中断。
- 公平性:
- synchronized是非公平锁,不确保等待的线程获得锁的顺序
- Lock可以设置为公平锁或非公平锁
Lock级别的锁有:
- ReentrantLock:可重入的互斥锁。
- ReentrantReadWriteLock:可重入的读写锁。读线程可以同时持有锁,对写线程互斥。
Synchronized 实现并发控制的底层实现是什么
答:
synchronized关键字是基于Java对象头的monitor对象
实现的。每一个Java对象都自带一个监视器。
synchronized实现并发控制主要流程为:
- 当一个线程尝试访问一个synchronized代码块或者方法时,首先会尝试获取对应的monitor对象。
- 如果monitor对象已经被其他线程持有,则当前线程进入阻塞状态,进入等待队列等待。
- 如果monitor对象未被其他线程持有,或者已经被释放,那么这个线程就会获取到monitor对象的所有权,并持有它,然后进入synchronized代码块或方法执行代码。
- 当线程执行完synchronized代码后,它会释放monitor对象,唤醒等待队列中的其他线程。
Mysql
Mysql 主从复制
答:
- Master主库在事务提交时,会把数据变更记录在二进制日志文件
Binlog
中。 - 从库读取主库的二进制日志文件
Binlog
,写入到从库的中继日志Relay Log
。 - slave重做中继日志中的事件,将主库数据进行同步。
SQL 优化
答:
- 避免直接使用
select *
,指明查询的字段。(减少网络消耗,减少解析器成本,使用索引提高查询性能)。 - 关联查询,使用小表驱动大表,使用数据量较小,索引比较完备的表,用它的索引和条件对大表进行数据筛选。
- 经常使用的
where
、group by
、order by
字段添加索引。 - 批量操作的时候,减少与数据库交互的次数。
- limit分页查询的时候,可以限制下分页的页数。或者使用覆盖索引加子查询形式进行优化。
- 涉及到要合并多个表的查询结果时,能用
union all
就不要用union
,因为union会对数据遍历,排序,比较,更耗时
Mysql 索引类型
答:
按数据结构划分
- B+树索引:基于B+树实现的。除了叶子节点,每个节点只存储指针,所有的数据都会出现在叶子节点,叶子节点使用了双向链表进行连接。
- hash索引:基于哈希表实现的,等值查询时效率高,不支持范围查询。
- 全文索引:是一种通过建立倒排索引,快速匹配文档的方式。
按存储结构划分
- 聚簇索引:主键索引就是聚集索引,非叶子节点只存储索引,叶子节点会记录这一行的数据。
- 二级索引:叶子节点会记录该字段值对应的主键值。
按字段特性划分
- 主键索引
- 唯一索引
- 普通索引
- 前缀索引
按个数划分
- 单列索引
- 联合索引
Mysql 事务隔离级别
答:
- 读未提交: 一个事务可以读取另一个事务未提交的数据。会产生脏读。
- 读已提交: 一个事务可以读取另一个事务已提交的数据。会产生不可重复读。
- 可重复读: 在同一个事务内,先后读取同一条记录保持一致。会产生幻读。
- 串行化: 所有的事务串行执行。没有并发问题,效率低。
什么是MVCC
答:
- MVCC: 多版本并发控制。用来维护一个数据的多个版本。
- 底层实现: 它的底层实现分为三部分:1.
隐藏字段
2.undo log日志
3.readView
隐藏字段
:在Mysql中每张表都设置了隐藏字段,有trx_id(事务id),记录的是操作当前记录的事务id。roll_pointer(回滚指针),指向的是当前记录的上一个版本地址。undo log日志
:存储旧版本的数据,旧版本的数据会形成一个版本链,通过回滚指针进行连接。readView
:解决的是版本选择的问题,它定义了一套访问规则,当事务id满足要求的时候,才能访问到对应版本的记录。不同隔离级别生成readView的时机也不同,在RC隔离级别下,在每次进行快照读时,都会生成一个readView。在RR隔离级别下,只在第一次进行快照读时才生成readView,后续都复用同一个。
Mysql范式
答:
- 一范式: 数据库表中的每一个字段是不可再拆分的。是关系型数据库必须满足的。
- 二范式: 满足一范式的基础上,消除了非主属性对码的部分函数依赖。(例如学生表包含了学生信息和课程成绩,那么课堂成绩完全可以通过
学生id
和课程id
推导出来,需要拆分成学生表
、课程表
、学生课程关系表
。)每张表都只会描述一件事情 - 三范式: 满足二范式的基础上,消除了非主属性对码的函数传递依赖。(例如在学生表中,有院系和院系主任这2个字段,而院系主任与院系之间存在关联关系。需要拆分成
学生表
、院系表
)
总结:
- 第一范式:确保原子性,表中每一个列数据都必须是不可再分的字段。
- 第二范式:确保唯一性,每张表都只描述一种业务属性,一张表只描述一件事。
- 第三范式:确保独立性,表中除主键外,每个字段之间不存在任何依赖,都是独立的。
MySql分页查询后面越来越慢,怎么优化?
答:
优化思路:1. 覆盖索引加子查询 2. 限制用户不允许查询页数太大的数据。
select * from user limit 9000000, 10;
优化后
select * from user u join(select id from user limit 9000000, 10) a on u.id = a.id;
- 首先只查询出分页记录的id。
- 然后将查询出来的id与与原表进行联表查询。
- 为什么会提升查询性能:原先是需要返回N + M条记录的
所有字段信息
,字段信息非常多情况下,需要进行大量的磁盘IO
才可以读取完。而优化后只返回分页记录的id信息
,从而会减少磁盘IO次数。
Mysql的事务特性,分别代表什么,脏读,不可重复读,幻读是什么
答:
事务特性:ACID
- 原子性:事务是一个不可分割的工作单位,操作要么全部执行,要么全面不执行。
- 一致性:事务操作的前后,数据库从一个一致性状态转变成另一个一致性状态。比如从转账业务角度来看,A向B转账之后事务,他们账户的总额与转账之前的总额要保持一致。
- 隔离性:事务不会受到其他并发执行事务的影响
- 持久性:事务对数据的操作会保存到磁盘,永久性的数据改变。
并发的事务操作带来的问题:
- 脏读:一个事务读取了另一个事务未提交的数据。如果另一个事务进行回滚,则当前读取的数据就是脏数据。
- 不可重复读:在同一个事务内,前后读取同一条数据不一致。
- 幻读:在同一个事务中,先后执行相同的查询操作,两次执行返回结果集的行数不一致。
Mysql日志有哪些,分别有什么作用
答:
-
undo log
- 回滚日志,有两大作用:
事务回滚
:事务处理过程中,如果遇到错误可以将数据恢复到事务开始之前的状态,保证事务的原子性。实现MVCC
:undo log中会记录每条数据的多个版本,当执行快照读时,如果事务id满足访问规则,则会读取到对应版本的数据。
-
redo log
- 重做日志,记录的是事务提交时数据页的物理修改,用于崩溃后的数据恢复,保证事务的持久性。
-
binlog
- binlog 记录的是数据库表结构变更和表数据的修改。它的用途有:
- 主从复制: 从库读取主库的binlog日志进行数据同步。
- 数据恢复: 可以将数据恢复到指定时间点的状态
char 和 varchar 的区别
答:
- char: 定长类型,占用的空间大小是固定的。
- varchar: 变长类型,占用的空间是字符串的实际大小。varchar类型数据实际占用的字节数会记录到行记录中的
变长字段长度列表中
Redis
RDB和AOF持久化的区别
答:
RDB:
- 由主进程fork一个子进程,子进程共享主进程的内存数据。子进程负责将内存数据写入到磁盘的RDB文件中。
- 采取了copy-on-write 技术,此时,如果主进程执行读操作时,直接访问共享内存即可。当主进程执行写操作时,则会在内存中拷贝一份数据,对拷贝的数据执行写操作,这样不会影响到子进程读取的内存数据。
- 相比于AOF,进行数据恢复的时候非常快,因为RDB文件存储的就是内存中的数据。
- 但是,有可能在两次备份之间,数据会丢失。
- 通常适合用作数据备份,适合于灾难恢复,可以很方便的被迁移到另一个数据中心。
AOF:
- 将每一个写操作的命令记录在AOF文件中,可以看作是一个命令日志文件。
- 数据完整性高,可以每执行一条指令就记录一次,或是每秒记录一次。
- 数据恢复时速度慢,因为要执行每一条的指令。
缓存穿透、击穿、雪崩的原因,解决方案
答:
- 缓存穿透: 请求的数据缓存和数据库中都不存在
- 缓存空值
- 使用布隆过滤器
- 缓存击穿: 缓存中热点数据过期了,大量请求直接访问到了数据库。
- 互斥锁:只让一个业务线程重建缓存。其他线程等待锁的释放,然后重新查询缓存,要么直接返回空值。
- 缓存雪崩: 大量缓存数据在同一时间失效,大量请求直接访问到了数据库。
- 给不同key的失效时间添加随机值
- 不设置缓存的过期时间
Spring
讲一讲AOP,是怎么实现的
答:
-
AOP: 面向切面编程,把方法中通用的功能抽离出来,比如(鉴权、日志记录等),通过预编译或是动态代理方式在不修改源代码的情况下给程序进行功能增强。
-
实现AOP有两种方法:
- 在编译期间生成要增强的字节码文件。
AspectJ
- 在程序运行时动态生成字节码文件。
Spring AOP
- 在编译期间生成要增强的字节码文件。
JDK动态代理,CGLIb动态代理
答:
- JDK 动态代理只能代理实现了接口的类,而 CGLIB 可以代理未实现接口的类。
- CGLIB 动态代理是通过生成一个被代理类的子类来拦截被代理类的方法调用,因此不能代理声明为 final 类型的类和方法。
怎么自定义SpringBoot Starter
答:
- 首先创建一个自动配置类,在这个配置类编写需要向容器注入的Bean。
- 在
resources
目录下,新建META-INF
文件夹,在文件夹中创建spring.factories
文件。在一个文件添加自动配置类的全限定名。 - 其他模块导入自定义Starter的依赖即可。
Spring 事务是什么,传播机制是什么
答:
- Spring 事务: 是在执行某项操作时,为了确保操作的数据完整性和一致性,会将其看作一个独立的工作单元
- 传播机制: 定义了当某个事务方法被另一个事务方法调用时,事务该如何传播。
- (默认)如果当前已经有事务,则加入该事务。没有则新建一个新事务。
- 新建事务,如果当前存在事务,把当前事务挂起。
- 如果当前存在事务,则会在当前事务内嵌套一个事务,如果没有事务,则新建一个事务
- 如果当前存在事务,则加入该事务;如果当前没有事务,则抛出异常。
- 如果当前存在事务,则加入该事务;如果当前没有事务,则以非事务的方式继续运行。
- 以非事务方式运行,如果当前存在事务,则把当前事务挂起。
- 以非事务方式运行,如果当前存在事务,则抛出异常
Spring 注入Bean的几种方式
答:
在Spring框架中,有三种主要的方式可以实现依赖注入,也就是向对象注入Bean。优先级从上至下
- 构造器注入:Spring通过调用一个Bean的构造器,将依赖项作为参数传递,从而实现依赖注入。
- Setter注入:通过调用一个Bean的Set方法,将依赖项作为参数传递,从而实现依赖注入。
- 字段注入:Spring直接将依赖项注入到类的字段中,通常使用
@Autowired
或@Resource
注解来实现。
Spring 注入Bead方式的先后顺序
答:
Spring的执行顺序是:构造器注入 -> Setter注入 -> 字段注入。
Spring中用到了哪些设计模式
答:
- 工厂设计模式
- Spring 使用工厂模式可以通过
BeanFactory
或ApplicationContext
创建 bean 对象。 - BeanFactory:延迟注入(使用到某个 bean 的时候才会注入)
- ApplicationContext:一次性创建所有 bean,功能更强大
-
单例设计模式
- 默认情况下,Spring容器中所有的Bean都是单例的。
- Bean会被放在一个
ConcurrentHashMap
缓存中。
-
代理设计模式
- SpringAOP基于JDK动态代理或CGLIB动态代理生成的代理对象,对方法进行功能增强。
-
模板方法模式
- 以
Template
结尾的类,使用了模板方法模式。也就是定义了一个操作的框架,而其中的一些操作下沉到子类实现。
- 以
-
观察者模式
- Spring 事件驱动模型,使用的就是观察者模式。对代码进行解耦。
Spring事务失效的原因
答:
- 异常被Catch处理
- Spring的默认事务规则是只有在
抛出运行时异常
和Error
时才进行回滚。如果使用Catch处理,Spring就无法感知到这些异常。
- Spring的默认事务规则是只有在
- 方法不是 public 修饰
- SpringAOP默认使用的是JDK动态代理,它其实是生成一个接口的实现类,重写其中的方法进行功能增强。不是public 修饰的方法就不是接口中的方法,所以不会增强。
- 没有被Spring管理
- Spring AOP默认只会对Spring的Bean生成代理对象
- 方法内部调用
- 调用同一个类中的事务方式时,是this对象调用,而不是代理对象调用
- 数据库存储引擎不支持事务
- Spring 事务是业务层的事务,其底层还是依赖于数据库本身的事务支持
Mybatis
Mybatis缓存
答:
- 一级缓存: 作用域是
SqlSession
级别(与数据库的一次会话),也就是在同一个SqlSession下可以走缓存。基于HashMap的本地索引, 默认是打开的,无法关闭。 - 二级缓存: 作用域
namespace
级别的,查询同一个mapper中的方法会走缓存,默认是关闭的。
二级缓存的缺点:
- 数据一致性问题: 二级缓存是可以被多个
SqlSession
共享的,所以就有可能出现并发安全问题。 - 资源消耗问题: 二级缓存会缓存一个
mapper
中记录,可能会消耗更多的内存资源。
Mybatis中${}和#{}有什么区别
答:
- ${}:遇到 ${},Mybatis会直接将 ${} 中的内容拼接在SQL语句中,容易引发SQL注入问题。
- #{}:遇到#{},Mybatis会使用预处理(
PreparedStatement
)语句,将 SQL 中的 #{} 替换为?
号进行占位,最后会调用PreparedStatement
的set
方法来赋值。能够预防SQL注入问题。
预处理语句(PreparedStatement)的优势:
- 性能更好:相比于
statement
,预处理语句只会编译一次,然后多次使用。减少SQL语句的编译次数,提高数据库的执行效率。 - 防止SQL注入:
PreparedStatement
可以使用参数占位符?
,并且会对参数进行合适的转换。
MyBatis中的resultMap与resultType是什么?如何使用?
Linux
Linux 常用命令
答:
top
:查看整个系统资源使用情况tail
:
计算机网络
TCP三次握手、四次挥手
答:
TCP三次握手
- SYN: 首先由客户端发送一个SYN包给服务器端,此时客户端进入
SYN_SEND
状态。 - SYN+ACK: 服务器收到客户端的SYN包,需要做出回应,所以发送了一个SYN+ACK的包,此时服务器进入
SYN_REVD
状态。 - ACK: 最后,客户端收到服务器端的SYN+ACK包后,再向服务器发送一个ACK确认包,此时客户端进入
ESTABLISHED
状态,当服务器接收到这个确认包时,也进入ESTABLISHED
状态,此时,完成了三次握手,客户端和服务器端正式建立起了连接。
TCP四次挥手
- FIN: 客户端打算关闭连接,发送一个FIN标志的包给服务器,告诉它我这边的数据已经发送完了,我要关闭连接了。
- ACK: 服务器接收到这个FIN包后会发送一个ACK标志的包给对方,告诉它:“我知道你要关闭连接了。”
- FIN: 服务端处理完数据后,也向客户端会发送一个FIN标志的包,告诉它:“我这边的数据也发送完了,我也要关闭连接了”。
- ACK: 客户端收到服务端的 FIN 包后,回一个 ACK 应答包。服务器收到ACK应答包后,关闭连接。客户端经过等待时间后关闭连接。
为什么三次握手,两次四次会导致什么
答:
三次握手是为了防止已经失效的连接请求到达服务器
,因为这样会浪费服务器资源。
两次握手问题:
- 可能会与一个已经失效的连接请求建立连接,造成资源浪费。
四次握手问题:
- 三次握手就已经理论上最少可靠连接建立,所以不需要使用更多的通信次数。
HTTP通信过程
答:
- 建立TCP连接: 通过解析URL的主机号,经过DNS解析获取目标服务器的ip,然后与目标服务器建立网络连接,并进行三次握手确认。
- 发送 HTTP 请求: 浏览器向目标服务器发送HTTP请求,HTTP请求主要由请求行、请求头部和请求体组成。
- 服务器响应: 服务器收到请求后,生成 HTTP 响应报文,由响应状态行、响应头部和响应正文组成。
- 浏览器解析渲染: 浏览器接收到返回的数据包,进行解析渲染。
- 断开连接: 通过四次挥手,客户端与服务器之间便会断开连接。
Get和Post方法区别
答:
- 参数传递
- Get:请求参数会加到URL上
- Post:请求参数在请求体中
- 安全性
- Get:请求参数会暴露在URL上,安全性较低
- Post:相比于Get,安全性高
- 数据长度
- Get:URL长度受浏览器的限制,所以参数会有限制
- Post:对数据长度没有限制
- 用途
- Get:一般用于查询操作
- Post:用于更新操作
HTTP常见的请求头、响应头有哪些
答:
请求头:
host
:指定请求资源的主机和端口。Accept
:告诉服务器,客户端支持的媒体类型。Cookie
:用于向服务器发送cookie。Accept-Language
:告诉服务器,客户端接受的语言类型。Accept-Encoding
:告诉服务器,客户端接受的编码方式。Referer
:告诉服务器,这个请求是从哪个页面链接过来的。
响应头:
Content-Type
:告诉客户端,响应内容的类型。Set-Cookie
:设置Cookie的值。Location
:指明客户端应当向哪个URL重新发起请求。
HTTP常见状态码
答:
200
:请求成功302
:临时重定向,浏览器要用另一个 URL 来访问。404
:请求的资源在服务器上不存在或未找到5xx
:表示客户端请求报文正确,但是服务器处理时内部发生了错误
HTTPS加密过程
答:
可以分为两部分:握手阶段、数据传输阶段
- 握手阶段:
- 客户端向服务器发起HTTPS请求,服务器会返回其配置的SSL证书(包含公钥)以及SSL版本和加密方式的选择。
- 客户端会验证服务器的SSL证书的合法性,确认无误后,生成一个随机数使用公钥加密,发给服务器。
- 服务器使用私钥解密得到随机数,然后双方都基于这个随机数创建相同的会话密钥。
- 数据传输阶段:
- 客户端和服务器用会话密钥对数据进行加密,然后进行数据的发送和接收。由于这个过程使用了对称加密,这个过程既安全又高效。
总的来说使用了混合加密,即非对称加密和对称加密。
HTTP 和 HTTPS的区别
答:
- 数据加密:
- http数据是明文传输,https则是在http层和tcp层之间加了一个ssl安全协议,将数据进行加密传输。
- 连接建立:
- http经过tcp三次握手之后就可以进行数据传输,而https经过tcp三次握手之后,还需要进行ssl的握手过程。
- 端口:
- http默认是80,https默认是443
- 认证:
- https需要向CA(证书权威机构)申请数字证书。(证书中包含了网站的公钥、网站的URL、证书的有效期等)
TCP、UDP区别
答:
- 连接方式:
TCP是面向连接的
,在发送数据之前,需要通过三次握手建立连接。UDP是无连接的
,在发送数据之前不需要建立连接
- 可靠性:
TCP提供可靠的服务
,TCP通过确认机制、重传机制、流量控制和拥塞控制等来确保数据的可靠传输。UDP尽最大努力交付
,尽可能快的将数据发送到目的地。
- 通信模式:
TCP是点对点的通信模式
,每一条TCP连接只能有两个端点,即客户端和服务器。UDP支持多种通信模式
,UDP不仅支持一对一,还支持一对多、多对一和多对多的通信模式。
OSI网络模型
答:
- 应用层: 规定了应用程序之间如何进行
数据交换
。 - 表示层: 主要负责数据的编码解码、加密解密等任务
- 会话层: 负责管理通信会话
- 传输层: 负责端到端的数据传输
- 网络层: 负责数据包的发送和数据路由
- 数据链路层: 将数据包装成帧,并进行错误检测和修正
- 物理层: 负责提供物理连接
应用层都有哪些协议
答:
- HTTP: 浏览器最常使用的协议,负责超文本的传输
- HTTPS: HTTP的安全版本,对传输的数据进行加密保护
- FTP:用于文件传输的协议
- SMTP:用于发送邮件的协议
输入url之后到返回结果的过程
答:
- URL解析: 浏览器解析出URL的协议、主机名、端口号、路径等信息。
- DNS解析: 将主机名解析为 IP 地址。如果本地 DNS 服务器缓存中没有记录,则进行迭代式的 DNS 查询流程,从根域名服务器一直查找到目标网站的权威 DNS 服务器获取 IP 地址。
- 建立TCP连接: 与目标服务器建立网络连接,并进行三次握手确认。
- 发送 HTTP 请求: 浏览器向目标服务器发送HTTP请求,HTTP请求主要由请求行、请求头部和请求体组成。
- 服务器响应: 服务器收到请求后,生成 HTTP 响应报文,由响应状态行、响应头部和响应正文组成。
- 浏览器解析渲染: 浏览器接收到返回的数据包,进行解析渲染。
- 断开连接: 当浏览器渲染完成之后,客户端与服务器之间便会断开连接。
TCP/IP模型每一层实现了什么功能
答:
- 应用层: 规定了应用程序之间如何进行
数据交换
。(应用层是工作在操作系统中的用户态
,传输层及以下则工作在内核态)- 常见协议:HTTP、HTTPS、FTP、SMTP
- 传输层: 为应用层提供
端到端的数据传输
。- 常见协议:TCP、UDP
- 网络层: 主要负责数据包的发送和
路由选择
,(可能会存在多条路径连接源IP和目标IP)。- 常见协议:IP
- 网络接口层: 将网络层的数据包实际
发送到网络的硬件接口
每一层的封装格式:
数据到达网卡之后的流程
答:
- 硬件中断:数据到达网卡之后,会向CPU发送一个
硬中断
。 - 数据接收: CPU暂停正在执行的任务,切换到内核态,通知
DMA控制器
将网卡中的数据拷贝到内核缓冲区
中。 - 包路由和协议解析:内核的网络协议栈会根据数据包头信息,判断该数据包应该传给哪个进程,并解析网络协议,例如TCP/IP协议,然后将数据发送到对应的
Socket缓冲区。
- 唤醒程序处理数据:数据包发送到正确的Socket后,相关的进程就会被唤醒,程序开始处理接收到的数据。
- 发送响应:应用程序处理完请求后,会将HTTP响应写入到Socket缓冲区,(CPU通知DMA将Socket缓冲区的数据拷贝到网卡)然后通知网络协议栈发送数据。网络协议栈将从发送缓冲区读取数据,将其放入网卡的内存缓冲区,然后通知网卡发送数据。
操作系统
线程和进程的区别
答:
协程和线程的区别
答:
协程的使用场景
答:
进程的通信方式
答:
操作系统内存管理机制
答:
其他
介绍IO多路复用
答:
Cookie 和 Session有什么区别?
答:
- 存储位置:
- Cookie:存储在客户端浏览器上,每当浏览器向服务器发起请求时,浏览器都会自动带上该网站的所有Cookie。
- Session:是存储在服务器上的,由于HTTP是无状态协议,为了保持浏览器与服务器之间的关系,才有了Session。SessionID是存储在Cookie中的,服务器从Cookie中拿到SessionID,然后找到对应的Session就知道是哪个用户的请求了。
- 存储容量:
- Cookie:Cookie的大小通常有限制,一般为
4KB
。 - Session:理论上没有限制,主要依赖服务器资源的限制。
- Cookie:Cookie的大小通常有限制,一般为
- 安全性:
- Cookie:会在网络上传输,并且存储在客户端,安全性较低。
- Session:存放在服务器端,相对更加安全。但如果是分布式场景下,需要考虑使用分布式Session。
重定向和转发的区别?
答:
跳转方式:
- 重定向:浏览器端会有两次请求。浏览器向服务器发起一次请求,服务器返回
302状态码
,同时在响应头中给出新的URL地址。浏览器会对新的URL地址再发起一次请求。 - 转发:浏览器端有一次请求。浏览器向服务器发起一次请求,在服务器内部,请求会被转发到其他地方,然后返回浏览器。
地址栏显示
- 重定向:浏览器地址栏会显示新的URL地址。
- 转发:浏览器地址栏不会发生变化。
数据共享
- 重定向:两次请求相互独立,不能共享请求作用域中的数据。如果需要传递数据,可以通过URL进行传递。
- 转发:请求在服务器内部转发,可以共享请求的数据。
重定向和转发的响应状态码
答:
- 重定向:
302
。临时性重定向。告诉客户端应该用另一个URL获取资源。 - 转发:因为转发是在服务器端内部处理的,客户端无感知。一般来说如果请求处理正常,状态码就是
200
。
对称加密和非对称加密算法和使用场景
答:
死锁场景、死锁解决
答:
fail-fast机制问题
答:
微服务和云原生了解多少,策略或者使用场景?
答:
项目
技术选型怎么做的
答:
为什么做这个项目,有合作吗?
答:
Redis 在项目中的使用
答:
实现分布式锁的方式有哪些
答:
项目的部署
答:
项目难点是什么
答:
算法
回文数
链表反转
计算时针与分针的角度
https://www.cnblogs.com/darknessplus/p/10687130.html