Java面试—Day6

numeric java应该用什么村_加载

我们目标是星辰大海,而非人间烟尘


文章目录

  • Java面试---Day6
  • 1、为什么Java里的匿名内部类只能访问final修饰的外部变量?
  • 2、Synchronized
  • 3、volatile
  • 4、类加载流程双亲委托机制


1、为什么Java里的匿名内部类只能访问final修饰的外部变量?

因为匿名内部类最终会被编译成一个单独的类,而被该类使用的变量会以构造函数参数的形式传递给该类。如果变量不定义为final的,参数在匿名内部类中可以被修改,进而造成和外部的变量不一致的问题,为了避免这种不一致的情况,规定匿名内部类只能访问final修饰的外部变量。

2、Synchronized

synchronized,是Java中用于解决并发情况下数据同步访问的一个很重要的关键字。当我们想要保证一个共享资源在同一个时间只会被一个线程访问到时,我们可以在代码中使用synchronized关键字对类或者对象加锁。

在Java中,synchronized有两种使用方式,同步方法和同步代码块。

对于同步方法,JVM采用ACC_SYNCHRONIZED标记符来实现同步。对于同步代码块,JVM采用monitorenter、monitorexit 两个指令来实现同步。

同步方法
方法级的同步是隐式的,同步方法的常量池中会有一个ACC_SYNCHRNZED 标志,当某个线程要访问某个方法的时候,会检查是否有 ACC_SYNCHORIZED ,如果有设置,则需要先获得监视器锁,然后开始执行方法,方法执行之后在释放监视器锁。这时如果其他线程来请求执行方法,会因为无法获得监视器锁而被阻断住。值得注意的是,如果方法在执行过程中发生了异常,并且方法内部并没有处理该异常,那么异常被抛到方法外面之前监视器锁会被自动释放。

同步代码块
同步代码块使用monitorenter和monitorexit两个指令实现。可以把执行monitorenter指令理解为加锁,执行monitorexit理解为释放锁。每个对象维护着一个记录被锁次数的计数器,未被锁定的对象的该计数器为0,当一个线程获得锁(执行monitorenter)后,该计数器自增变为1,当同一个线程再次获得该对象的锁的时候,计数器再次自增。当同一个线程释放锁(执行monitorexit指令)的时候,计数器在自减。当计数器为0的时候,锁将被释放,其他线程便可以获得锁。

synchronized与原子性
原子性是指一个操作是不可中断的,要全部执行完成,要不就都不执行。

线程是CPU调度的基本单位,CPU有时间片的概念,会根据不同的调度算法进行线程调度。当一个线程获得时间片之后开始执行,在时间片耗尽之后,就会失去CPU使用权。所以在多线程场景下,由于时间片在线程间轮换,就会发生原子性问题。

在Java中,为了保证原子性,提供了两个高级的字节码指令 monitorenter 和 monitorexit 。前面介绍过,这两个字节码指令,在Java中对应的关键字就是 synchronized。

通过 monitorenter 和 monitorexit 指令,可以保证被 synchronized修饰的代码在同一时间只能被一个线程访问,在锁未释放之前,无法被其他线程访问到。因此,在Java中可以使用 synchronized 来保证方法和代码块内的操作是原子性的。

线程一在执行monitorenter指令的时候,会对Monitor进行加锁,加锁后其他线程无法获得锁,除非线程一主动解锁。即使在执行过程中,由于某种原因,比如CPU时间片用完,线程一放弃了CPU,但是,他并没有进行解锁,而由于 synchorized 的锁是可以重入的,下一个时间片还是只能被他自己获取到,还是会继续执行代码,直到所有代码执行完,这就保证了原子性。

synchroized与可见性
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之后也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主内存之间进行数据同步进行。所以,就可能出现线程一修改了某个变量的值,但线程二不可见的情况。

前面我们介绍过,被 synchronized 修饰的代码,在开始执行时会加锁,执行完成后会进行解锁。而为了保证可见性,有一条规则是专业的:对一个变量解锁之前,必须先把变量同步到主内存中。这样解锁后,后续线程就可以访问到被修改后的值。

所以,被 synchronized 关键字锁住的对象,其值是具有可见性的。

synchronized与有序性
有序性即程序执行的顺序按照代码的先后顺序执行。

除了引入了时间片以外,由于处理器优化和指令重排,CPU还可能对输入代码进行乱序执行,这就可能存在有序性问题。

这里需要注意的是,synchronized 是无法禁止指令重排和处理器优化的,也就是说,synchronized 无法避免上述提到的问题。那么为什么还说synchronized 也提供了有序性保证呢?

这就要把有序性的概念扩展一下了,Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有操作都是天然有序的,如果在一个线程中观察另外一个线程,所有的操作都是无序的。

以上这句话也是《深入理解Java虚拟机》中的原句,但是怎么理解呢?这其实和 as-if-serial语义有关。

as-if-serial 语义的意思是指:不管怎么重排序(编译器和处理器为了提高并行度),单线程程序的执行结果都不能被改变,编译器和处理器无论如何优化,都必须遵守 as-if-serial语义。

简单来说,as-if-serial语义保证了单线程中,指令重排是有一定限制的,而只要编译器和处理器都遵守这个语义,那么就可以认为单线程程序是按照顺序执行的,当然,实际上还是有重排,只不过我们无需关心这种重排的干扰。

所以说,由于synchronized修饰的代码,同一时间只能被同一个线程访问,那么也就是单线程执行,所以可以保证其有序性。

synchronized与锁优化

无论是ACC_SYNCHORIZED还是monitorenter、monitorexit都是基于Monitor实现的,在Java虚拟机(HotSpot)中,Monitor是基于C++实现的,由ObjectMonitor实现。

ObjectMonitor类中提供了几个方法,如 enter、exit、wait、notify、notifyAll 等。sychronized 加锁的原理,会调用 objectMonitor的enter方法,解锁的时候会调用 exit 方法。事实上,只有在JDK1.6之前,synchronized的实现才会直接调用ObjectMonitor的enter和exit,这种锁被称之为重量级锁。为什么说这种方式操作锁很重呢?

Java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统的帮忙,这就是要从用户态转换为核心态,因此状态转换需要花费很多的处理器时间,对于代码简单的同步块,状态转换消耗的时间有可能比用户代码执行的时间还要长,所以说synchroized是java语言中一个重量级的操纵。

所以,在JDK1.6中出现对锁进行了很多的优化,进而出现了轻量级锁、偏向锁、锁消除,适应性自旋锁等等,这些操作都是为了在线程之间更高效的共享数据,解决竞争问题。

3、volatile

volatile用法
volatile通常被比喻成轻量级的synchronized,也是Java并发编程中比较重要的一个关键字,和synchronized不同,volatile是一个变量修饰符,只能用来修饰变量,无法修饰方法以及代码块。

volatile的用法比较简单,只需要在声明一个可能被多线程同时访问的变量时,使用volatile修饰就可以了。

volatile原理
为了提高处理器的执行速度,在处理器和内存之间增加了多级缓存来提升,但是由于引入了多级缓存,就存在缓存数据不一致的问题。

但是,对于volatile变量,当对volatile变量进行写操作的时候,JVM会向处理器发送一条Lock前缀的指令,将这个缓存中的变量回写到系统主存中。

但是就算回写内存,如果其他处理器缓存的值还是旧的,在执行计算操作就会有问题,所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议。

缓存一致性协议:

每个处理器通过嗅探在总线上传播的数据来检测自己缓存的信息是不是过期了,当处理器发现自己缓存行对应的内存地址被修改了,就会将当前处理器的缓存行设置为无效状态,当处理器要对这个数据进行修改操作的时候,会强制重新从系统内存里把数据读到处理器缓存里。

所以,如果一个变量被volatile所修饰的话,在每次数据变化之后,其值都会被强制刷新入主存。而其他处理器的缓存由于遵守了缓存一致性协议,也会把这个变量的值从主存加载到自己的缓存中,这就保证了一个volatile修饰的变量在多个缓存中是可见的。

volatile与可见性
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程也能够立即看到修改的值。

前面在关于volatile原理的时候讲过,Java中的volatile关键字提供了一个功能,那就是被修饰的变量在被修改后可以立即同步到主存中,被其修饰的变量在每次使用之前都是从主内存中刷新。因此,可以使用volatile来保证多线程操作时变量的可见性。

volatile与有序性
volatile禁止指令重排优化,这就保证了代码的程序会严格按照代码的先后顺序执行,这就保证了有序性。

volatile与原子性
在上面介绍synchronized的时候,提到过,为了保证原子性,需要通过字节码指令monitorenter和monitorexit,但是volatile和这两个指令没有任何关系。

所以,volatile是不能保证原子性的。

在以下两个场景中可以使用volatile替代synchronized:

  1. 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程会修改变量的值
  2. 变量不需要与其他状态变量共同参与不变约束

除了以上场景,都需要使用其他方式来保证原子性,如synchronized或者concurrent包。

synchronized可以保证原子性、有序性和可见性,而volatile只能保证有序性和可见性。

4、类加载流程双亲委托机制

类加载流程

  1. 装载
  2. 链接
  1. 验证
  2. 准备
  3. 解析
  1. 初始化

双亲委托机制

某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父类加载器,依次递归,如果父类加载器可以完成类加载任务,就成功返回;只有父类加载器无法完成此加载任务时,才会自己去加载。

使用双亲委托模型的好处在于Java类随着它的类加载器一起具备类一种带有优先级的层次关系。例如类java.lang.Object,它存在rt.jar中,无论哪一个类加载器要加载这个类,最终都会委托处于模型最顶端的Bootstrap ClassLoader进行加载,因此Object类在程序的各类加载器环境中都是同一个类。相反,如果没有双亲委派模型,而是由各个类加载器自行加载的话,如果用户编写了一个java.lang.Object的同名类并放在ClassPath中,那么系统中将会出现多个不同的Object类,程序将变得混乱。因此,如果开发者尝试编写一个与rt.jar类库中重名的Java类,可以正常编译,但是永远无法被加载运行。

  1. 当前ClassLoader首先从自己已经加载的类中查询是否此类已经记载,如果已经加载则可以直接返回已经加载的类。
    每个类加载器都有自己的加载缓存,当一个类被加载了以后就会放入缓存,等下次加载的时候就可以直接返回了。
  2. 当前ClassLoader的缓存中没有找到被加载的类的时候,会委托父类加载器去加载,父类加载器采用同样的策略,首先查看自己的缓存,然后委托父类的父类去加载,一直到BootStrap ClassLoader。
  3. 当所有的父类加载器都没有加载的时候,再有当前的类加载器加载,并将其放入它自己的缓存中,以便下次加载请求时直接返回。

托父类加载器去加载,父类加载器采用同样的策略,首先查看自己的缓存,然后委托父类的父类去加载,一直到BootStrap ClassLoader。

  1. 当所有的父类加载器都没有加载的时候,再有当前的类加载器加载,并将其放入它自己的缓存中,以便下次加载请求时直接返回。

为什么需要这样的委托机制呢?理解这个问题,我们要引入另外一个关于ClassLoader的概念“命名空间”,它是指要确定某一个类,需要类的全限定名以及加载此类的ClassLoader来共同确定。也就是说,即使两个类的全限定名相同,但是因为不同的ClassLoader加载了此类,那么在JVM中它是不同的类。明白了命名空间以后,我们再来看看委托模型。采用了委托模型以后加大了不同的ClassLoader的交互能力,比如上面说的,我们JDK本身提供的类库,比如HashMap、LinkedList等等,这些类由bootstrap类加载器加载以后,无论你程序中有多少个类加载器,那么这些类其实都是可以共享的,这样就避免了不同的类加载器加载了同样名字的不同类以后造成混乱。