概述
Java基础、多线程、JVM、集合八股文自述
一、Java基础
1.1 object类有哪些方法?
getClass()、hashCode()、equals()、clone()、toString()、wait()、notify()、notifyAll()、finalize()
1.2 “==”与equals有什么区别?
**== **: 它的作⽤是判断两个对象的地址是不是相等。即,判断两个对象是不是同⼀个对象(基本数据类型⽐较的是值,引⽤数据类型⽐较的是内存地址)。
equals():它的作⽤也是判断两个对象是否相等。但它⼀般有两种使⽤情况:
情况 1:类没有覆盖 equals() ⽅法。则通过 equals() ⽐较该类的两个对象时,等价于通过“==”⽐较这两个对象。
情况 2:类覆盖了 equals() ⽅法。⼀般,我们都覆盖 equals() ⽅法来⽐较两个对象的内容是否相等;若它们的内容相等,则返回 true (即,认为这两个对象相等)
这里就延伸出了一个问题:为什么需要重写equals方法?
因为默认equals在比较两个对象时,是看他们是否指向同一个地址。但是,有时我们需要两个不同对象只要是某些属性相同就认为他们的equals()的结果是为true。
1.3 hashCode与equals
① 什么是hashCode?
hashCode() 的作⽤是获取哈希码,也称为散列码;它实际上是返回⼀个 int 整数。这个哈希码的作⽤是确定该对象在哈希表中的索引位置。
② 为什么要有hashCode?
我们以“ HashSet 如何检查重复”为例⼦来说明为什么要有 hashCode?
当你把对象加⼊ HashSet 时, HashSet 会先计算对象的 hashcode 值来判断对象加⼊的位置,同时也会与其他已经加⼊的对象的 hashcode 值作⽐较,如果没有相符的 hashcode, HashSet会假设对象没有重复出现。但是如果发现有相同 hashcode 值的对象,这时会调⽤ equals() ⽅法来检hashcode 相等的对象是否真的相同。如果两者相同, HashSet 就不会让其加⼊操作成功。如果不同的话,就会重新散列到其他位置。(摘⾃我的 Java 启蒙书《Head First Java》第⼆版)。这样我们就⼤⼤减少了 equals 的次数,相应就⼤⼤提⾼了执⾏速度。
③ 为什么重写 equals 时必须重写 hashCode ⽅法?
如果两个对象相等,则 hashcode ⼀定也是相同的。两个对象相等,对两个对象分别调⽤ equals⽅法都返回 true。但是,两个对象有相同的 hashcode 值,它们也不⼀定是相等的。 因此,equals ⽅法被覆盖过,则 hashCode ⽅法也必须被覆盖。
hashCode() 的默认⾏为是对堆上的对象产⽣独特值。如果没有重写 hashCode() ,则该class 的两个对象⽆论如何都不会相等(即使这两个对象指向相同的数据)。
1.4 Java的基本数据类型
byte:1个字节,short:2个字节,int:4个字节,long:8个字节,float:4个字节,double:8个字节,char:2个字节,boolean:1个字节。
1.5 static关键字
我们可以一句话来概括:方便在没有创建对象的情况下来进行调用 。也就是说:被static关键字修饰的不需要创建对象去调用,直接根据类名就可以去访问。
有三种基本使用方法:
① static关键字修饰类
用static修饰内部类,普通类是不允许声明为静态的,只有内部类才可以。如果没有用static修饰InterClass,则只能new 一个外部类实例。再通过外部实例创建内部类。
② static关键字修饰方法
可以直接通过类名来进行调用。
③ static关键字修饰变量
被static修饰的成员变量叫做静态变量,也叫做类变量,说明这个变量是属于这个类的,而不是属于是对象,没有被static修饰的成员变量叫做实例变量,说明这个变量是属于某个具体的对象的。
1.6 Java反射机制
JAVA反射机制是在运行状态中,对于任意一个类,都能够知道这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意一个方法和属性;这种动态获取的信息以及动态调用对象的方法的功能称为java语言的反射机制。
**Java反射机制主要提供的以下功能:**在运行时判断任意一个对象所属的类;在运行时构造任意一个类的对象;在运行时访问一个类所具有的成员变量;在运行时调用任意一个对象的方法;生成动态代理。
什么是反射机制?
反射机制就是在运行状态时,动态地获得这个类的所有属性和方法。
反射机制在哪里使用?
当你无法确定到底传入的类的是什么样子的,要通用地兼容所有的可能传入的类,那你就应该用反射机制动态地去调用这个类的所拥有的属性和方法。
例如:①我们在使用JDBC连接数据库时使用Class.forName()通过反射加载数据库的驱动程序;②Spring框架也用到很多反射机制,最经典的就是xml的配置模式。Spring 通过 XML 配置模式装载 Bean 的过程:1) 将程序内所有 XML 或 Properties 配置文件加载入内存中; 2)Java类里面解析xml或properties里面的内容,得到对应实体类的字节码字符串以及相关的属性信息; 3)使用反射机制,根据这个字符串获得某个类的Class实例; 4)动态配置实例的属性。
Java获取反射的三种方法:
1.通过new对象(对象.getClass())实现反射机制; 2.通过路径(Class.forName())实现反射机制; 3.通过类名(类名.Class)实现反射机制
1.7 String、StringBuilder、StringBuffer的区别
String 中的对象是不可变的,也就可以理解为常量,线程安全。
StringBuffer 对⽅法加了同步锁或者对调⽤的⽅法加了同步锁,所以是线程安全的。
StringBuilder 并没有对⽅法进⾏加同步锁,所以是⾮线程安全的。
对于三者使⽤的总结:
- 操作少量的数据: 适⽤ String
- 单线程操作字符串缓冲区下操作⼤量数据: 适⽤ StringBuilder
- 多线程操作字符串缓冲区下操作⼤量数据: 适⽤ StringBuffer
1.8 Java中的I/O模型
BIO: 是指应用程序在执行 I/O 操作后,如果没有获得响应,就会阻塞当前线程,不能执行其他任务。
同步阻塞 I/O 模式,数据的读取写⼊必须阻塞在⼀个线程内等待其完成。在活动连接数不是特别⾼(⼩于单机 1000)的情况下,这种模型是⽐较不错的,可以让每⼀个连接专注于⾃⼰的 I/O 并且编程模型简单,也不⽤过多考虑系统的过载、限流等问题。线程池本身就是⼀个天然的漏⽃,可以缓冲⼀些系统处理不了的连接或请求。但是,当⾯对⼗万甚⾄百万级连接的时候,传统的 BIO 模型是⽆能为⼒的。因此,我们需要⼀种更⾼效的 I/O 处理模型来应对更⾼的并发量。对于低负载、低并发的应⽤程序,可以使⽤同步阻塞 I/O 来提升开发速率和更好的维护性;
NIO: 是指应用程序在执行 I/O 操作后,不会阻塞当前的线程,可以继续执行其他的任务。
同步⾮阻塞的 I/O 模型。**工作原理:**使一个线程从某通道发送请求读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用时,就什么都不会获取,而不是保持线程阻塞,所以直至数据变的可以读取之前,该线程可以继续做其他的事情。 非阻塞写也是如此。一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。 线程通常将非阻塞IO的空闲时间用于在其它通道上执行IO操作,所以一个单独的线程现在可以管理多个输入和输出通道(channel)。它有三大核心部分:channel(管道)、buffer(缓冲区)、selector(选择器)。Buffer为数据的输入输出端。每当有数据到达Buffer时,Buffer把相应的事件传递给Channel。Channel把事件的key加入selector的请求队列里。selector一直轮询请求队列,只要里面有key就将其拿出,获取到事件和事件所在的Channel,然后对操作。对于⾼负载、⾼并发的(⽹络)应⽤,应使⽤ NIO 的⾮阻塞模式来开发。
BIO 和 NIO 的区别:
① BIO 以流的方式处理数据,而NIO 以块的方式处理数据,块I/O 的效率比流 I/O的效率高很多;
② BIO 是阻塞的,NIO 则是非阻塞的;
③ BIO 基于字节流和字符流进行操作,而NIO 基于**Channel(通道)和 Buffer(缓冲区)**进行操作,数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中。**Selector(选择器)**用于监听多个通道的事件(比如:连接请求,数据到达等),因此,使用单个线程就可以监听多个客户端通道。
常问的面试题:
BIO 和 NIO 作为 Server 端,当建立了 10 个连接时,分别产生多少个线程?
因为传统的 IO 也就是 BIO 是同步线程堵塞的,所以每个连接都要分配一个专用线程来处理请求,这样 10 个连接就会创建 10 个线程去处理。而 NIO 是一种同步非阻塞的 I/O 模型,它的核心技术是多路复用,可以使用一个链接上的不同通道来处理不同的请求,所以即使有 10 个连接,对于 NIO 来说,开启 1 个线程就够了。
拓展
IO多路复用:
**I/O多路复用:**是指内核一旦发现进程指定的一个或者多个I/O条件准备读取,它就通知该进程。
IO多路复用是一种同步IO模型,实现一个线程可以监听多个文件句柄;一旦发现某个文件的句柄就绪,就能够通知应用程序进行相应的读写操作;没有文件句柄就绪就会阻塞应用程序,交出CPU。
多路是指网络连接,复用指的是同一个线程。
使用的场合:
① 当客户处理多个描述符(fd)时(一般是交互式输入和网络套接口),必须使用I/O复用;
② 当一个客户同时需要处理多个套接口时,而这种情况是可能的,但是很少出现。
③ 如果一个TCP服务器既要处理监听套接口,又要处理已连接套接口,一般也需要用到 I/O复用。
④ 如果一个服务器要处理多个服务或者多个协议,一般需要使用I/O复用。
⑤ 如果一个服务器既要处理TCP,又要处理UDP,一般使用I/O复用。
与多线程和多进程技术相比,I/O多路复用技术的最大优势是:系统开销小了,系统不用创建进程/线程,也不要维护这些线程,从而大大减少了系统的开销。
聊聊Linux 五种IO模型
聊聊IO多路复用之select、poll、epoll详解
目前支持I/O多路复用的系统调用有 select,poll,epoll,I/O多路复用就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作
。
但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的;而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。
1)select基本原理
select 函数监视的文件描述符分3类,分别是writefds、readfds、和exceptfds。调用后select函数会阻塞,直到有描述符就绪(有数据 可读、可写、或者有except),或者超时(timeout指定等待时间,如果立即返回设为null即可),函数返回。当select函数返回后,可以通过遍历fdset,来找到就绪的描述符。
2)poll基本原理
poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历。
它没有最大连接数的限制,原因是它是基于链表来存储的,但是同样有一个缺点:
大量的fd的数组被整体复制于用户态和内核地址空间之间
,而不管这样的复制是不是有意义。- poll还有一个特点是**“水平触发”**,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。
注意:
从上面看,select和poll都需要在返回后,通过遍历文件描述符来获取已经就绪的socket。事实上,同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。
3)epoll基本原理
epoll是在2.6内核中提出的,是之前的select和poll的增强版本。相对于select和poll来说,epoll更加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。
epoll支持水平触发和边缘触发,最大的特点在于边缘触发,它只告诉进程哪些fd刚刚变为就绪态,并且只会通知一次
。还有一个特点是,
epoll使用“事件”的就绪通知方式**,通过epoll_ctl注册fd,**一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd,epoll_wait便可以收到通知。
epoll的优点:
没有最大并发连接的限制,能打开的FD的上限远大于1024(1G的内存上能监听约10万个端口)。
效率提升,不是轮询的方式,不会随着FD数目的增加效率下降。只有活跃可用的FD才会调用callback函数;即Epoll最大的优点就在于它只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,Epoll的效率就会远远高于select和poll。
内存拷贝,利用mmap()文件映射内存加速与内核空间的消息传递;即epoll使用mmap减少复制开销。
epoll对文件描述符的操作有两种模式:LT(level trigger,水平触发)和ET(edge trigger,边缘触发)。LT模式(水平触发)是默认模式,LT模式与ET模式的区别如下:
LT模式(水平触发):当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wait时,会再次响应应用程序并通知此事件。
ET模式(边缘触发):当epoll_wait检测到描述符事件发生并将此事件通知应用程序,**应用程序必须立即处理该事件。**如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。
参考:极客时间
**水平触发:**只要文件描述符可以非阻塞地执行 I/O ,就会触发通知。也就是说,应用程序可以随时检查文件描述符的状态,然后再根据状态,进行 I/O 操作。
**边缘触发:**只有在文件描述符的状态发生改变(也就是 I/O 请求达到)时,才发送一次通知。这时候,应用程序需要尽可能多地执行 I/O,直到无法继续读写,才可以停止。如果 I/O 没执行完,或者因为某种原因没来得及处理,那么这次通知也就丢失了。
注意:
如果没有大量的idle-connection或者dead-connection,epoll的效率并不会比select/poll高很多,但是当遇到大量的idle-connection,就会发现epoll的效率大大高于select/poll。
select,poll,epoll的区别:
综上:
在选择select,poll,epoll时要根据具体的使用场合以及这三种方式的自身特点:
表面上看epoll的性能最好,但是在连接数少并且连接都十分活跃的情况下,select和poll的性能可能比epoll好,毕竟epoll的通知机制需要很多函数回调。
select低效是因为每次它都需要轮询。但低效也是相对的,视情况而定,也可通过良好的设计改善。
同步调用和异常调用的区别:
同步调用:是一种阻塞式调用,一段代码调用另一端代码时,必须要等到这段代码执行结束并返回结果后,代码才能继续执行下去。
异步调用:是一种非阻塞式调用,一段异步代码还未执行完,可以继续执行下一段代码逻辑,当代码执行完以后,通过回调函数返回继续执行想应的逻辑,而不耽搁其他代码的执行。
1.9 了解lambda表达式吗?
Lambda表达式:
Lambda 表达式在 Java 语言中引入了一个新的语法元素和操作符。这个操作符为 “->”,该操作符被称 为 Lambda 操作符或箭头操作符。它将 Lambda 分为两个部分:
- 左侧:指定了 Lambda 表达式需要的所有参数
- 右侧:指定了 Lambda 体,即 Lambda 表达式要执行的功能。
Lambda 表达式,也可称为闭包,它是推动Java 8 发布的最重要新特性。
Lambda 允许把函数作为一个方法的参数(函数作为参数传递进方法中)。
使用 Lambda 表达式可以使代码变的更加简洁紧凑。
使用 Lambda 表达式需要注意以下两点:
- Lambda 表达式主要用来定义行内执行的方法类型接口,例如,一个简单方法接口。
- Lambda 表达式免去了使用匿名方法的麻烦,并且给予Java简单但是强大的函数化的编程能力。
使用Lambda表达式的要求:
也许你已经想到了,能够使用Lambda的依据是必须有相应的 函数接口。
函数接口,是指内部只有一个抽象方法的接口。这一点跟Java是强类型语言吻合,也就是说你并不能在代码的任何地方任性的写Lambda表达式。实际上Lambda的类型就是对应函数接口的类型。Lambda表达式另一个依据是类型推断机制,在上下文信息足够的情况下,编译器可以推断出参数表的类型,而不需要显式指名。
参考博客:面试官 | 什么是 Lambda?该如何使用?
在Java 8里面,所有的Lambda的类型都是一个接口,而Lambda表达式本身,也就是”那段代码“,需要是这个接口的实现。这是我认为理解Lambda的一个关键所在,简而言之就是,Lambda表达式本身就是一个接口的实现。
1.10 Java序列化和反序列化
面试官:您能说说序列化和反序列化吗?是怎么实现的?什么场景下需要它?
什么是序列化和反序列化:
**序列化:**将Java对象转换为字节序列的过程;
**反序列化:**将字节序列转换为Java对象的过程。
Java 对象序列化是将实现了 Serializable 接口的对象转换成一个字节序列,能够通过网络传输、文件存储等方式传输 ,传输过程中却不必担心数据在不同机器、不同环境下发生改变,也不必关心字节的顺序或其他任何细节,并能够在以后将这个字节序列完全恢复为原来的对象(恢复这一过程称之为反序列化)。
对象的序列化是非常有趣的,因为利用它可以实现轻量级持久性,“持久性”意味着一个对象的生存周期不单单取决于程序是否正在运行,它可以生存于程序的调用之间。通过将一个序列化对象写入磁盘,然后在重新调用程序时恢复该对象,从而达到实现对象的持久性的效果。
本质上讲,序列化就是把实体对象状态按照一定的格式写入到有序字节流,反序列化就是从有序字节流重建对象,恢复对象状态。为什么需要序列化?
我们知道,不同进程/程序间进行远程通信时,可以相互发送各种类型的数据,包括文本、图片、音频、视频等,而这些数据都会以二进制序列的形式在网络上传送。
那么当两个 Java 进程进行通信时,能否实现进程间的对象传送呢?当然是可以的!如何做到呢?这就需要使用 Java 序列化与反序列化了。发送方需要把这个 Java 对象转换为字节序列,然后在网络上传输,接收方则需要将字节序列中恢复出 Java 对象。
我们清楚了为什么需要使用 Java 序列化和反序列化后,我们很自然地会想到 Java 序列化有哪些好处:
实现了数据的持久化,通过序列化可以把数据永久地保存到硬盘上(如:存储在文件里),实现永久保存对象。
利用序列化实现远程通信,即:能够在网络上传输对象。
如何实现的呢?
序列化,首先要创建某些 OutputStream 对象,然后将其封装在一个 ObjectOutputStream 对象内,这时调用**writeObject()**方法,即可将对象序列化,并将其发送给 OutputStream(对象序列化是基于字节的,因此使用的 InputStream 和 OutputStream 继承的类)。
反序列化,即反向进行序列化的过程,需要将一个 InputStream 封装在 ObjectInputStream 对象内,然后调用 **readObject()**方法,获得一个对象引用(它是指向一个向上转型的 Object),然后进行类型强制转换来得到该对象。
什么场景下需要序列化?
- 当你想把的内存中的对象状态保存到一个文件中或者数据库中时候。
- 当你想用套接字在网络上传送对象的时候。
- 当你想通过 RMI 传输对象的时候。
二、多线程
2.1 什么是线程安全,如何实现线程安全呢?以及线程的可见性怎么理解?
**线程安全:**多线程访问同一代码,不会产生不确定的结果
**如何实现线程安全:**可以通过Synchronized、lock、volatile等关键字。
**线程的可见性:**当多个线程并发访问共享变量时,一个线程对共享变量的修改,其它线程能够立即看到。
2.2 实现多线程的方式
① 继承Thread类
② 实现runnable接口
③ 通过线程池创建线程
④ 使用有返回值的Callable创建线程
2.3 线程池
线程池采用池化思想,主要的目的是为了减少每次获取线程资源的消耗,提高对资源消耗的利用率。使用线程池有以下好处:①降低资源消耗;②提高响应速度;③提高线程的可管理性。创建线程池,一般会采用ThreadPoolExecutor的方式创建,ThreadPoolExecutor有三个最重要的参数,分别为核心线程数(corePoolSize),最大线程数(maximumPoolSize)以及工作队列(workQueue)。还有四大饱和策略,分别为:①抛出异常来拒绝新任务的处理(AbortPolicy);②调用执行自己的线程运行任务(CallerRunsPolicy);③不处理新任务,直接丢弃掉(DiscardPolicy);④丢弃最早的未处理的任务请求(DiscardOldestPolicy)。
线程池的原理:
2.3 悲观锁和乐观锁的原理,以及哪些是属于悲观锁,哪些是属于乐观锁?
悲观锁:
悲观锁是基于一种悲观的态度类来防止一切数据冲突,它是以一种预防的姿态在修改数据之前把数据锁住,然后再对数据进行读写,在它释放锁之前任何人都不能对其数据进行操作,直到前面一个人把锁释放后下一个人数据加锁才可对数据进行加锁,然后才可以对数据进行操作,一般数据库本身锁的机制都是基于悲观锁的机制实现的;
Synchronized、lock都是属于悲观锁的。
一般多写的场景下用悲观锁就比较合适。
乐观锁:
乐观锁是对于数据冲突保持一种乐观态度,操作数据时不会对操作的数据进行加锁(这使得多个任务可以并行的对数据进行操作),只有到数据提交的时候才通过一种机制来验证数据是否存在冲突(一般实现方式是通过加版本号然后进行版本号的对比方式实现);
CAS是属于乐观锁的。
乐观锁适用于写比较少的情况下==(多读场景)==。
2.4 lock的底层原理的实现
lock的存储结构:一个int类型状态值(用于锁的状态变更),一个双向链表(用于存储等待中的线程)
lock获取锁的过程:本质上是通过CAS来获取状态值修改,如果当场没获取到,会将该线程放在线程等待链表中。
**lock释放锁的过程:**修改状态值,调整等待链表。
可以看到在整个实现过程中,lock大量使用CAS+自旋。因此根据CAS特性,lock建议使用在低锁冲突的情况下。目前java1.6以后,官方对synchronized做了大量的锁优化(偏向锁、自旋、轻量级锁)。因此在非必要的情况下,建议使用synchronized做同步操作。
2.5 CAS的原理
参考博客:Java:CAS(乐观锁)
CAS的英文单词为Compare And Swap的缩写,翻译过来就是比较并交换。CAS是一种乐观锁。在CAS当中使用3个基本操作,分别为:内存地址V、旧的预期值A、要修改的新值B。当更新一个变量的时候,只有当前的变量预期值A 和 内存地址中的实际的值相同时,才会将内存地址对应修改为要新值B。
例如,在内存地址V当中,假设存放着一个变量D为10的值,此时,线程1想要去修改变量D。对于线程1来说,旧的预期值A=10,而要修改的新值B=11。如果在线程1要提交更新前,线程2获取到cpu的时间片,将内存地址V中的值率先更新为11,而再次当线程1获得cpu时间的时候,准备提交更新的时候,首先会进行旧的预期值A和内存地址V当中的实际值比较,如果此时发现A不等于V的实际值,则就提交失败,线程1就只能再重新获取内存地址V上的值,而此时旧的预期值A=11,新值B=12,等到没有其他的线程去更改内存地址V中的值,这个时间就会把内存地址V中的值替换为B,也就是12。这样采用CAS机制,就保证了原子性操作,保证了线程安全性。
2.6 synchronized 和 ReentrantLock有什么区别呢?
参考博客:Synchronized 与 ReentrantLock 的区别!
可重入性:从名字上理解,ReenTrantLock的字面意思就是再进入的锁,其实synchronized关键字所使用的锁也是可重入的,两者关于这个的区别不大。两者都是同一个线程每进入一次,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁。
锁的实现:在Synchronized优化以前,synchronized的性能是比ReenTrantLock差很多的,但是自从Synchronized引入了偏向锁,轻量级锁(自旋锁)后,两者的性能就差不多了。
功能区别:Synchronized的使用比较方便简洁,并且由编译器去保证锁的加锁和释放,而ReenTrantLock需要手工声明来加锁和释放锁,为了避免忘记手工释放锁造成死锁,所以最好在finally中声明释放锁。锁的细粒度和灵活度:很明显ReenTrantLock优于Synchronized;
ReenTrantLock独有的能力:
1、ReenTrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。
2、ReenTrantLock提供了一个Condition(条件)类,用来实现分组唤醒需要唤醒的线程们,而不是像synchronized要么随机唤醒一个线程要么唤醒全部线程。
3、ReenTrantLock提供了一种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()来实现这个机制。
ReenTrantLock实现的原理:
简单来说,ReenTrantLock的实现是一种自旋锁,通过循环调用CAS操作来实现加锁。
2.7 锁升级的原理
现代的(Oracle)JDK 中,JVM 对此进行了大刀阔斧地改进,提供了三种不同的 Monitor 实现,也就是常说的三种不同的锁:偏斜锁(Biased Locking)、轻量级锁和重量级锁,大大改进了其性能。
所谓锁的升级,就是 JVM 优化 synchronized 运行的机制,当 JVM 检测到不同的竞争状况时,会自动切换到适合的锁实现,这种切换就是锁的升级、降级。
当没有竞争出现时,默认会使用偏斜锁。JVM 会利用CAS 操作(compare and swap),在对象头上的 Mark Word 部分设置线程 ID,以表示这个对象偏向于当前线程,所以并不涉及真正的互斥锁。这样做的假设是基于在很多应用场景中,大部分对象生命周期中最多会被一个线程锁定,使用偏斜锁可以降低无竞争开销。
如果有另外的线程试图锁定某个已经被偏斜过的对象,JVM 就需要撤销(revoke)偏斜锁,并切换到轻量级锁实现。轻量级锁依赖 CAS 操作 Mark Word 来试图获取锁,如果重试成功,就使用普通的轻量级锁;否则,进一步升级为重量级锁。
2.8 公平锁和非公平锁
==公平锁:==多个线程按照申请锁的顺序去获得锁,线程会直接进入队列去排队,永远是队列的第一位才能获得锁。
优点:所有的线程都能够得到资源,不会饿死在队列中;
缺点:吞吐量会下降很多,队列里面除了第一个线程,其他线程都会阻塞,CPU唤醒阻塞线程的开销很大。
非公平锁:
**优点:**可以减少CPU唤醒线程的开销,整体的吞吐效率会高点,CPU也不需要去唤醒所有的线程,会减少唤起线程的数量;
**缺点:**这样可能会导致队列中间的线程一直获取不到锁或者长时间获取不到锁的,导致饿死。
2.9 为什么有了synchronized,还要有lock这个关键字呢?
为什么提供了synchronized,还要提供Lock呢?
我们使用synchronized加锁是无法主动释放锁的,这就会涉及到死锁的问题。
如果我们的程序使用synchronized关键字发生了死锁时,synchronized关键字是是无法破坏**“不可剥夺”这个死锁的条件的。这是因为synchronized**申请资源的时候, 如果申请不到, 线程直接进入阻塞状态了, 而线程进入阻塞状态, 啥都干不了, 也释放不了线程已经占有的资源。
然而,在大部分场景下,我们都是希望**“不可剥夺”这个条件能够被破坏。也就是说对于“不可剥夺”**这个条件,占用部分资源的线程进一步申请其他资源时, 如果申请不到, 可以主动释放它占有的资源, 这样不可剥夺这个条件就破坏掉了。
synchronized关键字的局限性:
有时候用synchronized修饰的代码,访问它需要很长时间,下一个要访问同一代码块的线程就要等待阻塞很长的时间。如果我想要下一个线程在等待一段时间后,如果还没有得到锁的话,就放弃等待,这就可以使用Lock锁,来设置等待时间。synchronized 是互斥锁,同一时间只能有一个线程可以访问被它修饰的代码块。而Lock锁可以实现互斥锁,也可以实现共享锁(同一时间支持多条线程访问)。
如果我们的程序使用synchronized关键字发生了死锁时,synchronized关键是是无法破坏**“不可剥夺”这个死锁的条件的。这是因为synchronized**申请资源的时候, 如果申请不到, 线程直接进入阻塞状态了, 而线程进入阻塞状态, 啥都干不了, 也释放不了线程已经占有的资源。
三、JVM
3.1 如何调优JVM的参数呢?
JVM调优三大参数:(如: java -Xms128m -Xmx128m -Xss256k -jar xxxx.jar)
-Xss:规定了每个线程虚拟机栈的大小(影响并发线程数大小)
-Xms:堆大小的初始值(超过初始值会扩容到最大值)
-Xmx:堆大小的最大值(通常初始值和最大值一样,因为扩容会导致内存抖动,影响程序运行稳定性)
JVM调优参数简介、调优目标及调优经验
3.2. Java类加载过程
当Java程序需要使用某个类时,如果该类还未被加载到内存中,JVM会通过加载、链接(验证、准备和解析)、初始化三个步骤来对该类进行初始化。
类的加载是指把类的.class文件中的数据读入到内存中,通常是创建一个字节数组读入.class文件,然后产生与所加载类对应的Class对象。加载完成后,Class对象还不完整,所以此时的类还不可用。当类被加载后就进入连接阶段,这一阶段包括验证、准备(为静态变量分配内存并设置默认的初始值)和解析(将符号引用替换为直接引用)三个步骤。最后JVM对类进行初始化,包括:
1)如果类存在直接的父类并且这个类还没有被初始化,那么就先初始化父类;
2)如果类中存在初始化语句,就依次执行这些初始化语句。
加载:
通过ClassLoader加载Class文件字节码,生成Class对象
链接:
校验:检查加载的的Class的正确性和安全性
准备:为类变量分配存储空间并设置类变量初始值
解析:JVM将常量池内的符号引用转换为直接引用
初始化:
执行类变量赋值和静态代码块
3.3 JVM的内存模型与内存结构
参考博客:JVM内存结构和Java内存模型别再傻傻分不清了
内存模型(JMM):
通俗点来说,JMM是一套多线程读写共享数据时,对数据的可见性、有序性以及原子性的规则。
为什么需要JMM:
因为JVM实现不同会造成"翻译"的效果不同,不同的cpu平台的机器指令有千差万别,无法保证同一份代码并发的情况下执行效果是一致的。所以需要一套统一的规范来约束JVM的翻译过程,保证并发效果的一致性。
内存结构:
程序计数器:
- 字节码解释器通过改变程序计数器来依次读取指令,从⽽实现代码的流程控制,如:顺序执⾏、选择、循环、异常处理。
- 在多线程的情况下,程序计数器⽤于记录当前线程执⾏的位置,从⽽当线程被切换回来的时候能够知道该线程上次运⾏到哪⼉了。
Java虚拟机栈:
与程序计数器⼀样, Java 虚拟机栈也是线程私有的,它的⽣命周期和线程相同,描述的是 Java⽅法执⾏的内存模型,每次⽅法调⽤的数据都是通过栈传递的。
本地方法栈:
和虚拟机栈所发挥的作⽤⾮常相似,区别是:虚拟机栈为虚拟机执⾏ Java ⽅法 (也就是字节码)服务,⽽本地⽅法栈则为虚拟机使⽤到的 Native ⽅法服务。 本地⽅法被执⾏的时候,在本地⽅法栈也会创建⼀个栈帧,⽤于存放该本地⽅法的局部变量表、操作数栈、动态链接、出⼝信息。
堆区:
Java 虚拟机所管理的内存中最⼤的⼀块, Java 堆是所有线程共享的⼀块内存区域,在虚拟机启动时创建。 此内存区域的唯⼀⽬的就是存放对象实例,⼏乎所有的对象实例以及数组都在这⾥分配内存
方法区:
⽅法区与 Java 堆⼀样,是各个线程共享的内存区域,它⽤于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
拓展:
JVM内存结构中堆和栈的区别:
- 管理方式:栈自动释放,堆需要GC
- 空间大小:栈比堆小
- 碎片:栈产生的碎片远少于堆
- 分配方式:栈支持静态分配和动态分配,堆只支持动态分配
- 效率:栈的效率比堆高
3.4 那你知道full GC和young GC的区别吗?
参考博客:young GC和Full GC的区别、什么时候触发young gc和Full GC、如何优化GC
young GC(新生代GC):指发生在新生代的垃圾收集动作,新生代中的对象朝生夕死,所以 Minor GC 非常频繁,回收速度也比较快。当新生代中的eden区分配满的时候触发。
Full GC(老年代GC):指发生在老年代的GC,速度一般比 Minor GC 慢十倍以上。Full GC 会 Stop-The-World。Full GC是对收集整堆(新生代、老年代)和方法区的垃圾收集。当老年代满时会引发Full GC,Full GC将会同时回收新生代、年老代 ;当永久代满时也会引发Full GC,会导致Class、Method元信息的卸载 。
拓展:
参考博客:JVM 垃圾回收之Minor GC、Major GC和Full GC之间的区别
- Minor GC 是 清理 新生代中的Eden区,Survivor区满时不会触发 ;当年轻代(Eden区)满时就会触发 Minor GC,这里的年轻代满指的是 Eden区满。
- Major GC 是 **清理 老年代 **;CMS收集器中,当老年代满时会触发 Major GC。目前,只有CMS收集器会有单独收集老年代的行为。其他收集器均无此行为。
3.5 为什么JVM中要有s0和s1这两个东西呢?
参考博客:请你谈谈JVM设置Eden、S0、S1的理由?
Survivor的存在意义:就是减少被送到老年代的对象,进而减少Full GC的发生,Survivor的预筛选保证,只有经历16次Minor GC还能在新生代中存活的对象,才会被送到老年代。
设置两个Survivor区最大的好处就是解决了碎片化,为什么一个Survivor区不行?假设现在只有一个survivor区:刚刚新建的对象在Eden中,一旦Eden满了,触发一次Minor GC,Eden中的存活对象就会被移动到Survivor区。这样继续循环下去,下一次Eden满了的时候,问题来了,此时进行Minor GC,Eden和Survivor各有一些存活对象,如果此时把Eden区的存活对象硬放到Survivor区,很明显这两部分对象所占有的内存是不连续的,也就导致了内存碎片化。碎片化带来的风险是极大的,堆中没有足够大的连续内存空间,无法为一个内存需求很大的对象分配内存。所以应该建立两块Survivor区,刚刚新建的对象在Eden中,经历一次Minor GC,Eden中的存活对象就会被移动到S0,Eden被清空;等Eden区再满了,就再触发一次Minor GC,Eden和S0中的存活对象又会被复制送入第二块S1(这个过程非常重要,因为这种复制算法保证了S1中来自S0和Eden两部分的存活对象占用连续的内存空间,避免了碎片化的发生)。
3.6 为什么需要 JVM 这样的东西呢?
因为JVM能够实现一次编译,到处运行的效果,可以跨平台运行。Java编译时,并不直接翻译为相依于某平台的0101指令,而是翻译为中介格式的位元码(byte code)。Java 的原始码文件格式名为***.java**,经过编译器翻译过后,会变成***.class**的格式文件位元码。如果想要执行这个位元码档案,目标平台上必须安装有JVM(Java Virtual Machine)。JVM会将位元码翻译为相应平台支持的语言。
而C/C++在不同的平台上必须使用不同的编译器来编译你的代码,在Windows平台上编译好的程序,也不能直接拿到Linux等其它平台上执行,而必须经过重新编译的动作,让编译器将你的程式编译为该平台可以执行的指令。
总结:
Java 虚拟机(JVM)是运⾏ Java 字节码的虚拟机。 JVM 有针对不同系统的特定实现
(Windows, Linux, macOS),⽬的是使⽤相同的字节码,它们都会给出相同的结果。字节码和不同系统的 JVM 实现是 Java 语⾔**“⼀次编译,随处可以运⾏”**的关键所在。
最后
以上就是美满天空为你收集整理的Java基础、多线程、JVM、集合八股文自述(持续更新)的全部内容,希望文章能够帮你解决Java基础、多线程、JVM、集合八股文自述(持续更新)所遇到的程序开发问题。
如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。
发表评论 取消回复