文章由来:没有自己的java体系,就是死记硬背面试题目。还是要带着理解去记忆,才能是自己的东西,多问问为什么?
本文借鉴--杨晓峰-java核心技术36讲。后期完善,变为自己的理解
01.为什么使用java?
1.两个特点:跨平台运行/垃圾收集器
-
为什么能跨平台?因为JVM即Java虚拟机,相当于翻译官,JVM来向下关联所有操作系统,他能操作所有操作系统,向上提供统一接口,也就是JavaAPI,程序员只要面向JVM编程,将想要让操作系统做的告诉JVM,它就会去跟操作系统转达。只要面向JVM编程,就可以做到一个程序在所有平台上都能运行。Java语言和平台无关,这就是Java能够跨平台的原因
jdk:即java语言编写的程序所需的开发工具包。JDK 包含了 JRE,同时还包括 java 源码的编译器 javac、监控工具 jconsole、分析工具 jvisualvm等
jre:java程序的运行时环境,包含了 java 虚拟机,java基础类库
-
什么是垃圾收集器?释放分配给程序不再使用的对象的内存-因此被命名为“垃圾”,GC处理频繁的是堆 使得程序员不必担心内存管理问题
02.为什么要比较Exception和Error?
1.Exception和Error都是继承了Throwable类,在Java中只有Throwable类型的实例才可以被抛出(throw)或者捕获(catch)
2.Exception是基本都是程序的错误,Error是指在JVM自身,系统本身的错误
3.Exception又分为可检查(checked)异常和不检查(unchecked)异常, 可检查异常在源代码里必须显式地进行捕获处理,这是编译期检查的一部分, 不检查异常就是所谓的运行时异常,类似 NullPointerException、ArrayIndexOutOfBoundsException之类,通常是可以编码避免的逻辑错误,具体根据需要来判断是否需要捕 获,并不会在编译期强制要求。
开发注意:
1.勿将异常用于控制流。
2.禁用e.printStackTrace()打印异常 logger.error(各类参数或者对象toString + "_" + e.getMessage(), e)
03.为什么比较final、finally、 finalize?
1.final可以用来修饰类、方法、变量,分别有不同的意义,final修饰的class代表不可以继承扩展,final的变量是不可以修改的,而final的方法也是不可以重写的(override)。
2.finally则是Java保证重点代码一定要被执行的一种机制。我们可以使用try-finally或者try-catch-finally来进行类似关闭JDBC连接、保证unlock锁等动作。
3.finalize是基础类java.lang.Object的一个方法,它的设计目的是保证对象在被垃圾收集前完成特定资源的回收。finalize机制现在已经不推荐使用,并且在JDK 9开始被标记 为deprecated
04.强引用、软引用、弱引用、幻象引用到底是什么?
在Java语言中,除了基本数据类型外,其他的都是指向各类对象的对象引用;
Java中根据其生命周期的长短,将引用分为4类。
1 强引用
特点:
我们平常典型编码Object obj = new Object()中的obj就是强引用。通过关键字new创建的对象所关联的引用就是强引用。 当JVM内存空间不足,JVM宁愿抛出OutOfMemoryError运 行时错误(OOM),使程序异常终止,也不会靠随意回收具有强引用的“存活”对象来解决内存不足的问题。对于一个普通的对象,如果没有其他的引用关系,只要超过了引用的作用域或者显式 地将相应(强)引用赋值为 null,就是可以被垃圾收集的了,具体回收时机还是要看垃圾收集策略。
2 软引用
特点:
软引用通过SoftReference类实现。 软引用的生命周期比强引用短一些。只有当 JVM 认为内存不足时,才会去试图回收软引用指向的对象:即JVM 会确保在抛出 OutOfMemoryError 之前,清理软引用指向的对象。软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收器回收,Java虚拟机就会把这个软引用加入到与之关联的引用 队列中。后续,我们可以调用ReferenceQueue的poll()方法来检查是否有它所关心的对象被回收。如果队列为空,将返回一个null,否则该方法返回队列中前面的一个Reference对象。
应用场景:软引用通常用来实现内存敏感的缓存。如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。
3 弱引用
弱引用通过WeakReference类实现。 弱引用的生命周期比软引用短。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了具有弱引用的对象,不管当前内存空间足够与否,都会 回收它的内存。由于垃圾回收器是一个优先级很低的线程,因此不一定会很快回收弱引用的对象。弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾 回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中。
应用场景:弱应用同样可用于内存敏感的缓存。
4 虚引用
特点: 虚引用也叫幻象引用,通过PhantomReference类来实现。无法通过虚引用访问对象的任何属性或函数。幻象引用仅仅是提供了一种确保对象被 fnalize 以后,做某些事情的机制。如果 一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。虚引用必须和引用队列 (ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如 果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。 ReferenceQueue queue = new ReferenceQueue (); PhantomReference pr = new PhantomReference (object, queue); 程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。如果程序发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之 前采取一些程序行动。
应用场景:可用来跟踪对象被垃圾回收器回收的活动,当一个虚引用关联的对象被垃圾收集器回收之前会收到一条系统通知。
05.String、StringBufer、StringBuilder有什么区别?
- String
(1) String的创建机理 由于String在Java世界中使用过于频繁,Java为了避免在一个系统中产生大量的String对象,引入了字符串常量池。其运行机制是:创建一个字符串时,首先检查池中是否有值相同的字符串对 象,如果有则不需要创建直接从池中刚查找到的对象引用;如果没有则新建字符串对象,返回对象引用,并且将新创建的对象放入池中。但是,通过new方法创建的String对象是不检查字符串 池的,而是直接在堆区或栈区创建一个新的对象,也不会把对象放入池中。上述原则只适用于通过直接量给String对象引用赋值的情况。
举例:String str1 = "123"; //通过直接量赋值方式,放入字符串常量池 String str2 = new String(“123”);//通过new方式赋值方式,不放入字符串常量池
注意:String提供了inter()方法。调用该方法时,如果常量池中包括了一个等于此String对象的字符串(由equals方法确定),则返回池中的字符串。否则,将此String对象添加到池中,并且 返回此池中对象的引用。
- String的特性
[A] 不可变。是指String对象一旦生成,则不能再对它进行改变。不可变的主要作用在于当一个对象需要被多线程共享,并且访问频繁时,可以省略同步和锁等待的时间,从而大幅度提高系统 性能。不可变模式是一个可以提高多线程程序的性能,降低多线程程序复杂度的设计模式。
[B] 针对常量池的优化。当2个String对象拥有相同的值时,他们只引用常量池中的同一个拷贝。当同一个字符串反复出现时,这个技术可以大幅度节省内存空间。
- StringBufer/StringBuilder
StringBufer和StringBuilder都实现了AbstractStringBuilder抽象类,拥有几乎一致对外提供的调用接口;其底层在内存中的存储方式与String相同,都是以一个有序的字符序列(char类型 的数组)进行存储,不同点是StringBufer/StringBuilder对象的值是可以改变的,并且值改变以后,对象引用不会发生改变;两者对象在构造过程中,首先按照默认大小申请一个字符数组,由 于会不断加入新数据,当超过默认大小后,会创建一个更大的数组,并将原先的数组内容复制过来,再丢弃旧的数组。因此,对于较大对象的扩容会涉及大量的内存复制操作,如果能够预先评 估大小,可提升性能。
唯一需要注意的是:
StringBufer是线程安全的,但是StringBuilder是线程不安全的。 可参看Java标准类库的源代码,StringBufer类中方法定义前面都会有synchronize关键字。为 此,StringBufer的性能要远低于StringBuilder。
3 应用场景
[A]在字符串内容不经常发生变化的业务场景优先使用String类。例如:常量声明、少量的字符串拼接操作等。如果有大量的字符串内容拼接,避免使用String与String之间的“+”操作,因为这 样会产生大量无用的中间对象,耗费空间且执行效率低下(新建对象、回收对象花费大量时间)。
[B]在频繁进行字符串的运算(如拼接、替换、删除等),并且运行在多线程环境下,建议使用StringBufer,例如XML解析、HTTP参数解析与封装。
[C]在频繁进行字符串的运算(如拼接、替换、删除等),并且运行在单线程环境下,建议使用StringBuilder,例如SQL语句拼装、JSON封装等。
06.动态代理是基于什么原理?
- 反射机制
利用Java反射机制我们可以加载一个运行时才得知名称的class,获悉其构造方法,并生成其对象实体,能对其felds设值并唤起其methods。获取类声明的属性和方法,调用方法或者构造对象
应用场景: 反射技术常用在各类通用框架开发中。因为为了保证框架的通用性,需要根据配置文件加载不同的对象或类,并调用不同的方法,这个时候就会用到反射——运行时动态加载需要加载的对象。
特点: 由于反射会额外消耗一定的系统资源,因此如果不需要动态地创建一个对象,那么就不需要用反射。另外,反射调用方法时可以忽略权限检查,因此可能会破坏封装性而导致安全问题。
-
动态代理 为其他对象提供一种代理以控制对这个对象的访问。在某些情况下,一个对象不适合或者不能直接引用另一个对象,而代理对象可以在两者之间起到中介的作用(可类比房屋中介,房东委托中 介销售房屋、签订合同等)。 所谓动态代理,就是实现阶段不用关心代理谁,而是在运行阶段才指定代理哪个一个对象(不确定性)。如果是自己写代理类的方式就是静态代理(确定性)。
实现方式:
实现动态代理的方式很多,比如 JDK 自身提供的动态代理,就是主要利用了反射机制 。还有其他的实现方式, 比如利用字节码操作机制,类似 ASM、CGLIB(基于 ASM)、Javassist 等。
举例,常可采用的JDK提供的动态代理接口InvocationHandler来实现动态代理类。其中invoke方法是该接口定义必须实现的,它完成对真实方法的调用。通过InvocationHandler接口,所有 方法都由该Handler来进行处理,即所有被代理的方法都由InvocationHandler接管实际的处理任务。此外,我们常可以在invoke方法实现中增加自定义的逻辑实现,实现对被代理类的业务逻 辑无侵入。
- 运用场景 日志、用户鉴权、全局性异常处理、性能监控,甚至事务处理,AOP
07.int和Integer有什么区别?
int是我们常说的整形数字,是Java的8个原始数据类型(Primitive Types,boolean、byte 、short、char、int、foat、double、long)之一。Java语言虽然号称一切都是对象, 但原始数据类型是例外。
Integer是int对应的包装类,它有一个int类型的字段存储数据,并且提供了基本操作,比如数学运算、int和字符串之间转换等。
在Java 5中,引入了自动装箱和自动拆箱功能 (boxing/unboxing),Java可以根据上下文,自动进行转换,极大地简化了相关编程。 关于Integer的值缓存,这涉及Java 5中另一个改进。构建Integer对象的传统方式是直接调用构造器,直接new一个对象。但是根据实践,我们发现大部分数据操作都是集中在有 限的、较小的数值范围,因而,在Java 5中新增了静态工厂方法valueOf,在调用它的时候会利用一个缓存机制,带来了明显的性能改进。
按照Javadoc,这个值默认缓存是-128到127之间。
08.Vector、ArrayList、LinkedList有何区别?
这三者都是实现集合框架中的List,也就是所谓的有序集合,因此具体功能也比较近似,比如都提供按照位置进行定位、添加或者删除的操作,都提供迭代器以遍历其内容等。但因 为具体的设计区别,在行为、性能、线程安全等方面,表现又有很大不同。
Vector是Java早期提供的线程安全的动态数组,如果不需要线程安全,并不建议选择,毕竟同步是有额外开销的。Vector内部是使用对象数组来保存数据,可以根据需要自动的增加 容量,当数组已满时,会创建新的数组,并拷贝原有数组数据。
ArrayList是应用更加广泛的动态数组实现,它本身不是线程安全的,所以性能要好很多。与Vector近似,ArrayList也是可以根据需要调整容量,不过两者的调整逻辑有所区 别,Vector在扩容时会提高1倍,而ArrayList则是增加50%。 LinkedList顾名思义是Java提供的双向链表,所以它不需要像上面两种那样调整容量,它也不是线程安全的。
- List,也就是我们前面介绍最多的有序集合,它提供了方便的访问、插入、删除等操作。
- Set,Set是不允许重复元素的,这是和List最明显的区别,也就是不存在两个对象equals返回true。我们在日常开发中有很多需要保证元素唯一性的场合。
- TreeSet支持自然顺序访问,但是添加、删除、包含等操作要相对低效(log(n)时间)。
- HashSet则是利用哈希算法,理想情况下,如果哈希散列正常,可以提供常数时间的添加、删除、包含等操作,但是它不保证有序。
- LinkedHashSet,内部构建了一个记录插入顺序的双向链表,因此提供了按照插入顺序遍历的能力,与此同时,也保证了常数时间的添加、删除、包含等操作,这些操作性能略低于HashSet,因为需要维护链表的开销。
Vector、ArrayList、LinkedList均为线型的数据结构,但是从实现方式与应用场景中又存在差别。
1 底层实现方式
ArrayList内部用数组来实现;LinkedList内部采用双向链表实现;Vector内部用数组实现。
2 读写机制
ArrayList在执行插入元素是超过当前数组预定义的最大值时,数组需要扩容,扩容过程需要调用底层System.arraycopy()方法进行大量的数组复制操作;在删除元素时并不会减少数组的容量 (如果需要缩小数组容量,可以调用trimToSize()方法);在查找元素时要遍历数组,对于非null的元素采取equals的方式寻找。
LinkedList在插入元素时,须创建一个新的Entry对象,并更新相应元素的前后元素的引用;在查找元素时,需遍历链表;在删除元素时,要遍历链表,找到要删除的元素,然后从链表上将此元 素删除即可。
Vector与ArrayList仅在插入元素时容量扩充机制不一致。对于Vector,默认创建一个大小为10的Object数组,并将capacityIncrement设置为0;当插入元素数组大小不够时,如 果capacityIncrement大于0,则将Object数组的大小扩大为现有size+capacityIncrement;如果capacityIncrement<=0,则将Object数组的大小扩大为现有大小的2倍。
3 读写效率
ArrayList对元素的增加和删除都会引起数组的内存分配空间动态发生变化。因此,对其进行插入和删除速度较慢,但检索速度很快。 LinkedList由于基于链表方式存放数据,增加和删除元素的速度较快,但是检索速度较慢。
4 线程安全性
ArrayList、LinkedList为非线程安全;Vector是基于synchronized实现的线程安全的ArrayList。 需要注意的是:单线程应尽量使用ArrayList,Vector因为同步会有性能损耗;即使在多线程环境下,我们可以利用Collections这个类中为我们提供的synchronizedList(List list)方法返回一个 线程安全的同步列表对象。
09.Hashtable、HashMap、TreeMap有什么不同?
Hashtable、HashMap、TreeMap都是最常见的一些Map实现,是以键值对的形式存储和操作数据的容器类型。 Hashtable是早期Java类库提供的一个哈希表实现,本身是同步的,不支持null键和值,由于同步导致的性能开销,所以已经很少被推荐使用。
HashMap是应用更加广泛的哈希表实现,行为上大致上与HashTable一致,主要区别在于HashMap不是同步的,支持null键和值等。通常情况下,HashMap进行put或者get操 作,可以达到常数时间的性能,所以它是绝大部分利用键值对存取场景的首选,比如,实现一个用户ID和用户信息对应的运行时存储结构。
TreeMap则是基于红黑树的一种提供顺序访问的Map,和HashMap不同,它的get、put、remove之类操作都是O(log(n))的时间复杂度,具体顺序可以由指定 的Comparator来决定,或者根据键的自然顺序来判断。
LinkedHashMap和TreeMap都可以保证某种顺序,但二者还是非常不同的。
LinkedHashMap通常提供的是遍历顺序符合插入顺序,它的实现是通过为条目(键值对)维护一个双向链表。
对于TreeMap,它的整体顺序是由键的顺序关系决定的,通过Comparator或Comparable(自然顺序)来决定
HashMap源码分析
HashMap内部实现基本点分析。
是数组(Node[] table)和链表结合组成的复合结构,数组被分为一个个桶(bucket),通过哈希值决定了键值对在这个 数组的寻址;哈希值相同的键值对,则以链表形式存储,你可以参考下面的示意图。这里需要注意的是,如果链表大小超过阈值(TREEIFY_THRESHOLD, 8),图中的链表就会被 改造为树形结构。
容量(capcity)和负载系数(load factor)
为什么我们需要在乎容量和负载因子呢?
这是因为容量和负载系数决定了可用的桶的数量,空桶太多会浪费空间,如果使用的太满则会严重影响操作的性能。极端情况下,假设只有一个桶,那么它就退化成了链表,完全不 能提供所谓常数时间存的性能。 既然容量和负载因子这么重要,我们在实践中应该如何选择呢? 如果能够知道HashMap要存取的键值对数量,可以考虑预先设置合适的容量大小。具体数值我们可以根据扩容发生的条件来做简单预估,根据前面的代码分析,我们知道它需要符合
计算条件:负载因子 * 容量 > 元素数量
所以,预先设置的容量需要满足,大于“预估元素数量/负载因子”,同时它是2的幂数,结论已经非常清晰了。 而对于负载因子,我建议: 如果没有特别需求,不要轻易进行更改,因为JDK自身的默认负载因子是非常符合通用场景的需求的。 如果确实需要调整,(HashMap默认容量为16)建议不要设置超过0.75的数值,因为会显著增加冲突,降低HashMap的性能。 如果使用太小的负载因子,按照上面的公式,预设容量值也进行调整,否则可能会导致更加频繁的扩容,增加无谓的开销,本身访问性能也会受影响
一个对象哈希冲突,都被放置到同一个桶里,则会形成一个链表
解决哈希冲突的常用方法有:
开放定址法
基本思想是:当关键字key的哈希地址p=H(key)出现冲突时,以p为基础,产生另一个哈希地址p1,如果p1仍然冲突,再以p为基础,产生另一个哈希地址p2,…,直到找出一个不冲突的哈 希地址pi ,将相应元素存入其中。 再哈希法 这种方法是同时构造多个不同的哈希函数: Hi=RH1(key) i=1,2,…,k 当哈希地址Hi=RH1(key)发生冲突时,再计算Hi=RH2(key)……,直到冲突不再产生。这种方法不易产生聚集,但增加了计算时间。
链地址法
这种方法的基本思想是将所有哈希地址为i的元素构成一个称为同义词链的单链表,并将单链表的头指针存在哈希表的第i个单元中,因而查找、插入和删除主要在同义词链中进行。链地址法适用 于经常进行插入和删除的情况。 极客时间
建立公共溢出区
这种方法的基本思想是:将哈希表分为基本表和溢出表两部分,凡是和基本表发生冲突的元素,一律填入溢出表。
树化
在JDK1.8中,HashMap做了一些改变:
JDK1.7中,发生哈希碰撞时,将键值对添加到链表头部,JDK1.8是将键值对添加到链表尾部。 JDK1.8中,如果链表的长度超过8,将会将链表转化为红黑树。 容量的初始化:JDK1.7的HashMap在构造时会对容量进行初始化,而JDK1.8是在首次向HashMap总中执行put操作时,对容量进行初始化,也就是说,JDK1.8的HashMap使用了懒汉模式(在使用时才初始化),避免了初始化后却不用的资源浪费。 那为什么要进行树化的改造呢?
主要是为了避免哈希碰撞拒绝服务攻击。
从性能角度来看:解决哈希冲突时使用链表,插入和删除的效率很高,只需O(1)的时间复杂度,但对于查询而言,则需要O(n)的时间负责度。但红黑树的插入,删除,查询的最差时间复杂度为O(logn)。恶意代码可以利用大量数据与服务器交互,比如String的hashcode函数的强度很弱,有人可以很容易的构造出大量hashcode相同的String对象。如果向服务器一次提交数万个hashcode相同的字符串,服务器的查询时间过长,让服务器的CPU被大量占用,当有其他更多的请求时服务器会拒绝服务。而使用红黑树可以将查询时间降低到一定的数量级,可以有效避免哈希碰撞拒绝服务攻击。
10 如何保证集合是线程安全的? ConcurrentHashMap如何实现高效地线程安全
用并发包提供的线程安全容器类,它提供了: 各种并发容器,比如ConcurrentHashMap、CopyOnWriteArrayList。 各种线程安全队列(Queue/Deque),如ArrayBlockingQueue、SynchronousQueue。
如何保证集合是线程安全的
体保证线程安全的方式,包括有从简单的synchronize方式,到基于更加精细化的,比如基于分离锁实现的ConcurrentHashMap等并发实现等。CAS+synchronize
为什么需要ConcurrentHashMap?
Hashtable本身比较低效,因为它的实现基本就是将put、get、size等各种方法加上“synchronized”。简单来说,这就导致了所有并发操作都要竞争同一把锁,一个线程在进行同 步操作时,其他线程只能等待,大大降低了并发操作的效率。 前面已经提过HashMap不是线程安全的,并发情况会导致类似CPU占用100%等一些问题
1.7 ConcurrentHashMap,其实现是基于:分离锁
也就是将内部进行分段(Segment),里面则是HashEntry的数组,和HashMap类似,哈希相同的条目也是以链表形式存放。 HashEntry内部使用volatile的value字段来保证可见性,也利用了不可变对象的机制以改进利用Unsafe提供的底层能力,比如volatile access,去直接完成部分操作,以最优化 性能,毕竟Unsafe中的很多操作都是JVM intrinsic优化过的。 Segment的数量由所谓的concurrentcyLevel决定,默认是16
get操作需要保证的是可见性,
对于put操作,首先是通过二次哈希避免哈希冲突,然后以Unsafe调用方式,直接获取相应的Segment,然后进行线程安全的put操作:
所以,从上面的源码清晰的看出,在进行并发写操作时:
put加锁
通过分段加锁segment,一个hashmap里有若干个segment,每个segment里有若干个桶,桶里存放K-V形式的链表,put数据时通过key哈希得到该元素要添加到的segment,然后 对segment进行加锁,然后在哈希,计算得到给元素要添加到的桶,然后遍历桶中的链表,替换或新增节点到桶中 size 分段计算两次,两次结果相同则返回,否则对所以段加锁重新计算
1.8 ConcurrentHashMap
put CAS 加锁
1.8中不依赖与segment加锁,segment数量与桶数量一致; 首先判断容器是否为空,为空则进行初始化利用volatile的sizeCtl作为互斥手段,如果发现竞争性的初始化,就暂停在那里,等待条件恢复,否则利用CAS设置排他标志 (U.compareAndSwapInt(this, SIZECTL, sc, -1));否则重试 对key hash计算得到该key存放的桶位置,判断该桶是否为空,为空则利用CAS设置新节点 否则使用synchronize加锁,遍历桶中数据,替换或新增加点到桶中 最后判断是否需要转为红黑树,转换之前判断是否需要扩容
11 Java提供了哪些IO方式? NIO如何实现多路复用?
IO
Java IO方式有很多种,基于不同的IO抽象模型和交互方式,可以进行简单区分。
首先,传统的java.io包,它基于流模型实现,提供了我们最熟知的一些IO功能,比如File抽象、输入输出流等。交互方式是同步、阻塞的方式,也就是说,在读取输入流或者写入输 出流时,在读、写动作完成之前,线程会一直阻塞在那里,它们之间的调用是可靠的线性顺序
java.io包的好处是代码比较简单、直观,缺点则是IO效率和扩展性存在局限性,容易成为应用性能的瓶颈。
人们也把java.net下面提供的部分网络API,比如Socket、ServerSocket、HttpURLConnection也归类到同步阻塞IO类库,因为网络通信同样是IO行为。
NIO
在Java 1.4中引入了NIO框架(java.nio包),提供了Channel、Selector、Bufer等新的抽象,可以构建多路复用的、同步非阻塞IO程序,同时提供了更接近操作系统底层 的高性能数据操作方式。
NIO2
在Java 7中,NIO有了进一步的改进,也就是NIO 2,引入了异步非阻塞IO方式,也有很多人叫它AIO(Asynchronous IO)。异步IO操作基于事件和回调机制,可以简单 理解为,应用操作直接返回,而不会阻塞在那里,当后台处理完成,操作系统会通知相应线程进行后续工作
基本概念
1.区分同步或异步(synchronous/asynchronous)。简单来说,同步是一种可靠的有序运行机制,当我们进行同步操作时,后续的任务是等待当前调用返回,才会进行下一步; 而异步则相反,其他任务不需要等待当前调用返回,通常依靠事件、回调等机制来实现任务间次序关系。
2.区分阻塞与非阻塞(blocking/non-blocking)。在进行阻塞操作时,当前线程会处于阻塞状态,无法从事其他任务,只有当条件就绪才能继续,比如ServerSocket新连接建立 完毕,或数据读取、写入操作完成;而非阻塞则是不管IO操作是否结束,直接返回,相应操作在后台继续处理。 不能一概而论认为同步或阻塞就是低效,具体还要看应用和系统特征。 对于java.io,我们都非常熟悉,我这里就从总体上进行一下总结,如果需要学习更加具体的操作,你可以通过教程等途径完成。总体上,我认为你至少需要理解:
3.IO不仅仅是对文件的操作,网络编程中,比如Socket通信,都是典型的IO操作目标。 输入流、输出流(InputStream/OutputStream)是用于读取或写入字节的,例如操作图片文件。 而Reader/Writer则是用于操作字符,增加了字符编解码等功能,适用于类似从文件中读取或者写入文本信息。本质上计算机操作的都是字节,不管是网络通信还是文件读 取,Reader/Writer相当于构建了应用逻辑和原始数据之间的桥梁。
4.BuferedOutputStream等带缓冲区的实现,可以避免频繁的磁盘读写,进而提高IO处理效率。这种设计利用了缓冲区,将批量数据进行一次操作,但在使用中千万别忘 了fush。
Java NIO概览
1.Bufer,高效的数据容器,除了布尔类型,所有原始数据类型都有相应的Bufer实现。
2.Channel,类似在Linux之类操作系统上看到的文件描述符,是NIO中被用来支持批量式IO操作的一种抽象。通过Socket获取Channel
3.Selector,是NIO实现多路复用的基础,它提供了一种高效的机制,可以检测到注册在Selector上的多个Channel中,是否有Channel处于就绪状态,进而实现了单线程对 多Channel的高效管理。
4.Chartset,提供Unicode字符串定义,NIO也提供了相应的编解码器等,例如,通过下面的方式进行字符串到ByteBufer的转换: Charset.defaultCharset().encode("Hello world!"));
由于nio实际上是同步非阻塞io,是一个线程在同步的进行事件处理,当一组事channel处理完毕以后,去检查有没有又可以处理的channel。这也就是同步+非阻塞。
12 Java有几种文件拷贝方式?哪一种最高效?
利用java.io类库,直接为源文件构建一个FileInputStream读取,然后再为目标文件构建一个FileOutputStream,完成写入工作
利用java.nio类库提供的transferTo或transferFrom方法实现
用户态空间(User Space)和内核态空间(Kernel Space),这是操作系统层面的基本概念,操作系统内核、硬件驱动等运行在内核态空间,具有相对高的特 权;而用户态空间,则是给普通应用和服务使用。
13 谈谈接口和抽象类有什么区别?
接口和抽象类是Java面向对象设计的两个基础机制。
接口是对行为的抽象,interface 它是抽象方法的集合,利用接口可以达到API定义和实现分离的目的。接口,不能实例化;不能包含任何非常量成员,任何feld都是隐含着public static final的意义;同时,没有非静态方法实现,也就是说要么是抽象方法,要么是静态方法。Java标准类库中,定义了非常多的接口,比如java.util.List。
抽象类是不能实例化的类,用abstract关键字修饰class,其目的主要是代码重用。除了不能实例化,形式上和一般的Java类并没有太大区别,可以有一个或者多个抽象方法,也可 以没有抽象方法。抽象类大多用于抽取相关Java类的共用方法实现或者是共同成员变量,然后通过继承的方式达到代码复用的目的。Java标准库中,比如collection框架,很多通用 部分就被抽取成为抽象类,例如java.util.AbstractList。
Java类实现interface使用implements关键词,继承abstract class则是使用extends关键词,我们可以参考Java标准库中的ArrayList。
进行面向对象编程,掌握基本的设计原则是必须的,我今天介绍最通用的部分,也就是所谓的S.O.L.I.D原则。 单一职责(Single Responsibility),类或者对象最好是只有单一职责,在程序设计中如果发现某个类承担着多种义务,可以考虑进行拆分。
开关原则(Open-Close, Open for extension, close for modifcation),设计要对扩展开放,对修改关闭。换句话说,程序设计应保证平滑的扩展性,尽量避免因为新增同 类功能而修改已有实现,这样可以少产出些回归(regression)问题。
里氏替换(Liskov Substitution),这是面向对象的基本要素之一,进行继承关系抽象时,凡是可以用父类或者基类的地方,都可以用子类替换。
接口分离(Interface Segregation),我们在进行类和接口设计时,如果在一个接口里定义了太多方法,其子类很可能面临两难,就是只有部分方法对它是有意义的,这就破坏 了程序的内聚性。
对于这种情况,可以通过拆分成功能单一的多个接口,将行为进行解耦。在未来维护中,如果某个接口设计有变,不会对使用其他接口的子类构成影响。 依赖反转(Dependency Inversion),实体应该依赖于抽象而不是实现。也就是说高层次模块,不应该依赖于低层次模块,而是应该基于抽象。实践这一原则是保证产品代码之 间适当耦合度的法宝。
14 谈谈你知道的设计模式?请手动实现单例模式,Spring等框架中使用了哪些模式?
创建型模式
是对对象创建过程的各种问题和解决方案的总结,包括各种工厂模式(Factory、Abstract Factory)、单例模式(Singleton)、构建器模式(Builder)、原型模 式(ProtoType)。
结构型模式
是针对软件设计结构的总结,关注于类、对象继承、组合方式的实践经验。常见的结构型模式,包括桥接模式(Bridge)、适配器模式(Adapter)、装饰者模式 (Decorator)、代理模式(Proxy)、组合模式(Composite)、外观模式(Facade)、享元模式(Flyweight)等。
行为型模式
是从类或对象之间交互、职责划分等角度总结的模式。比较常见的行为型模式有策略模式(Strategy)、解释器模式(Interpreter)、命令模式(Command)、 观察者模式(Observer)、迭代器模式(Iterator)、模板方法模式(Template Method)、访问者模式(Visitor)。
单例
private satic Singleton insance = new Singleton();
public satic Singleton getInsance() {
return insance;
}
}
单例2 懒加载
private satic Singleton insance;
private Singleton() {
}
public satic Singleton getInsance() {
if (insance == null) {
insance = new Singleton();
}
return insance;
}
}
单例 加 锁
private satic volatile Singleton singleton = null;
private Singleton() {
}
public satic Singleton getSingleton() {
if (singleton == null) { // 尽量避免重复进入同步块
synchronized(Singleton.class) { // 同步.class,意味着对同步类方法调用
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
15 synchronized和ReentrantLock有什么区别?有人说synchronized最慢,这话靠谱吗?
synchronized是Java内建的同步机制,它提供了互斥的语义和可见性,当一个线程已经获取当前锁时,其他试图获取的线程只能等待或者阻塞在那里。
ReentrantLock,通常翻译为再入锁,它的语义和synchronized基本相同。再入锁通过代码直接调用lock()方法获取,代码书写也更加灵活。与此同 时,ReentrantLock提供了很多实用的方法,能够实现很多synchronized无法做到的细节控制,比如可以控制fairness,也就是公平性,或者利用定义条件等。但是,编码中也需 要注意,必须要明确调用unlock()方法释放,不然就会一直持有该锁。
ReentrantLock是Lock的实现类,是一个互斥的同步器,在多线程高竞争条件下,
ReentrantLock比synchronized有更加优异的性能表现。
Lock使用起来比较灵活,但是必须有释放锁的配合动作
Lock必须手动获取与释放锁,而synchronized不需要手动释放和开启锁
Lock只适用于代码块锁,而synchronized可用于修饰方法、代码块等
Lock都是基于AQS来实现了。AQS和Condition各自维护了不同的队列
理解什么是线程安全。
如果状态不是共享的,或者不是可修改的,也就不存在线程安全问题
封装:通过封装,我们可以将对象内部状态隐藏、保护起来。
不可变:
线程安全需要保证几个基本特性:
原子性,简单说就是相关操作不会中途被其他线程干扰,一般通过同步机制实现。
可见性,是一个线程修改了某个共享变量,其状态能够立即被其他线程知晓,通常被解释为将线程本地状态反映到主内存上,volatile就是负责保证可见性的。
有序性,是保证线程内串行语义,避免指令重排等
synchronized、ReentrantLock等机制的基本使用与案例。
synchronized (lockObject) {
// update object state
}
lock.lock();
try {
// update object state
}
finally {
lock.unlock();
}
synchronized 底层
synchronized该关键字在编译成class文件后可以看到对应的语句是monitorEnter与monitorExit
synchronized底层原理,跟JVM指令和monitor有关。如果用到了synchronized关键字,在底层编译后的JVM指令中,会有monitorenter和monitorexit两个指令
monitorenter指令执行:
每个对象都有一个关联的monitor,一个对象实例就有一个monitor,一个类的class对象也有一个monitor。如果要对这个对象加锁,那么必须获取这个对象关联的monitor的lock锁
原理:
monitor中有个计数器,默认为0。如果一个线程要获取monitor的锁,会去判断当前计数器是否为0,如果为0,那么可以获得锁,然后对计数器加1。
ReentrantLock底层实现
AQS原理 AQS:AbstractQuenedSynchronizer抽象的队列式同步器。是除了java自带的synchronized关键字之外的锁机制
AQS的核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并将共享资源设置为锁定状态,如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。
CLH(Craig,Landin,and Hagersten)队列是一个虚拟的双向队列,虚拟的双向队列即不存在队列实例,仅存在节点之间的关联关系。 AQS是将每一条请求共享资源的线程封装成一个CLH锁队列的一个结点(Node),来实现锁的分配。
AQS就是基于CLH队列,用volatile修饰共享变量state,线程通过CAS去改变状态符,成功则获取锁成功,失败则进入等待队列,等待被唤醒。
**注意:AQS是自旋锁:**在等待唤醒的时候,经常会使用自旋(while(!cas()))的方式,不停地尝试获取锁,直到被其他线程获取成功
实现了AQS的锁有:自旋锁、互斥锁、读锁写锁、条件产量、信号量、栅栏都是AQS的衍生物
一直在研究JUC方面的。所有的Lock都是基于AQS来实现了。AQS和Condition各自维护了不同的队列,在使用lock和condition的时候,其实就是两个队列的互相移动。如果我们想自定义一个 同步器,可以实现AQS。它提供了获取共享锁和互斥锁的方式,都是基于对state操作而言的。ReentranLock这个是可重入的。其实要弄明白它为啥可重入的呢,咋实现的呢。其实它内部自定 义了同步器Sync,这个又实现了AQS,同时又实现了AOS,而后者就提供了一种互斥锁持有的方式。其实就是每次获取锁的时候,看下当前维护的那个线程和当前请求的线程是否一样,一样就 可重入了。
理解锁膨胀、降级;理解偏斜锁、自旋锁、轻量级锁、重量级锁等概念。
todo 后期完善把
16 synchronized底层如何实现?什么是锁的升级、降级?
synchronized代码块是由一对儿monitorEnter/monitorExit指令实现的,Monitor对象是同步的基本实现单元。 在Java 6之前,Monitor的实现完全是依靠操作系统内部的互斥锁,因为需要进行用户态到内核态的切换,所以同步操作是一个无差别的重量级操作。 现代的(Oracle)JDK中,JVM对此进行了大刀阔斧地改进,提供了三种不同的Monitor实现,也就是常说的三种不同的锁:偏斜锁(Biased Locking)、轻量级锁和重量级锁,大 大改进了其性能。
所谓锁的升级、降级,就是JVM优化synchronized运行的机制,当JVM检测到不同的竞争状况时,会自动切换到适合的锁实现,这种切换就是锁的升级、降级。 当没有竞争出现时,默认会使用偏斜锁。JVM会利用CAS操作(compare and swap),在对象头上的Mark Word部分设置线程ID,以表示这个对象偏向于当前线程,所以并不涉 及真正的互斥锁。这样做的假设是基于在很多应用场景中,大部分对象生命周期中最多会被一个线程锁定,使用偏斜锁可以降低无竞争开销。
如果有另外的线程试图锁定某个已经被偏斜过的对象,JVM就需要撤销(revoke)偏斜锁,并切换到轻量级锁实现。轻量级锁依赖CAS操作Mark Word来试图获取锁,如果重试成 功,就使用普通的轻量级锁;否则,进一步升级为重量级锁。 我注意到有的观点认为Java不会进行锁降级。实际上据我所知,锁降级确实是会发生的,当JVM进入安全点(SafePoint)的时候,会检查是否有闲置的Monitor,然后试图进行降 级。
自旋锁采用让当前线程不停循环体内执行实现,当循环条件被其他线程改变时,才能进入临界区。 由于自旋锁只是将当前线程不停执行循环体,不进行线程状态的改变,所以响应会更快。但当线程不停增加时,性能下降明显。
线程竞争不激烈,并且保持锁的时间段。适合使用自旋锁。 为什么会提出自旋锁,因为互斥锁,在线程的睡眠和唤醒都是复杂而昂贵的操作,需要大量的CPU指令。如果互斥仅仅被锁住是一小段时间, 用来进行线程休眠和唤醒的操作时间比睡眠时间还长,更有可能比不上不断自旋锁上轮询的时间长。
当然自旋锁被持有的时间更长,其他尝试获取自旋锁的线程会一直轮询自旋锁的状态。这将十分浪费CPU。 在单核CPU上,自旋锁是无用,因为当自旋锁尝试获取锁不成功会一直尝试,这会一直占用CPU,其他线程不可能运行, 同时由于其他线程无法运行,所以当前线程无法释放锁。
混合型互斥锁, 在多核系统上起初表现的像自旋锁一样, 如果一个线程不能获取互斥锁, 它不会马上被切换为休眠状态,在一段时间依然无法获取锁,进行睡眠状态。 混合型自旋锁,起初表现的和正常自旋锁一样,如果无法获取互斥锁,它也许会放弃该线程的执行,并允许其他线程执行。
切记,自旋锁只有在多核CPU上有效果,单核毫无效果,只是浪费时间。
17 一个线程两次调用start()方法会出现什么情况?
Java的线程是不允许启动两次的,第二次调用必然会抛出IllegalThreadStateException,这是一种运行时异常,多次调用start被认为是编程错误。
线程生命周期的不同状态,线程状态被明确定义在其公共内部枚举类型java.lang.Thread.State中,分别是:
新建(NEW),表示线程被创建出来还没真正启动的状态,可以认为它是个Java内部状态。
就绪(RUNNABLE),表示该线程已经在JVM中执行,当然由于执行需要计算资源,它可能是正在运行,也可能还在等待系统分配给它CPU片段,在就绪队列里面排队。
在其他一些分析中,会额外区分一种状态RUNNING,但是从Java API的角度,并不能表示出来。
阻塞(BLOCKED),这个状态和我们前面两讲介绍的同步非常相关,阻塞表示线程在等待Monitor lock。比如,线程试图通过synchronized去获取某个锁,但是其他线程已经 独占了,那么当前线程就会处于阻塞状态。
等待(WAITING),表示正在等待其他线程采取某些操作。一个常见的场景是类似生产者消费者模式,发现任务条件尚未满足,就让当前消费者线程等待(wait),另外的生产 者线程去准备任务数据,然后通过类似notify等动作,通知消费线程可以继续工作了。Thread.join()也会令线程进入等待状态。
计时等待(TIMED_WAIT),其进入条件和等待状态类似,但是调用的是存在超时条件的方法,比如wait或join等方法的指定超时版本,如下面示例: public fnal native void wait(long timeout) throws InterruptedException;
终止(TERMINATED),不管是意外退出还是正常执行结束,线程已经完成使命,终止运行,也有人把这个状态叫作死亡。
18 什么情况下Java程序会产生死锁?如何定位、修复?
死锁是一种特定的程序状态,在实体之间,由于循环依赖导致彼此一直处于等待之中,没有任何个体可以继续前进。死锁不仅仅是在线程之间会发生,存在资源独占的进程之间同样 也可能出现死锁。通常来说,我们大多是聚焦在多线程场景中的死锁,指两个或多个线程之间,由于互相持有对方需要的锁,而永久处于阻塞的状态。
定位死锁最常见的方式就是利用jstack等工具获取线程栈,然后定位互相之间的依赖关系,进而找到死锁。如果是比较明显的死锁,往往jstack等就能直接定位,类似JConsole甚至 可以在图形界面进行有限的死锁检测。
如果程序运行时发生了死锁,绝大多数情况下都是无法在线解决的,只能重启、修正程序本身问题。所以,代码开发阶段互相审查,或者利用工具进行预防性排查,往往也是很重要 的
如何在编程中尽量预防死锁呢?
基本上死锁的发生是因为:
互斥条件,类似Java中Monitor都是独占的,要么是我用,要么是你用。
互斥条件是长期持有的,在使用结束之前,自己不会释放,也不能被其他线程抢占。 循环依赖关系,两个或者多个个体之间出现了锁的链条环。
解决方法
第一种方法
如果可能的话,尽量避免使用多个锁,并且只有需要时才持有锁 嵌套的synchronized或者lock非常容易出问题
第二种方法 如果必须使用多个锁,尽量设计好锁的获取顺序,
第三种方法 使用带超时的方法,为程序带来更多可控性 类似Object.wait(…)或者CountDownLatch.await(…),都支持所谓的timed_wait,我们完全可以就不假定该锁一定会获得,指定超时时间,并为无法得到锁时准备退出逻辑。
有时候并不是阻塞导致的死锁,只是某个线程进入了死循环,导致其他线程一直等待,这种问题如何诊断呢?
可以通过linux下top命令查看cpu使用率较高的java进程,进而用top -Hp pid查看该java进程下cpu使用率较高的线程。再用jstack命令查看线程具体调用情况,排查问题
19 Java并发包提供了哪些并发工具类?
我们通常所说的并发包也就是java.util.concurrent及其子包,集中了Java并发的各种基础工具类,具体主要包括几个方面:
1.提供了比synchronized更加高级的各种同步结构,包括CountDownLatch、CyclicBarrier、Semaphore等,可以实现更加丰富的多线程操作,比如利用Semaphore作为资源 控制器,限制同时进行工作的线程数量。
2.各种线程安全的容器,比如最常见的ConcurrentHashMap、有序的ConcunrrentSkipListMap,或者通过类似快照机制,实现线程安全的动态数 组CopyOnWriteArrayList等。
3.各种并发队列实现,如各种BlockedQueue实现,比较典型的ArrayBlockingQueue、 SynchorousQueue或针对特定场景的PriorityBlockingQueue等。
4.强大的Executor框架,可以创建各种不同类型的线程池,调度任务运行等,绝大部分情况下,不再需要自己从头实现线程池和任务调度器。
20 并发包中的ConcurrentLinkedQueue和LinkedBlockingQueue有什么区别?
关于问题中它们的区别:
Concurrent类型基于lock-free,在常见的多线程访问场景,一般可以提供较高吞吐量。 而LinkedBlockingQueue内部则是基于锁,并提供了BlockingQueue的等待性方法。
java.util.concurrent包提供的容器(Queue、List、Set)、Map,从命名上可以大概区分为Concurrent、CopyOnWrite和Blocking*等三类,同样是线 程安全容器,可以简单认为:
Concurrent类型没有类似CopyOnWrite之类容器相对较重的修改开销。 但是,凡事都是有代价的,Concurrent往往提供了较低的遍历一致性。你可以这样理解所谓的弱一致性,例如,当利用迭代器遍历时,如果容器发生修改,迭代器仍然可以继续 进行遍历。
与弱一致性对应的,就是我介绍过的同步容器常见的行为“fast-fail”,也就是检测到容器在遍历过程中发生了修改,则抛出ConcurrentModifcationException,不再继续遍历。 弱一致性的另外一个体现是,size等操作准确性是有限的,未必是100%准确。 与此同时,读取的性能具有一定的不确定性。
21 Java并发类库提供的线程池有哪几种? 分别有什么特点?
Executors目前提供了5种不同的线程池创建配置:
newCachedThreadPool(),它是一种用来处理大量短时间工作任务的线程池,具有几个鲜明特点:它会试图缓存线程并重用,当无缓存线程可用时,就会创建新的工作线程;如 果线程闲置的时间超过60秒,则被终止并移出缓存;长时间闲置时,这种线程池,不会消耗什么资源。其内部使用SynchronousQueue作为工作队列。
newFixedThreadPool(int nThreads),重用指定数目(nThreads)的线程,其背后使用的是无界的工作队列,任何时候最多有nThreads个工作线程是活动的。这意味着,如 果任务数量超过了活动队列数目,将在工作队列中等待空闲线程出现;如果有工作线程退出,将会有新的工作线程被创建,以补足指定的数目nThreads。
newSingleThreadExecutor(),它的特点在于工作线程数目被限制为1,操作一个无界的工作队列,所以它保证了所有任务的都是被顺序执行,最多会有一个任务处于活动状 态,并且不允许使用者改动线程池实例,因此可以避免其改变线程数目
newSingleThreadScheduledExecutor()和newScheduledThreadPool(int corePoolSize),创建的是个ScheduledExecutorService,可以进行定时或周期性的工作调度, 区别在于单一工作线程还是多个工作线程。
newWorkStealingPool(int parallelism),这是一个经常被人忽略的线程池,Java 8才加入这个创建方法,其内部会构建ForkJoinPool,利用Work-Stealing算法,并行地处 理任务,不保证处理顺序。
22 AtomicInteger( [əˈtɑːmɪk] )底层实现原理是什么?如何在自己的产品代码中应用CAS操作?
AtomicIntger是对int类型的一个封装,提供原子性的访问和更新操作,其原子性操作的实现是基于CAS(compare-and-swap)技术。
所谓CAS,表征的是一些列操作的集合,获取当前数值,进行一些运算,利用CAS指令试图进行更新。如果当前数值未变,代表没有其他线程进行并发修改,则成功更新。否则,可 能出现不同的选择,要么进行重试,要么就返回一个成功或者失败的结果
在什么场景下,可以采用CAS技术,调用Unsafe毕竟不是大多数场景的最好选择,有没有更加推荐的方式呢?毕竟我们掌握一个技术,cool不是目的,更不是为了应付面试,我 们还是希望能在实际产品中有价值。
目前Java提供了两种公共API,可以实现这种CAS操作,比如使 用java.util.concurrent.atomic.AtomicLongFieldUpdater
Atomic包提供了最常用的原子性数据类型,甚至是引用、数组等相关原子类型和更新操作工具,是很多线程安全程序的首选。
可以并且推荐使用Variable Handle API去实现,其提供了精细粒度的公共底层API。
著名的ABA问题,这是通常只在lock-free算法下暴露的问题。我前面说过CAS是在更新时比较前值,如果对方只是恰好相同,例如期间发生了 A -> B -> A的更新, 仅仅判断数值是A,可能导致不合理的修改操作。针对这种情况,Java提供了AtomicStampedReference工具类,通过为引用建立类似版本号(stamp)的方式,来保证CAS的正 确性
23 请介绍类加载过程,什么是双亲委派模型?
Java的类加载过程分为三个主要步骤:加载、链接、初始化
加载
首先是加载阶段(Loading),它是Java将字节码数据从不同的数据源读取到JVM中,并映射为JVM认可的数据结构(Class对象),这里的数据源可能是各种各样的形态,如jar文 件、class文件,甚至是网络数据源等;如果输入数据不是ClassFile的结构,则会抛出ClassFormatError。
加载阶段是用户参与的阶段,我们可以自定义类加载器,去实现自己的类加载过程
链接
这是核心的步骤,简单说是把原始的类定义信息平滑地转化入JVM运行的过程中。这里可进一步细分为三个步骤:
验证(Verifcation),这是虚拟机安全的重要保障,JVM需要核验字节信息是符合Java虚拟机规范的,否则就被认为是VerifyError,这样就防止了恶意信息或者不合规的信息危 害JVM的运行,验证阶段有可能触发更多class的加载。
准备(Preparation),创建类或接口中的静态变量,并初始化静态变量的初始值。但这里的“初始化”和下面的显式初始化阶段是有区别的,侧重点在于分配所需要的内存空间, 不会去执行更进一步的JVM指令。
解析(Resolution),在这一步会将常量池中的符号引用(symbolic reference)替换为直接引用。在Java虚拟机规范中,详细介绍了类、接口、方法和字段等各个方面的解 析。
初始化
,这一步真正去执行类初始化的代码逻辑,包括静态字段赋值的动作,以及执行类定义中的静态初始化块内的逻辑,编译器在编译阶段就会把这 部分逻辑整理好,父类型的初始化逻辑优先于当前类型的逻辑
双亲委派模型
简单说就是当类加载器(Class-Loader)试图加载某个类型的时候,除非父加载器找不到相应类型,否则尽量将这个任务代理给当前加载器的父加载器去 做。使用委派模型的目的是避免重复加载Java类型
24 有哪些方法可以在运行时动态生成一个Java类?
Java类来源分析,通常的开发过程是,开发者编写Java代码,调用javac编译成class文件,然后通过类加载机制载入JVM,就成为应用运行时可以使用的Java类 了。
思路,直接生成对应的字节码,然后交给类加载器去加载
ASM、Javassist、cglib 这些 可以生成字节码文件
25 谈谈JVM内存区域的划分,哪些区域可能发生OutOfMemoryError?
程序计数器(PC,Program Counter Register)。在JVM规范中,每个线程都有它自己的程序计数器,并且任何时间一个线程都只有一个方法在执行,也就是所谓的当前方 法。程序计数器会存储当前线程正在执行的Java方法的JVM指令地址;或者,如果是在执行本地方法,则是未指定值(undefned)
Java虚拟机栈(Java Virtual Machine Stack),早期也叫Java栈。每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次 的Java方法调用。
堆(Heap),它是Java内存管理的核心区域,用来放置Java对象实例,几乎所有创建的Java对象实例都是被直接分配在堆上。堆被所有的线程共享,在虚拟机启动时,我们 指定的“Xmx”之类参数就是用来指定最大堆空间等指标
理所当然,堆也是垃圾收集器重点照顾的区域,所以堆内空间还会被不同的垃圾收集器进行进一步的细分,最有名的就是新生代、老年代的划分。
方法区(Method Area) 这也是所有线程共享的一块内存区域,用于存储所谓的元(Meta)数据,例如类结构信息,以及对应的运行时常量池、字段、方法代码等。 由于早期的Hotspot JVM实现,很多人习惯于将方法区称为永久代(Permanent Generation)。Oracle JDK 8中将永久代移除,同时增加了元数据区(Metaspace)
运行时常量池(Run-Time Constant Pool),这是方法区的一部分。如果仔细分析过反编译的类文件结构,你能看到版本号、字段、方法、超类、接口等各种信息,还有一 项信息就是常量池。Java的常量池可以存放各种常量信息,不管是编译期生成的各种字面量,还是需要在运行时决定的符号引用,所以它比一般语言的符号表存储的信息更加宽泛。
本地方法栈(Native Method Stack)。它和Java虚拟机栈是非常相似的,支持对本地方法的调用,也是每个线程都会创建一个。在Oracle Hotspot JVM中,本地方法栈 和Java虚拟机栈是在同一块儿区域,这完全取决于技术实现的决定,并未在规范中强制。
内存主要包含4块,即heap(堆内存)、stack(栈内存)、data segment(静态变量或是常量存放区)、codesegment(方法区).
堆内存中存放的是new出的对象,new出的对象只包含成员变量。
栈内存中:存放的是局部成员变量。对于基本的数据类型存放的是基本变量的值,而对于对象变量,存放的是堆内存的地址。
静态、常量区:存放的是静态变量(类变量)或是常量。
方法区:存放的是对象的方法。因此即使new出多个对象也是只是存在一个方法。
26 如何监控和诊断JVM堆内和堆外内存使用?
接连接到Java进程,然后就可以在图形化界面里掌握内存使用情况。(JConsole)
命令行工具进行运行时查询,如jstat和jmap等工具都提供了一些选项,可以查看堆、方法区等使用数据。
使用的是Tomcat、Weblogic等Java EE服务器,这些服务器同样提供了内存管理相关的功能
堆内部是什么结构
1.新生代
新生代是大部分对象创建和销毁的区域,在通常的Java应用中,绝大部分对象生命周期都是很短暂的。其内部又分为Eden区域,作为对象初始分配的区域;两个Survivor,有时候 也叫from、to区域,被用来放置从Minor GC中保留下来的对象。
JVM会随意选取一个Survivor区域作为“to”,然后会在GC过程中进行区域间拷贝,也就是将Eden中存活下来的对象和from区域的对象,拷贝到这个“to”区域。这种设计主要是为 了防止内存的碎片化,并进一步清理无用对象。
从内存模型而不是垃圾收集的角度,对Eden区域继续进行划分,Hotspot JVM还有一个概念叫做Thread Local Allocation Bufer(TLAB),据我所知所有OpenJDK衍生出来 的JVM都提供了TLAB的设计。
2.老年代
放置长生命周期的对象,通常都是从Survivor区域拷贝过来的对象。当然,也有特殊情况,我们知道普通的对象会被分配在TLAB上;如果对象较大,JVM会试图直接分配在Eden其 他位置上;如果对象太大,完全无法在新生代找到足够长的连续空闲空间,JVM就会直接分配到老年代。
3.永久代
这部分就是早期Hotspot JVM的方法区实现方式了,储存Java类元数据、常量池、Intern字符串缓存,在JDK 8之后就不存在永久代这块儿了。
1、大部分对象创建都是在Eden的,除了个别大对象外。
2、Minor GC开始前,to-survivor是空的,from-survivor是由对象的。
3、Minor GC后,Eden的存活对象都copy到to-survivor中,from-survivor的存活对象也复制to-survivor中。其中所有对象的年龄+1
4、from-survivor清空,成为新的to-survivor,带有对象的to-survivor变成新的from-survivor。重复回到步骤2
27 Java常见的垃圾收集器有哪些?
Serial GC,它是最古老的垃圾收集器,“Serial”体现在其收集工作是单线程的,并且在进行垃圾收集过程中,会进入臭名昭著的“Stop-The-World”状态。当然,其单线程设计也 意味着精简的GC实现,无需维护复杂的数据结构,初始化也简单,所以一直是Client模式下JVM的默认选项。 从年代的角度,通常将其老年代实现单独称作Serial Old,它采用了标记-整理(Mark-Compact)算法,区别于新生代的复制算法。
Serial GC的对应JVM参数是: -XX:+UseSerialGC ParNew GC,很明显是个新生代GC实现,它实际是Serial GC的多线程版本,最常见的应用场景是配合老年代的CMS GC工作,下面是对应参数 -XX:+UseConcMarkSweepGC -XX:+UseParNewGC
CMS(Concurrent Mark Sweep) GC,基于标记-清除(Mark-Sweep)算法,设计目标是尽量减少停顿时间,这一点对于Web等反应时间敏感的应用非常重要,一直到今 天,仍然有很多系统使用CMS GC。但是,CMS采用的标记-清除算法,存在着内存碎片化问题,所以难以避免在长时间运行等情况下发生full GC,导致恶劣的停顿。另外,既然 强调了并发(Concurrent),CMS会占用更多CPU资源,并和用户线程争抢。
Parrallel GC,在早期JDK 8等版本中,它是server模式JVM的默认GC选择,也被称作是吞吐量优先的GC。它的算法和Serial GC比较相似,尽管实现要复杂的多,其特点是新 生代和老年代GC都是并行进行的,在常见的服务器环境中更加高效。 开启选项是: -XX:+UseParallelGC 另外,Parallel GC引入了开发者友好的配置项,我们可以直接设置暂停时间或吞吐量等目标,JVM会自动进行适应性调整,例如下面参数: -XX:MaxGCPauseMillis=value -XX:GCTimeRatio=N // GC时间和用户时间比例 = 1 / (N+1)
G1 GC这是一种兼顾吞吐量和停顿时间的GC实现,是Oracle JDK 9以后的默认GC选项。G1可以直观的设定停顿时间的目标,相比于CMS GC,G1未必能做到CMS在最好情况 下的延时停顿,但是最差情况要好很多。 G1 GC仍然存在着年代的概念,但是其内存结构并不是简单的条带式划分,而是类似棋盘的一个个region。Region之间是复制算法,但整体上实际可看作是标记-整理(MarkCompact)算法,可以有效地避免内存碎片,尤其是当Java堆非常大的时候,G1的优势更加明显。 G1吞吐量和停顿表现都非常不错,并且仍然在不断地完善,与此同时CMS已经在JDK 9中被标记为废弃(deprecated),所以G1 GC值得你深入掌握。
垃圾收集的原理和基础概念
第一,自动垃圾收集的前提是清楚哪些内存可以被释放。这一点可以结合我前面对Java类加载和内存结构的分析,来思考一下。 主要就是两个方面,最主要部分就是对象实例,都是存储在堆上的;还有就是方法区中的元数据等信息,例如类型不再使用,卸载该Java类似乎是很合理的。 对于对象实例收集,主要是两种基本算法,引用计数和可达性分析。
引用计数算法,顾名思义,就是为对象添加一个引用计数,用于记录对象被引用的情况,如果计数为0,即表示对象可回收。这是很多语言的资源回收选择,例如因人工智能而更 加火热的Python,它更是同时支持引用计数和垃圾收集机制。具体哪种最优是要看场景的,业界有大规模实践中仅保留引用计数机制,以提高吞吐量的尝试。
Java并没有选择引用计数,是因为其存在一个基本的难题,也就是很难处理循环引用关系。 另外就是Java选择的可达性分析,Java的各种引用关系,在某种程度上,将可达性问题还进一步复杂化,具体请参考专栏第4讲,这种类型的垃圾收集通常叫作追踪性垃圾收集 (Tracing Garbage Collection)。其原理简单来说,就是将对象及其引用关系看作一个图,选定活动的对象作为 GC Roots,然后跟踪引用链条,如果一个对象和GC Roots之 间不可达,也就是不存在引用链条,那么即可认为是可回收对象。JVM会把虚拟机栈和本地方法栈中正在引用的对象、静态属性引用的对象和常量,作为GC Roots。
方法区无用元数据的回收比较复杂,我简单梳理一下。还记得我对类加载器的分类吧,一般来说初始化类加载器加载的类型是不会进行类卸载(unload)的;而普通的类型的卸载, 往往是要求相应自定义类加载器本身被回收,所以大量使用动态类型的场合,需要防止元数据区(或者早期的永久代)不会OOM。在8u40以后的JDK中,下面参数已经是默认的: -XX:+ClassUnloadingWithConcurrentMark
第二,常见的垃圾收集算法,我认为总体上有个了解,理解相应的原理和优缺点,就已经足够了,其主要分为三类:
复制(Copying)算法,我前面讲到的新生代GC,基本都是基于复制算法,过程就如专栏上一讲所介绍的,将活着的对象复制到to区域,拷贝过程中将对象顺序放置,就可以避 免内存碎片化。 这么做的代价是,既然要进行复制,既要提前预留内存空间,有一定的浪费;另外,对于G1这种分拆成为大量region的GC,复制而不是移动,意味着GC需要维护region之间对 象引用关系,这个开销也不小,不管是内存占用或者时间开销。
标记-清除(Mark-Sweep)算法,首先进行标记工作,标识出所有要回收的对象,然后进行清除。这么做除了标记、清除过程效率有限,另外就是不可避免的出现碎片化问题, 这就导致其不适合特别大的堆;否则,一旦出现Full GC,暂停时间可能根本无法接受。
标记-整理(Mark-Compact),类似于标记-清除,但为避免内存碎片化,它会在清理过程中将对象移动,以确保移动后的对象占用连续的内存空间。 注意,这些只是基本的算法思路,实际GC实现过程要复杂的多,目前还在发展中的前沿GC都是复合算法,并且并行和并发兼备。
谈谈你的GC调优思路
谈到调优,这一定是针对特定场景、特定目的的事情, 对于GC调优来说,首先就需要清楚调优的目标是什么?从性能的角度看,通常关注三个方面,内存占用(footprint)、延时 (latency)和吞吐量(throughput),大多数情况下调优会侧重于其中一个或者两个方面的目标,很少有情况可以兼顾三个不同的角度。当然,除了上面通常的三个方面,也可能 需要考虑其他GC相关的场景,例如,OOM也可能与不合理的GC相关参数有关;或者,应用启动速度方面的需求,GC也会是个考虑的方面。
基本的调优思路可以总结为:
理解应用需求和问题,确定调优目标。假设,我们开发了一个应用服务,但发现偶尔会出现性能抖动,出现较长的服务停顿。评估用户可接受的响应时间和业务量,将目标简化 为,希望GC暂停尽量控制在200ms以内,并且保证一定标准的吞吐量。
掌握JVM和GC的状态,定位具体的问题,确定真的有GC调优的必要。具体有很多方法,比如,通过jstat等工具查看GC等相关状态,可以开启GC日志,或者是利用操作系统提供 的诊断工具等。例如,通过追踪GC日志,就可以查找是不是GC在特定时间发生了长时间的暂停,进而导致了应用响应不及时。
这里需要思考,选择的GC类型是否符合我们的应用特征,如果是,具体问题表现在哪里,是Minor GC过长,还是Mixed GC等出现异常停顿情况;如果不是,考虑切换到什么类 型,如CMS和G1都是更侧重于低延迟的GC选项。
通过分析确定具体调整的参数或者软硬件配置。
验证是否达到调优目标,如果达到目标,即可以考虑结束调优;否则,重复完成分析、调整、验证这个过程。
28 Java内存模型中的happen-before是什么
Happen-before关系,是Java内存模型中保证多线程操作可见性的机制,也是对早期语言规范中含糊的可见性概念的一个精确定义。
它的具体表现形式,包括但远不止是我们直觉中的synchronized、volatile、lock操作顺序等方面,例如:
线程内执行的每个操作,都保证happen-before后面的操作,这就保证了基本的程序顺序规则,这是开发者在书写程序时的基本约定。
对于volatile变量,对它的写操作,保证happen-before在随后对该变量的读取操作。 对于一个锁的解锁操作,保证happen-before加锁操作。
对象构建完成,保证happen-before于fnalizer的开始动作。
甚至是类似线程内部操作的完成,保证happen-before其他Thread.join()的线程等。
这些happen-before关系是存在着传递性的,如果满足a happen-before b和b happen-before c,那么a happen-before c也成立。
前面我一直用happen-before,而不是简单说前后,是因为它不仅仅是对执行时间的保证,也包括对内存读、写操作顺序的保证。仅仅是时钟顺序上的先后,并不能保证线程交互的
为什么需要JMM,它试图解决什么问题? 什么是内存模型
为了保证并发编程中可以满足原子性、可见性及有序性。有一个重要的概念,那就是——内存模型。
为了保证共享内存的正确性(可见性、有序性、原子性),内存模型定义了共享内存系统中多线程程序读写操作行为的规范。通过这些规则来规范对内存的读写操作,从而保证指令执行的正确性。它与处理器有关、与缓存有关、与并发有关、与编译器也有关。他解决了CPU多级缓存、处理器优化、指令重排等导致的内存访问问题,保证了并发场景下的一致性、原子性和有序性。
内存模型解决并发问题主要采用两种方式:限制处理器优化和使用内存屏障。本文就不深入底层原理来展开介绍了,感兴趣的朋友可以自行学习。
什么是Java内存模型
Java程序是需要运行在Java虚拟机上面的,Java内存模型(Java Memory Model ,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范。
Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。
而JMM就作用于工作内存和主存之间数据同步过程。他规定了如何做数据同步以及什么时候做数据同步。
JAVA 这里面提到的主内存和工作内存,读者可以简单的类比成计算机内存模型中的主存和缓存的概念。特别需要注意的是,主内存和工作内存与JVM内存结构中的Java堆、栈、方法区等并不是同一个层次的内存划分,无法直接类比。《深入理解Java虚拟机》中认为,如果一定要勉强对应起来的话,从变量、主内存、工作内存的定义来看,主内存主要对应于Java堆中的对象实例数据部分。工作内存则对应于虚拟机栈中的部分区域。
所以,再来总结下,JMM是一种规范,目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。
Java内存模型的实现
了解Java多线程的朋友都知道,在Java中提供了一系列和并发处理相关的关键字,比如volatile、synchronized、final、concurren包等。其实这些就是Java内存模型封装了底层的实现后提供给程序员使用的一些关键字。
在开发多线程的代码的时候,我们可以直接使用synchronized等关键字来控制并发,从来就不需要关心底层的编译器优化、缓存一致性等问题。所以,Java内存模型,除了定义了一套规范,还提供了一系列原语,封装了底层实现后,供开发者直接使用。
本文并不准备把所有的关键字逐一介绍其用法,因为关于各个关键字的用法,网上有很多资料。读者可以自行学习。本文还有一个重点要介绍的就是,我们前面提到,并发编程要解决原子性、有序性和一致性的问题,我们就再来看下,在Java中,分别使用什么方式来保证。
原子性
在Java中,为了保证原子性,提供了两个高级的字节码指令monitorenter和monitorexit。在synchronized的实现原理文章中,介绍过,这两个字节码,在Java中对应的关键字就是synchronized。
因此,在Java中可以使用synchronized来保证方法和代码块内的操作是原子性的。
可见性
Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值的这种依赖主内存作为传递媒介的方式来实现的。
Java中的volatile关键字提供了一个功能,那就是被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次是用之前都从主内存刷新。因此,可以使用volatile来保证多线程操作时变量的可见性。
除了volatile,Java中的synchronized和final两个关键字也可以实现可见性。只不过实现方式不同,这里不再展开了。
有序性
在Java中,可以使用synchronized和volatile来保证多线程之间操作的有序性。实现方式有所区别:
volatile关键字会禁止指令重排。synchronized关键字保证同一时刻只允许一条线程操作。
好了,这里简单的介绍完了Java并发编程中解决原子性、可见性以及有序性可以使用的关键字。读者可能发现了,好像synchronized关键字是万能的,他可以同时满足以上三种特性,这其实也是很多人滥用synchronized的原因。
但是synchronized是比较影响性能的,虽然编译器提供了很多锁优化技术,但是也不建议过度使用。
JMM是怎么解决可见性等问题的呢?
JMM内部的实现通常是依赖于所谓的内存屏障,通过禁止某些重排序的方式,提供内存可见性保证,也就是实现了各种happen-before规则。
可从四个维度去理解JMM:
1 从JVM运行时视角来看,JVM内存可分为JVM栈、本地方法栈、PC计数器、方法区、堆;其中前三区是线程所私有的,后两者则是所有线程共有的
2 从JVM内存功能视角来看,JVM可分为堆内存、非堆内存与其他。其中堆内存对应于上述的堆区;非堆内存对应于上述的JVM栈、本地方法栈、PC计数器、方法区;其他则对应于直接内存
3 从线程运行视角来看,JVM可分为主内存与线程工作内存。Java内存模型规定了所有的变量都存储在主内存中;每个线程的工作内存保存了被该线程使用到的变量,这些变量是主内存的副本 拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量
4 从垃圾回收视角来看,JVM中的堆区=新生代+老年代。新生代主要用于存放新创建的对象与存活时长小的对象,新生代=E+S1+S2;老年代则用于存放存活时间长的对象
29 Java应用开发中的注入攻击吗
1.sql注入
Select * from use_info where username = “input_usr_name” and password = “” or “” = “”
2.操作系统命令注入
3.XML注入攻击
Java API和工具构成了Java安全基础
第一,运行时安全机制。
第二,Java提供的安全框架API,这是构建安全通信等应用的基础。 例如: 加密、解密API。
授权、鉴权API。
安全通信相关的类库,比如基本HTTPS通信协议相关标准实现,如TLS 1.3;或者附属的类似证书撤销状态判断(OSCP)等协议实现。 注意,这一部分API内部实现是和厂商相关的,不同JDK厂商往往会定制自己的加密算法实现。
第三, 就是JDK集成的各种安全工具,例如:
keytool,这是个强大的工具,可以管理安全场景中不可或缺的秘钥、证书等,并且可以管理Java程序使用的keystore文件。
jarsigner,用于对jar文件进行签名或者验证。
30 谈谈MySQL支持的事务隔离级别,以及悲观锁和乐观锁的原理和应用场景
所谓隔离级别(Isolation Level),就是在数据库事务中,为保证并发数据读写的正确性而提出的定义,它并不是MySQL专有的概念,而是源于ANSI/ISO制定的SQL-92标准。 每种关系型数据库都提供了各自特色的隔离级别实现,虽然在通常的定义中是以锁为实现单元,但实际的实现千差万别。以最常见的MySQL InnoDB引擎为例,它是基于 MVCC(Multi-Versioning Concurrency Control)和锁的复合实现,按照隔离程度从低到高,MySQL事务隔离级别分为四个不同层次:
读未提交(Read uncommitted),就是一个事务能够看到其他事务尚未提交的修改,这是最低的隔离水平,允许脏读出现。
读已提交(Read committed),事务能够看到的数据都是其他事务已经提交的修改,也就是保证不会看到任何中间性状态,当然脏读也不会出现。读已提交仍然是比较低级别的 隔离,并不保证再次读取时能够获取同样的数据,也就是允许其他事务并发修改数据,允许不可重复读和幻象读(Phantom Read)出现。
可重复读(Repeatable reads),保证同一个事务中多次读取的数据是一致的,这是MySQL InnoDB引擎的默认隔离级别,但是和一些其他数据库实现不同的是,可以简单认 为MySQL在可重复读级别不会出现幻象读。
串行化(Serializable),并发事务之间是串行化的,通常意味着读取需要获取共享读锁,更新需要获取排他写锁,如果SQL使用WHERE语句,还会获取区间锁
(MySQL以GAP锁形式实现,可重复读级别中默认也会使用),这是最高的隔离级别。 至于悲观锁和乐观锁,也并不是MySQL或者数据库中独有的概念,而是并发编程的基本概念。主要区别在于,操作共享数据时,“悲观锁”即认为数据出现冲突的可能性更大,而“乐 观锁”则是认为大部分情况不会出现冲突,进而决定是否采取排他性措施。
反映到MySQL数据库应用开发中,悲观锁一般就是利用类似SELECT … FOR UPDATE这样的语句,对数据加锁,避免其他事务意外修改数据。乐观锁则与Java并发包中 的AtomicFieldUpdater类似,也是利用CAS机制,并不会对数据加锁,而是通过对比数据的时间戳或者版本号,来实现乐观锁需要的版本判断。
我认为前面提到的MVCC,其本质就可以看作是种乐观锁机制,而排他性的读写锁、双阶段锁等则是悲观锁的实现。 有关它们的应用场景,你可以构建一下简化的火车余票查询和购票系统。同时查询的人可能很多,虽然具体座位票只能是卖给一个人,但余票可能很多,而且也并不能预测哪个查询 者会购票,这个时候就更适合用乐观锁。