java基础
-
JDK 和 JRE 有什么区别?
jdk是开发公具包,里面包含了jre以及jvm
jvm是java运行环境
-
== 和 equals 的区别是什么?
对于基本类型,== 是比较值
对于引用类型,== 是比较地址
equals不能用于基本类型
如果没有重写equals,equals就相当于==
如果重写了equals,equals比较的是对象的内容
(==判断对象物理地址。
equals判断对象Value是否相等。)
-
final 在 java 中有什么作用?
修饰变量,创建时必须赋值,赋值后不能更改
修饰方法,不能被子类重写
final类中的方法默认是final的
-
java为什么可以跨平台?
因为java是运行在jvm上的,jvm有windows版本,有Linux,还有其他的,java运行依赖jdk内的jvm,所以可以处处运行.
-
Synchronized和lock有什么区别?
-
首先,synchronize是java内置关键字,在jvm层面,lock是个java类
-
synchronize可以给类、方法、代码块加锁,lock只能给代码块加锁。
-
synchronize不需要手动获取锁和释放锁,发生异常时会自动释放锁,不会造成死锁,而lock需要自己去加锁和释放锁,使用不当容易出现死锁。
-
通过lock可以知道有没有成功获取锁,但是synchronize无法办到。
-
抽象类必须要有抽象方法吗?
不是必须,抽象类可以没有抽象方法。
-
arryList的扩容机制?
扩容的本质的计算出扩容数组的size后实例化,将原有的数组内容复制到新的数组中去。(不是原数组,而是新数组给与数组对象地址)。
默认情况下,新的容量是原来的1.5倍。ArrayList是通过动态数组来实现的,我们初始化一个ArrayList还没有添加元素时,他是一个空数组,当我们添加第一个元素是,内部会调用扩容方法并返回最小容量10,也就是说ArrayList的初始容量为10(前期容量是10,当添加第11个元素就开始扩容)ArrayList的真正计算是在grow()里面,数组大小是原来的1.5倍,如果扩容之后还不能满足最小容量,则会再次扩容,扩容后的大小是最小容量,后面会调用一个Arrays.copyof()方法,这是真正实现扩容的步骤。
-
mysql 调优?
-
硬件角度:数据库所在的主机硬盘采用好一些的,固态硬盘比机械硬盘IO读取快很多
-
数据库部署架构:采用集群模式,读写分离(采用多个副本机制,当主机不可以,可以从从机中选一台作为主机,减少宕机风险,群集可以提升数据库的并发响应能力。 缺点:数据冗余)
-
从整体应用架构出发:可以架设一层缓存,缓存一些热点数据,常规的可以采用redis,ehcache,
如果架设了缓存,需要防范缓存击穿,穿透,雪崩等问题。
-
在表中建立索引,优先考虑where,group by使用到的字段
-
尽量避免使用select *
-
RAbbitMQ有什么优点?
解耦,异步,肖峰;
-
如何保证RabbitMQ不被重复消费?
为什么会被重复消费:正常情况下,消费者消费消息后会发送一个确认信息给消息队列,消息队列就知道这个消息被消费了。
但是因为网络传输故障导致确认消息没有发送成功,导致消费过的消息被多次发送给消费者。
针对以上问题,一个解决思路:保证消息的唯一性,就算多次传输,不要让消息的多次传输带来影响;保证消息等幂性。
-
java线程的生命周期?
新建,就绪,运行,阻塞,销毁
-
java中的异常有哪几类?分别怎么使用?
分为检出异常(非运行时异常)和非检出异常(运行时异常)
检出异常,必须通过检查处理的,否则不能通过编译
非检出异常,一般都会用try-catch-finally捕捉或者抛出异常
□ ArithmeticException:数学计算异常。
□ NullPointerException:空指针异常。
□ NegativeArraySizeException:负数组长度异常。
□ ArrayOutOfBoundsException:数组索引越界异常。
□ ClassNotFoundException:类文件未找到异常。
□ ClassCastException:类型强制转换异常。
□ SecurityException:违背安全原则异常。
-
常用的集合有哪些?list如何排序?
分三种,一种是实现set接口,一种是实现list接口,还有map。
-
set:主要用到hashSet,treeSet两种,hashSet在工作中一般都是用于去重。
HashSet 无序,不允许重复。还有一个linkedHashSet可以记录插入顺序,看起来是有序的。
TreeSet 可以实现集合排序,可以自然排序以及自定义排序。
-
list:分为ArrayList和linkedList,有序集合,允许重复。
排序的方法一般有两种,一种是通过comparable接口实现排序,一种是通过comparator接口实现排序。
-
map分为hashMap 和linkedHashMap,主要以key-value的方式进行存储。
-
ArrayList与linkedList之前的区别?
ArrayList是数组结构linkedList基于双向链表,ArrayList查询更快,linkedList插入更快.linkedList占用内存更大,节点除了存储数据,还有两个引用,一个指向上一节点,一个指向下一节点.
-
内存溢出是什么情况?
对象有被指向的引用,但是再也用不到它就是内存溢出.
GC机制:复制回收,标记清楚,引用计数(如果有循环引用后,会影响垃圾回收,所以jvm没有采用此方法进行垃圾回收).
-
线程安全的集合?
Set:
-
CopyOnWriteArraySet:是一种基于CopyOnWrite技术的线程安全Set实现,使用写时复制技术,在写操作时复制一个新的数组进行修改,从而避免了数据竞争和锁的争用。适用于读多写少的场景。
-
ConcurrentHashSet
List:
-
Vector:是一个古老的线程安全List实现,使用了synchronized关键字来保证方法的同步性。但是由于其采用了粗粒度锁的方式,效率较低,已经不再推荐使用。
-
CopyOnWriteArrayList:是一种高效的线程安全List实现,使用写时复制技术,在写操作时复制一个新的数组进行修改,从而避免了数据竞争和锁的争用。适用于读多写少的场景.
map:
-
ConcurrentHashMap:线程安全的Map实现,支持高并发读写操作。
-
hashCode方法的作用?
在hashMap中使用,把一个对象变成一个整形.
hashCode规范,如果equals返回true,那么hashCode一定相等,如果hashCode相等,equals不一定相等.
-
NIO是什么,适用什么场景?
NIO就是newIO,接口都是异步的,非阻塞的.
-
java中一个字节占几个字符,int long double占几个字节?
一个字节占两个字符 int 4 long double 8
-
创建一个对象有哪些方法?
new 反射.
-
final/finally/finalize的区别?
final是变量,类,方法的修饰,修饰变量和方法不可改变,修饰类不可被继承.
finally一般用于try-catch后执行的finally
finalize是一个方法,在垃圾回收之前会调用这个方法.
List 和set的区别?
list 有序可重复 允许多个null元素对象,可以使用Iterator(迭代器)取出所有元素,再逐一遍历,还可以使用get(int index)获取指定的下标元素。
set 无序不能重复,只能允许一个null对象,只能使用Iterator取出所有元素。
ArrayList 和 LinkedList 有什么区别?
1.首先,他们的底层数据结构不同,ArrayList底层基于数据实现,LinkedList底层是基于链表实现的。
2.由于底层数据结构不同,ArrayList更适合随机查找,LinkedList更适合删除和添加,查询添加的复杂度不同
3.另外ArrayList和LinkedList都实现了List接口,但是LinkedList还额外实现了Deque接口,所以LinkedList还可以当做队列来使用。
Jdk1.7到jdk1.8 hashMap发生了什么变化?
1.7中是基于数组+链表的方式实现的,1.8是基于数组+链表+红黑树实现的。加入红黑树的目的是为了提高hashMap的插入和查询速度。
1.7插入数据时使用的头插法,1.8用的是尾插法。因为需要判断链表的长度(当链表长度大于8,数组长度达到64)会转为红黑树,所以正好使用尾插法。
1.7中的哈希算法比较复杂,存在各种右移与异或运算。1.8新增了红黑树,可以简化hash算法,节省CPU资源。
说一下hashMap 的put方法
1.根据key通过哈希算法与与运算得出数组下标。
2.找到下标之后,如果下标位置元素为空,则会将key-value封装成Entry对象(1.7是entry对象,1.8是Node对象)并放入该位置。
3.如果不为空
a:如果是1.7版本,首先判断是否需要扩容,不扩容则生成entry对象,然后使用头插法添加到当前链表中。如果存在相同的key,就更新value。
b:如果是1.8,链表长度小于8,就使用尾插法添加到链表尾部,添加侯长度大于8就会转为红黑树。如果链表长度大于8,则会封装成一个红黑树节点,并添加到红黑树中,存在相同的key就更新value。
entry 是先判断是否需要扩容再添加,node是先添加在判断是否需要扩容。
深拷贝与浅拷贝的去呗?
深拷贝和浅拷贝是值对象的拷贝,一个对象中存在两种类型的属性,一种是基本数据类型,一种是引用类型。
浅拷贝:只会拷贝基本来下的值,以及实例对象的引用地址,拷贝出来的对象指向同一个对象。
深拷贝:除了拷贝值,还会拷贝实例对象,深拷贝出来的,指向的不是同一个对象。
CopyOnWriteArrayList底层是什么样的?
1.首先CopyOnWriteArrayList底层也是基于数据实现的,在CopyOnWriteArrayList中添加元素时,会复制一个新的数组,写操作会在新的数组上执行,读操作在原来的数组上进行。
2.写的时候会加锁,防止并发写入丢失的情况。
3.写操作执行完毕后会指向新的数组。
4.CopyOnWriteArrayList允许再写的时候读操作,大大提高了读的性能,适合读多写少的情况。
5.因为每次写的时候会赋值新的数组,所以比较占内存,同时还会出现读取不到实时最新的数据,所以不适合实时性很高的场景。
java中的异常体系是怎么样的
所有的异常都是来自throwable。
throwable 有两个子类
一个是error ,一个是Exception
error表示严重的错误,比如java.lang.StackOverFlowError(栈溢出) 和 java.lang.OutOfMemoryError(内存溢出)。通常出现这种错误时,仅仅靠代码层面已经解决不了了,可能是虚拟机,磁盘,系统出现问题了,所以也不建议在代码中捕获这些Error。
Exception 表示异常,Exception出现时,是可以靠程序自己来解决的。比如空指针,下标越界等。
Exception又分为运行时异常以及非运行时异常。
在Java的异常处理机制中,什么时候应该抛出异常,什么时候捕获异常?
首先考虑这个异常自己能不能处理,能处理就自己捕获,自己无法处理就往上抛。
Java中有哪些类加载器
jdk中自带了三个类加载器: bootstrapClassLoader, ExtClassLoader,AppClassLoader
bootstrap 是ExtClassLoader的父类加载器,默认负责加载 %JAVA_HOME%lib下的jar包和class文件。
ExtClassLoader是AppClassLoader的父类加载器,负责加载 %JAVA_HOME%lib/ext下的jar包和class文件。
AppClassLoader是自定义类加载器的父类,负责加载classpath下的类文件。
jvm
-
JVM的主要组成部分?及其作用?
class loader 类加载器: 加载类文件到内存,只要符合文件结构,就加载
exection engine 执行引擎:也叫解释器,负责解释命令,交由操作系统执行
native interface 本地接口:本地接口的作用是融合不同语言为java使用
Runtimedata area 运行数据区: 运行时数据区是jvm的重点,我们所写的程序会被加载到这里,然后才开始运行
stack栈内存: 是java程序的运行区,是在线程创建时创建,它的生命周期跟随线程的生命周期,线程结束栈内存释放;对于栈来说不存在垃圾回收的问题,只要线程一结束,该栈就结束。栈中的数据以栈帧的格式存在,栈帧是一个内存区块,是一个数据集,是一个有关方法和运行期数据的集合,当一个方法A被调用时就产生了一个栈帧F1,并被压入到栈中,A方法又调用了B方法,于是产生栈帧F2也被压入栈,执行完毕后,先弹出F2栈帧,再弹出F1栈帧,遵循“先进后出”原则。
堆内存:一个jvm实例只存在一个堆内存,堆内存大小是可以调节的. 类的加载器加载了类之后,会给类,方法常变量放到堆内存中去,以方便执行器执行. 堆内存分为三部分:永久存储区(用于存放jdk自身携带的class interface的元数据,也就是说它存储的是运行环境必须的类信息,被装载到这里的数据是不会被垃圾回收的,只有关闭jvm才会使用此区域的占用的内存),新生代,老年代
method area方法区:方法区是被所有线程共享,该区域保存所有的字段,以及字节方法码以及一些特殊方法如构造函数,接口代码也在此定义.
PC Register程序计数器:每个线程都有程序计数器,就是一个指针,指向方法区的方法字节码,由执行引擎读取下一条指令
-
说一下jvm运行时数据区?
不同的虚拟机的运行时数据区可能略微有所不同,但都会遵从java虚拟机规范,主要分为5个部分
-
程序计数器:分支、循环、跳转、异常处理线程恢复都需要依赖这个处理。
-
java虚拟机栈:
-
本地方法栈:
-
java堆
-
方法区:
-
队列和栈是什么?有什么区别?
-
队列和栈都是用来预存储数据的。
-
队列允许先进先出检索数据,但是也有例外的情况,Deque接口允许从两段检索元素
-
栈和队列很相似,但是它是后进先出进行检索
-
什么是双亲委派机制?
如果一个类加载器收到类加载请求,首先它不会主动去加载 ,而是委派给父类去加载,父类也会交给父类,直到顶层,若果父类加载不了,子类才会尝试去加载类。
-
类装载的执行过程
-
加载:根据查找路径找到相应的class文件然后导入;
-
检查: 检查class文件的正确性
-
准备:给类中的静态变量分配内存空间
-
解析:虚拟机将常量池中的符号引用替换成直接引用的过程。符号引用就理解成一个标示,而在直接引用直接指向内存中的地址。
-
初始化:对静态变量和静态代码块执行初始化工作。
-
怎么判断对象是否可以回收?
一般有两种方法:
-
引用计数器:为每一个对象创建引用计数,有对象引用时计数器+1,有对象被释放时计数器-1,当计数器为0时就可以被回收。缺点就是不能解决循环引用的问题。
-
可达性分析:从GC roots开始乡下搜索,搜索所走过的路径称为引用链。当一个对象到GC Roots时没有任何引用链相连时,则证明此对象是可以被回收的。
-
java中有哪些引用类型?
-
强引用:发生gc时不会被回收。
-
软引用:有用但不是必须的对象,在发生内存溢出之前会被回收。
-
弱引用:有用但不是必须的对象,在下一次GC时一定会被回收。
-
虚引用:无法通过虚引用获得对象,用PhantomReference 实现虚引用,虚引用的用途是在GC时返回一个通知
-
说一下JVM有哪些垃圾回收算法?
-
标记-清除算法:标记无用对象,然后进行清除回收。缺点:效率不高,无法清除垃圾碎片。
-
标记-整理算法:标记无用对象,让所有存活的对象向一端移动,然后清除掉边界以外的内存。
-
复制算法:按照容量划分两个大小相同的内存区域,当一块快要用完的时候,将或者的对象复制到另一块上,然后把已使用的内存空间一次性清理掉。缺点:内存使用率不高,只有原来的一半。
-
分代算法:根据对象的存活周期的不同将内存划分为几块,一般是老年代和新生代,新生代采用复制算法,老年代采用标记-整理算法。
-
说一下jvm有哪些垃圾回收器?
-
serial:最早的单线程串行垃圾回收器
-
serial Old:serial垃圾回收器的老年版本,同样也是单线程的,可以作为CMS垃圾回收器的备选方案。
-
ParNew:是serial的多线程版本。
-
Parallel 和 ParNew 收集器类似,都是多线程的,但是parallel是吞吐量优先的收集器,可以牺牲等待时间换取系统的吞吐量。
-
Parallel Old 是 Parallel 老生代版本,Parallel 使用的是复制的内存回收算法,Parallel Old 使用的是标记-整理的内存回收算法。
-
CMS:一种以获得最短停顿时间为目标的收集器,非常适合B/S系统(B/S架构的软件一般都是通过访问一个网页的形式来使用)。
-
G1:一种兼顾吞吐量和停顿时间的 GC 实现,是 JDK 9 以后的默认 GC 选项。
-
能保存GC执行吗?
不能,虽然可以调用System.gc()或者runtime.gc(),但是没办法保证gc的执行
-
JVM内存结构,GC算法,CMS、G1的原理?
JVM内存结构:
Java虚拟机(JVM)的内存可分为以下几个部分:
1.程序计数器(Program Counter Register):用于记录正在执行的Java虚拟机字节码指令的地址。
2.Java虚拟机栈(Java Virtual Machine Stacks):用于存储方法的局部变量、操作数栈、动态链接、方法出口等信息。
3.本地方法栈(Native Method Stack):与Java虚拟机栈类似,但是用于执行本地方法的栈。
4.Java堆(Java Heap):用于存放对象实例和数组,是Java虚拟机中最大的一块内存区域。
5.方法区(Method Area):用于存储已被虚拟机加载的类信息、常量、静态变量等数据。
6.运行时常量池(Runtime Constant Pool):是方法区的一部分,用于存放编译期生成的各种字面量和符号引用。
7.直接内存(Direct Memory):不是JVM运行时数据区的一部分,但是也被频繁使用。直接内存是利用操作系统的内存管理进行分配和释放的,可以通过JVM的-XX:MaxDirectMemorySize参数来限制其大小。
GC算法:
Java虚拟机的垃圾回收算法主要有以下几种:
1.标记-清除算法(Mark and Sweep):首先标记所有活动对象,然后清除所有未标记对象。该算法会产生内存碎片,并且效率较低。
2.复制算法(Copying):将内存分成两块,每次只使用其中一块。当一块内存用完后,将还存活的对象复制到另一块内存上。该算法可以避免内存碎片,但是需要消耗更多的空间。
3.标记-整理算法(Mark and Compact):首先标记所有活动对象,然后将它们向一端移动,然后清理掉端部的所有垃圾对象。该算法可以避免内存碎片,但是效率较低。
4.分代算法(Generational):根据对象的年龄将其分为不同的代,如新生代和老年代。然后针对不同代使用不同的垃圾回收算法。例如,新生代通常使用复制算法,老年代通常使用标记-整理算法。
CMS、G1的原理:
Concurrent Mark Sweep(CMS):是一种基于标记-清除算法的垃圾回收器。在CMS垃圾回收过程中,应用程序可以继续运行,垃圾回收过程会和应用程序并发执行。CMS主要关注最小化停顿时间。
Garbage First(G1):是一种基于分代算法的垃圾回收器,旨在提高大堆内存情况下的垃圾回收性能。G1将堆划分为多个大小相等的区域,每个区域可能属于新生代或老年代。G1的垃圾回收过程分为四个阶段:初始标记、并发标记、重新标记和清理。在清理阶段,G1会优先清理那些垃圾最多的区域,从而避免因清理时间过长而导致的全局停顿。
Spring boot
-
Springmvc用到的设计模式有哪些?
简单工厂,工厂方法,单例模式,适配器,观察者等模式
-
简单工厂模式:比如beanFactory.getBean()就会使用到这个模式,就是简单工厂模式的体现
-
工厂方法模式:在使用factoryBean调用getObjects获取Bean的时候就是使用工厂方法
-
单例模式:spring创建对象默认就是单例
-
适配器模式:在springMVC中HanderAdapter处理不同的controller就是使用的适配器模式
-
装饰器模式:在读源码的时候经常看到某个类带wrapper后缀,在Bean创建的时候,他就作为一个封装,封装到wrapper中去
-
动态代理:在aop中使用的就是代理模式
-
观察者模式:在spring中监听器,我们使用到listener时就会使用到观察者模式
-
策略模式:我们使用不同的resuorce时就会用到策略模式
-
Spring中bean的生命周期?
-
创建前
-
准备阶段
-
创建实例阶段
-
依赖注入阶段
-
容器缓存阶段
-
销毁实例阶段
如何理解springboot-starter
springboot-starter 是springboot中的核心特性之一,除此以外,springboot还有自动装配,Actuator监控等一些特性,这些特性都是为了让开发者在开发基于spring生态下的应用时更加专注于业务,减少对配置和外部环境的依赖。
starter 组件的主要作用
1.starter 是以功能为维度来维护对应jar包的版本依赖的,那么开发者就不需要去关心版本冲突这种容易出错的细节了。
2.starter组件会给对应功能的jar包全部导进来,避免开发者自己去引入依赖。
3.starter组件内部集成了自动装配这样一个机制。 程序依赖对应的一个starter组件以后,会自动加入到spring生态中,对于相关bean 的管理,也是基于自动装配机制来完成的。
4.依赖starter组件以后,这些组件对应的功能所需要维护的配置会自动集成到springboot中,我们只需要在yml中进行维护配置就好了
总结 : 在我看来,starter组件几乎完美的体现了springboot里面的约定优于配置这么一个理念。另外springboot官方提供了非常多的starter组件、如Redis,JPA,MongoDB等等,但是官方并不能去维护所有中间件的starter组件,所以对于第三方组件,第三方会自己主动的去维护。
官方starter 组件一般命名为spring-boot-starter-xxx 第三方一般命名为 xxx- spring-boot-starter,这也是一个约定优于配置的体现。
Spring cloud alibaba
1.分布式事务
2pc:事务协调者会发起指令,然后参与者开启事务执行业务,但是不提交,等所有参与响应,然后参与者发起确认或回滚执行。(数据库层面操作)
缺点:如果有个程序不可用,其他参与者会浪费很多资源。
3pc:在2pc基础上增加canCommit阶段,开始之前确认每个参与者是否可以执行,然后再执行2pc操作。
TCC(代码层面):颗粒度小
try阶段:给所有的数据准备好
confirm阶段:插入数据
cancel阶段:执行取消操作
saga(长事务,流水线比较长时使用,不要求隔离 在高并发情况下会引发超卖的问题):
参与者直接提交事务,出现问题侯执行反向回滚。
mq消息最终一致性:生产者发送消息给mq,mq发给指定的消费者消费,当生产者出现问题,mq直接返回false,如果消费者出现问题,会一直ack(重试),直到成功,如果一直不成功,只能采取人工干预的方式了。
Redis
1.Redis支持哪几种数据类型?
-
String:最基本的数据类型,二进制安全的字符串,最大512M
-
List:按照添加顺序保存的字符串列表
-
set:无需的字符串集合,不存在重复的元素
-
sorted set:已排序的字符串集合
-
hash:key-value对的一种集合
2.Redis主要消耗什么物理资源?
redis是一种基于内存高性能的数据库,主要依赖于内存
3.Redis和数据库怎么保证数据一致性
都会出现脏数据, 更推荐先操作数据库,更加能保证数据的一致性。
删除出现删除失败的情况,可以采用mq执行异步操作,但是会出现高耦合的情况,可以使用canal客户端进行解耦
4. redis 是单线程还是多线程?
redis 在6.0之前都是单线程(客户端发起请求到服务端,服务端内部处理请求都是单线程操作)。
6.0之后,客户端发起请求到服务端,采用了多线程操作,主要利用CPU的多核来实现,但是服务端内部处理还是采用排队单线程处理。
客户端发起请求到服务端:也就是网络请求
服务端内部: 主要就是键值对的读写操作
5.redis 是单线程为什么速度这么快?
1.redis是基于内存操作的,所以他的效率会特别高
2.redis是单线程的,没有线程切换的资源损耗
3.redis基于IO的多路复用机制提升redis的I/O利用率
4.redis全局引用的hash表,数据结构更高效.
6.redis底层是如何利用跳表来存储的?
什么是跳表?
按分值从小到大排序,可以将zset 的有序链表改造为近似对半查找的算法,
比如我们有一个分为2,为5的链表,这是需要插入一个分值为3的元素,这个时候只需要让2的指针指向3,然后3指向5就完成了插入.
跳表查找的核心就是,每两个元素他会建一个冗余的元素,冗余的元素上面还可以建冗余的元素,查找的时候,比如查找一个分值为15的元素,会先从 1 -> 10 ->20中找到 10 ->20的区间 然后找到10-15的区间,然后找到15.
7.redis key过期了为啥没有释放内存?
主要有三种情况
1.比如 创建了一个键值对 并设置的了过期时间,但是后面又修改了里面的value,但是没有设置过期时间,那么这个key就会永不过期.
set name zhangsan EX 120
set name zhangsan1 这时候这个key会成为永不过期
2.惰性删除,这个key过期了但是不会立马删除,下次找到这个key的时候会判断是否过期,过期了才会删除.
3.定时删除,每次(默认100ms)删除一部分过期的key并不会删除全部的,所以如果存在大量过期的key,那么依旧会有一部分key没有得到删除
8.redis设置了过期时间,为什么会被清除?
redis有一个内存淘汰机制,当redis已用内存超出了maxmemory的值时,会触发主动清理策略
主要分为三种,一种是针对过期的key做清理,一种是针对所有的key做清理,还有一种是不处理,当有新的写操作都会报错.
被清除了可能就是redis中设置了针对所有key的清理策略引起的
详情可以看下图