JAVA面试必备--JAVA基础篇(一)之 关键字

      相信很多同行小伙伴会因为许多原因想跳槽,不论是干得不开心还是想跳槽涨薪,在如此内卷的行业,我们都面临着“面试造火箭,上班拧螺丝”的局面,鉴于当前形势博主呕心沥血整理的干货满满的造火箭的技巧来了,本博主花费2个月时间,整理归纳java全生态知识体系常见面试题!总字数高达百万! 干货满满,每天更新,关注我,不迷路,用强大的归纳总结,全新全细致的讲解来留住各位猿友的关注,希望能够帮助各位猿友在应付面试笔试上!当然如有归纳总结错误之处请各位指出修正!如有侵权请联系博主QQ1062141499!    

目录

1 区别Java中i++和++i的

2 final关键字作用

3 static关键字作用

4 abstract关键字作用

5 final与static的区别

6  instanceof关键字的作用是什么?

7 volatite关键字的作用是什么?

 7.1 volatile 可见性的实现

7.2 volatile 有序性的实现

7.3 内存屏障

8  volatile关键字解析

  8.1.内存模型的相关概念

8.2 .并发编程中的三个概念

8.3. Java内存模型

8.4 .深入剖析 volatile 关键字

 8.4.1、volatile关键字的两层语义

8.5 使用 volatile 关键字的场景

8.6 小结

9 volatile关键字能否保证线程安全?

10 什么是Assert?

 10.1 assert:断言

  10.2 Java 中断言有两种语法形式:

11 synchronized关键字的作用是什么?

12 synchronized 是可重入锁吗?为什么?

    12.1 什么是可重入锁?

    12.2 synchronized 是可重入锁吗?

12.3 可重入锁的实现原理?

13 JDK6 对 synchronized 的锁优化

   1. 背景

   2. 锁优化

    2.1 适应性自旋锁

    2.2 锁消除

    2.3 锁粗化

   2.4 偏向锁(重入锁)

   核心思想

2.5 轻量级锁

3. 心得

4. 三种锁的对比

5. 总结

14 synchronized实现原理?

15 synchronized锁的升级原理是什么?

16 synchronized和volatile关键字的作用

17 Java中的 <<、>>、>>> 是什么?

18 if-else-if-else与switch的区别

   18.1 if-else-if-else:

  18.2 switch:

19 while和do-while的区别

20 this和super关键字的作用

21 switch 是否能作用在 byte 上,是否能作用在 long 上,是否能作用在String上

22  ==和equals区别

23 Java 有没有goto语句

24 &和&&的区别

25 break和continue的区别

26  访问权限修饰符public、private、protected, 以及不写(默认)时的区别


1 区别Java中i++和++i的

       int i=1,a=0;

  •      i++ 先赋值在运算,例如 a=i++,先赋值a=i,后运算i=i+1,所以结果是a==1
  •      ++i 先运算在赋值,例如 a=++i,先运算i=i+1,后赋值a=i,所以结果是a==2

2 final关键字作用

  •              final 语义是最终的、不可改变的。
  •       被 final 修饰的类,不能够被继承
  •       被 final 修饰的成员变量必须要初始化,赋初值后不能再重新赋值
  •       (可以调用对象方法修改属性值)。对基本类型来说是其值不可变;对引用变量来说其引用不可变,即不能再指向其他的对象。
  •       被 final 修饰的方法代表不能重写

3 static关键字作用

  1.           static 可以修饰变量、方法、代码块和内部类
  2.           static 变量是这个类所有,由该类创建的所有对象共享同一个 static 属性
  3.           static 变量在内存中只有一份
  4.           static 修饰的变量只能是类的成员变量
  5.           static 方法可以通过对象名.方法名和类名.方法名两种方式来访问
  6.           static 代码块在类被第一次加载时执行静态代码块,且只被执行一次,主要作用是实现 static 属性的初始化
  7.           static 内部类属于整个外部类,而不属于外部类的每个对象,只可以访问外部类的静态变量和方法

4 abstract关键字作用

  1.         可以修饰类和方法
  2.         不能修饰属性和构造方法
  3.         abstract 修饰的类是抽象类,需要被继承
  4.         abstract 修饰的方法是抽象方法,需要子类被重写

5 final与static的区别

  •        都可以修饰类、方法、成员变量。
  •        都不能用于修饰构造方法。
  •        static 可以修饰类的代码块,final 不可以。
  •         static 不可以修饰方法内的局部变量,final 可以。

static

  1. static 修饰表示静态或全局,被修饰的属性和方法属于类,可以用类名.静态属性 / 方法名 访问
  2. static 修饰的代码块表示静态代码块,当 Java 虚拟机(JVM)加载类时,就会执行该代码块,只会被执行一次
  3. static 修饰的属性,也就是类变量,是在类加载时被创建并进行初始化,只会被创建一次
  4. static 修饰的变量可以重新赋值
  5. static 方法中不能用 this 和 super 关键字
  6. static 方法必须被实现,而不能是抽象的abstract
  7. static 方法不能被重写

final

  1. final 修饰表示常量、一旦创建不可改变
  2. final 标记的成员变量必须在声明的同时赋值,或在该类的构造方法中赋值,不可以重新赋值
  3. final 方法不能被子类重写
  4. final 类不能被继承,没有子类,final 类中的方法默认是 final 的

final 和 static 修饰成员变量加载过程例子

public class TestStaticFinal {
    public static void main(String[] args) {
        StaticFinal sf1 = new StaticFinal();
        StaticFinal sf2 = new StaticFinal();
        System.out.println(sf1.fValue == sf2.fValue);//打印false
        System.out.println(sf1.sValue == sf2.sValue);//打印true
    }
}
class StaticFinal {
    final int fValue = new Random().nextInt();
    static int sValue = new Random().nextInt();
}

6  instanceof关键字的作用是什么?

     instanceof 运算符是用来在运行时判断对象是否是指定类及其父类的一个实例。比较的是对象,不能比较基本类型

     使用如下:

public class TestInstanceof {
    public static void main(String[] args) {
        A a = new A();
        AA aa = new AA();
        AAA aaa = new AAA();
        System.out.println(a instanceof A);//true
        System.out.println(a instanceof AA);//false
        System.out.println(aa instanceof AAA);//false
        System.out.println(aaa instanceof A);//true
    }
}
class A {
}
class AA extends A {
}
class AAA extends AA {
}

7 volatite关键字的作用是什么?

Java 中 volatile 关键字是一个类型修饰符。JDK 1.5 之后,对其语义进行了增强。

  • 保证了不同线程对共享变量进行操作时的可见性,即一个线程修改了共享变量的值,共享变量修改后的值对其他线程立即可见
  • 通过禁止编译器、CPU 指令重排序和部分 happens-before 规则,解决有序性问题

 7.1 volatile 可见性的实现

  • 在生成汇编代码指令时会在 volatile 修饰的共享变量进行写操作的时候会多出 Lock 前缀的指令
  • Lock 前缀的指令会引起 CPU 缓存写回内存
  • 一个 CPU 的缓存回写到内存会导致其他 CPU 缓存了该内存地址的数据无效
  • volatile 变量通过缓存一致性协议保证每个线程获得最新值
  • 缓存一致性协议保证每个 CPU 通过嗅探在总线上传播的数据来检查自己缓存的值是不是修改
  • 当 CPU 发现自己缓存行对应的内存地址被修改,会将当前 CPU 的缓存行设置成无效状态,重新从内存中把数据读到 CPU 缓存

看一下我们之前的一个可见性问题的测试例子

public class TestVisibility {
	//是否停止 变量
	private static boolean stop = false;
	
	public static void main(String[] args) throws InterruptedException {
		//启动线程 1,当 stop 为 true,结束循环
		new Thread(() -> {
		    System.out.println("线程 1 正在运行...");
		    while (!stop) ;
		    System.out.println("线程 1 终止");
		}).start();
		
		//休眠 10 毫秒
		Thread.sleep(10);
		
		//启动线程 2, 设置 stop = true
		new Thread(() -> {
		    System.out.println("线程 2 正在运行...");
		    stop = true;
		    System.out.println("设置 stop 变量为 true.");
		}).start();
	}
	
}

程序会一直循环运行下去

这个就是因为 CPU 缓存导致的可见性导致的问题。

线程 2 设置 stop 变量为 true,线程 1 在 CPU 1上执行,读取的 CPU 1 缓存中的 stop 变量仍然为 false,线程 1 一直在循环执行。

示意如图:

给 stop 变量加上 valatile 关键字修饰就可以解决这个问题。

7.2 volatile 有序性的实现

  • 3 个 happens-before 规则实现:

           1) 对一个 volatile 变量的写 happens-before 任意后续对这个 volatile 变量的读

           2) 在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作

           3) happens-before 传递性,A happens-before B,B happens-before C,则 A happens-before C

  • 内存屏障(Memory Barrier 又称内存栅栏,是一个 CPU 指令)禁止重排序

           1) 在程序运行时,为了提高执行性能,在不改变正确语义的前提下,编译器和 CPU 会对指令序列进行重排序。

           2) Java 编译器会在生成指令时,为了保证在不同的编译器和 CPU 上有相同的结果,通过插入特定类型的内存屏障来禁止特定类型的指令重排序

           3) 编译器根据具体的底层体系架构,将这些内存屏障替换成具体的 CPU 指令

          4) 内存屏障会告诉编译器和 CPU:不管什么指令都不能和这条 Memory Barrier 指令重排序

7.3 内存屏障

  • 为了实现 volatile 内存语义时,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的 CPU 重排序。
  • 对于编译器,内存屏障将限制它所能做的重排序优化;对于 CPU,内存屏障将会导致缓存的刷新操作
  • volatile 变量的写操作,在变量的前面和后面分别插入内存屏障;volatile 变量的读操作是在后面插入两个内存屏障

               1) 在每个 volatile 写操作的前面插入一个 StoreStore 屏障

               2) 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障

               3) 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障

               4) 在每个 volatile 读操作的后面插入一个 LoadStore 屏障

  • 屏障说明

              1) StoreStore:禁止之前的普通写和之后的 volatile 写重排序;

              2) StoreLoad:禁止之前的 volatile 写与之后的 volatile 读/写重排序

              3) LoadLoad:禁止之后所有的普通读操作和之前的 volatile 读重排序

              4) LoadStore:禁止之后所有的普通写操作和之前的 volatile 读重排序

     我觉得,有序性最经典的例子就是 JDK 并发包中的显式锁 java.util.concurrent.locks.Lock 的实现类对有序性的保障。

     以下摘自:http://ifeve.com/java锁是如何保证数据可见性的/

  实现 Lock 的代码思路简化为

private volatile int state;
void lock() {
    read state
    if (can get lock)
        write state
}
void unlock() {
    write state
}
  • 假设线程 a 通过调用lock方法获取到锁,此时线程 b 也调用了 lock() 方法,因为 a 尚未释放锁,b 只能等待。
  • a 在获取锁的过程中会先读 state,再写 state。
  • 当 a 释放掉锁并唤醒 b,b 会尝试获取锁,也会先读 state,再写 state。

Happens-before 规则:一个 volatile 变量的写操作发生在这个 volatile 变量随后的读操作之前。

8  volatile关键字解析

摘要:

  在 Java 并发编程中,要想使并发程序能够正确地执行,必须要保证三条原则,即:原子性、可见性和有序性。只要有一条原则没有被保证,就有可能会导致程序运行不正确。volatile关键字 被用来保证可见性,即保证共享变量的内存可见性以解决缓存一致性问题。一旦一个共享变量被 volatile关键字 修饰,那么就具备了两层语义:内存可见性和禁止进行指令重排序。在多线程环境下,volatile关键字 主要用于及时感知共享变量的修改,并使得其他线程可以立即得到变量的最新值,例如,用于 修饰状态标记量 和 Double-Check (双重检查)中。

  volatile关键字 虽然从字面上理解起来比较简单,但是要用好不是一件容易的事情。由于 volatile关键字 是与 内存模型 紧密相关,因此在讲述 volatile关键字 之前,我们有必要先去了解与内存模型相关的概念和知识,然后回头再分析 volatile关键字 的实现原理,最后在给出 volatile关键字 的使用场景。

  8.1.内存模型的相关概念

  大家都知道,计算机在执行程序时,每条指令都是在 CPU 中执行的,而执行指令过程中,势必涉及到数据的读取和写入。由于程序运行过程中的临时数据是存放在主存(物理内存)当中的,这时就存在一个问题:由于 CPU 执行速度很快,而从内存读取数据和向内存写入数据的过程跟 CPU 执行指令的速度比起来要慢的多,因此如果任何时候对数据的操作都要通过和内存的交互来进行,会大大降低指令执行的速度。因此,在 CPU 里面就有了 高速缓存(寄存器)。也就是说,在程序运行过程中,会将运算需要的数据从主存复制一份到 CPU 的高速缓存当中,那么, CPU 进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。举个简单的例子,比如下面的这段代码:

i = i + 1;

  当线程执行这个语句时,会先从主存当中读取 i 的值,然后复制一份到高速缓存当中,然后CPU执行指令对 i 进行加1操作,然后将数据写入高速缓存,最后将高速缓存中 i 最新的值刷新到主存当中。

  这个代码在单线程中运行是没有任何问题的,但是在多线程中运行就会有问题了。在多核 CPU 中,每个线程可能运行于不同的 CPU 中,因此每个线程运行时有自己的高速缓存(对单核CPU来说,其实也会出现这种问题,只不过是以线程调度的形式来分别执行的)。本文我们以多核CPU为例。

  比如,同时有两个线程执行这段代码,假如初始时 i 的值为 0,那么我们希望两个线程执行完之后 i 的值变为 2。但是事实会是这样吗?

  可能存在下面一种情况:初始时,两个线程分别读取 i 的值存入各自所在的 CPU 的高速缓存当中,然后线程1 进行加 1 操作,然后把 i 的最新值 1 写入到内存。此时线程 2 的高速缓存当中 i 的值还是 0,进行加 1 操作之后,i 的值为 1,然后线程 2 把 i 的值写入内存。

  最终结果 i 的值是 1,而不是 2 。这就是著名的 缓存一致性问题 。通常称这种被多个线程访问的变量为 共享变量 

  也就是说,如果一个变量在多个 CPU 中都存在缓存(一般在多线程编程时才会出现),那么就可能存在 缓存不一致 的问题。

  为了解决缓存不一致性问题,在 硬件层面 上通常来说有以下两种解决方法:

  1)通过在 总线加 LOCK# 锁 的方式 (在软件层面,效果等价于使用 synchronized 关键字)

  2)通过 缓存一致性协议 (在软件层面,效果等价于使用 volatile 关键字)

  在早期的 CPU 当中,是通过在总线上加 LOCK# 锁的形式来解决缓存不一致的问题。因为 CPU 和其他部件进行通信都是通过总线来进行的,如果对总线加 LOCK# 锁的话,也就是说阻塞了其他 CPU 对其他部件访问(如内存),从而使得只能有一个CPU能使用这个变量的内存。比如上面例子中, 如果一个线程在执行 i = i + 1,如果在执行这段代码的过程中,在总线上发出了 LCOK# 锁的信号,那么只有等待这段代码完全执行完毕之后,其他 CPU 才能从变量 i 所在的内存读取变量,然后进行相应的操作,这样就解决了缓存不一致的问题。但是上面的方式会有一个问题,由于在锁住总线期间,其他 CPU 无法访问内存,导致效率低下。

  所以,就出现了 缓存一致性协议 ,其中最出名的就是 Intel 的 MESI 协议。MESI 协议保证了每个缓存中使用的共享变量的副本是一致的。它核心的思想是: 当 CPU 写数据时,如果发现操作的变量是共享变量,即在其他 CPU 中也存在该变量的副本,会发出信号通知其他 CPU 将该变量的缓存行置为无效状态。因此,当其他 CPU 需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。 

 

8.2 .并发编程中的三个概念

  在并发编程中,我们通常会遇到以下三个问题: 原子性问题  可见性问题  有序性问题 。我们先看具体看一下这三个概念:

 1、原子性

  原子性: 即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

  一个很经典的例子就是银行账户转账问题:

  比如从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。

  试想一下,如果这两个操作不具备原子性,会造成什么样的后果。假如从账户 A 减去 1000 元之后,操作突然中止。然后又从 B 取出了 500 元,取出 500 元之后,再执行 往账户 B 加上 1000 元 的操作。这样就会导致账户A虽然减去了 1000 元,但是账户 B 没有收到这个转过来的 1000 元。所以,这两个操作必须要具备原子性才能保证不出现一些意外的问题。

  同样地,反映到并发编程中会出现什么结果呢?

  举个最简单的例子,大家想一下,假如为一个32位的变量赋值过程不具备原子性的话,会发生什么后果?

i = 9;

  假若一个线程执行到这个语句时,我们暂且假设为一个32位的变量赋值包括两个过程:为低16位赋值,为高16位赋值。那么就可能发生一种情况:当将低16位数值写入之后,突然被中断,而此时又有一个线程去读取 i 的值,那么读取到的就是错误的数据,导致 数据不一致性 问题。

2、可见性

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

  举个简单的例子,看下面这段代码:

//线程1执行的代码
int i = 0;
i = 10;
//线程2执行的代码
j = i;

  假若执行 线程1 的是 CPU1,执行 线程2 的是 CPU2。由上面的分析可知,当 线程1 执行 i = 10 这句时,会先把 i 的初始值加载到 CPU1 的高速缓存中,然后赋值为10,那么在 CPU1 的高速缓存当中 i 的值变为 10 了,却没有立即写入到主存当中。此时,线程2 执行 j = i,它会先去主存读取 i 的值并加载到 CPU2 的缓存当中,注意此时内存当中 i 的值还是 0,那么就会使得 j 的值为 0,而不是 10。

  这就是可见性问题,线程1 对变量 i 修改了之后,线程2 没有立即看到 线程1 修改后的值。

3、有序性

  有序性:即程序执行的顺序按照代码的先后顺序执行。举个简单的例子,看下面这段代码:

int i = 0;              
boolean flag = false;
i = 1;                
//语句1  
flag = true;          
//语句2

  上面代码定义了一个 int型 变量,定义了一个 boolean型 变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句1 是在 语句2 前面的,那么 JVM 在真正执行这段代码的时候会保证 语句1 一定会在 语句2 前面执行吗?不一定,为什么呢?这里可能会发生 指令重排序(Instruction Reorder)

  下面解释一下什么是指令重排序,一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的(单线程情形下)

  比如上面的代码中,语句1 和 语句2 谁先执行对最终的程序结果并没有影响,那么就有可能在执行过程中, 语句2 先执行而 语句1 后执行。但是要注意,虽然处理器会对指令进行重排序,但是它会保证程序最终结果会和代码顺序执行结果相同,那么它靠什么保证的呢?再看下面一个例子:

int a = 10;    
//语句1
int r = 2;    
//语句2
a = a + 3;   
//语句3
r = a*a;     
//语句4

  这段代码有4个语句,那么可能的一个执行顺序是:

 那么可不可能是这个执行顺序呢: 语句2 -> 语句1 -> 语句4 -> 语句3

  答案是不可能,因为处理器在进行重排序时会考虑指令之间的 数据依赖性,如果一个指令 Instruction 2 必须用到 Instruction 1 的结果,那么处理器会保证 Instruction 1 会在 Instruction 2 之前执行。

  虽然 重排序不会影响单个线程内程序执行的结果,但是多线程呢?下面,看一个例子:

//线程1:
context = loadContext();   
//语句1
inited = true;             
//语句2
//线程2:
while(!inited ){
  sleep()
}
doSomethingwithconfig(context);

  上面代码中,由于 语句1 和 语句2 没有数据依赖性,因此可能会被重排序。假如发生了重排序,在 线程1 执行过程中先执行 语句2,而此时 线程2 会以为初始化工作已经完成,那么就会跳出 while循环 ,去执行 doSomethingwithconfig(context) 方法,而此时 context 并没有被初始化,就会导致程序出错。

  从上面可以看出,指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。也就是说,要想使并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。

8.3. Java内存模型

  在前面谈到了一些关于内存模型以及并发编程中可能会出现的一些问题。下面我们来看一下 Java内存模型,研究一下 Java内存模型 为我们提供了哪些保证以及在 Java 中提供了哪些方法和机制来让我们在进行多线程编程时能够保证程序执行的正确性。

      在 Java虚拟机规范 中,试图定义一种 Java内存模型(Java Memory Model,JMM) 来屏蔽各个硬件平台和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果。那么,Java内存模型 规定了哪些东西呢,它定义了程序中变量的访问规则,往大一点说是定义了程序执行的次序。注意,为了获得较好的执行性能,Java内存模型并没有限制执行引擎使用处理器的寄存器或者高速缓存来提升指令执行速度,也没有限制编译器对指令进行重排序。也就是说,在 Java内存模型 中,也会存在缓存一致性问题和指令重排序的问题。

  Java内存模型 规定所有的变量都是存在主存当中(类似于前面说的物理内存),每个线程都有自己的工作内存(类似于前面的高速缓存)。线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作,并且每个线程不能访问其他线程的工作内存。

  举个简单的例子:在java中,执行下面这个语句:

i  = 10;

  执行线程必须先在自己的工作线程中对 变量i 所在的缓存进行赋值操作,然后再写入主存当中,而不是直接将数值10写入主存当中。那么,Java语言本身对原子性、可见性以及有序性 提供了哪些保证呢?

   1、原子性

  在 Java 中,对基本数据类型的变量的 读取 和 赋值 操作是原子性操作,即这些操作是不可被中断的 : 要么执行,要么不执行。

  上面一句话虽然看起来简单,但是理解起来并不是那么容易。看下面一个例子,请分析以下哪些操作是原子性操作:

x = 10;         //语句1y = x;         //语句2x++;           //语句3x = x + 1;     //语句4

  乍一看,有些朋友可能会说上面的四个语句中的操作都是原子性操作。其实 只有 语句1 是原子性操作,其他三个语句都不是原子性操作。

  语句1 是直接将数值 10 赋值给 x,也就是说线程执行这个语句的会直接将数值 10 写入到工作内存中;

  语句2 实际上包含两个操作,它先要去读取 x 的值,再将 x 的值写入工作内存。虽然,读取 x 的值以及 将 x 的值写入工作内存这两个操作都是原子性操作,但是合起来就不是原子性操作了;

  同样的,x++ 和 x = x+1 包括3个操作:读取 x 的值,进行加 1 操作,写入新的值。

  所以,上面四个语句只有 语句1 的操作具备原子性。也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。

  不过,这里有一点需要注意:在32位平台下,对64位数据的读取和赋值是需要通过两个操作来完成的,不能保证其原子性。但是好像在最新的JDK中,JVM 已经保证对64位数据的读取和赋值也是原子性操作了。

  从上面可以看出,Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过 synchronized 和 Lock 来实现。由于 synchronized 和 Lock 能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。

2、可见性

  对于可见性,Java 提供了 volatile关键字 来保证可见性。

  当一个共享变量被 volatile 修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。

  另外,通过 synchronized 和 Lock 也能够保证可见性,synchronized 和 Lock 能保证同一时刻只有一个线程获取锁然后执行同步代码,并且 在释放锁之前会将对变量的修改刷新到主存当中,因此可以保证可见性。

3、有序性

  在 Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。

  在 Java 中,可以通过 volatile 关键字来保证一定的“有序性”(具体原理在下一节讲述)。另外,我们千万不能想当然地认为,可以通过synchronized 和 Lock 来保证有序性,也就是说,不能由于 synchronized 和 Lock 可以让线程串行执行同步代码,就说它们可以保证指令不会发生重排序,这根本不是一个粒度的问题。

  另外,Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before 原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。

  下面就来具体介绍下 happens-before原则(先行发生原则):

  • 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;
  • 锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作;
  • volatile 变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;
  • 传递规则:如果操作 A 先行发生于操作 B,而操作 B 又先行发生于操作 C,则可以得出操作 A 先行发生于操作 C ;
  • 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作;
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
  • 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
  • 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始。

  这八条原则摘自《深入理解Java虚拟机》。这八条规则中,前四条规则是比较重要的,后四条规则都是显而易见的。下面我们来解释一下前四条规则:

  对于程序次序规则来说,我的理解就是一段程序代码的执行在单个线程中看起来是有序的。注意,虽然这条规则中提到“书写在前面的操作先行发生于书写在后面的操作”,这个应该是程序看起来执行的顺序是按照代码顺序执行的,因为虚拟机可能会对程序代码进行指令重排序。虽然进行重排序,但是最终执行的结果是与程序顺序执行的结果一致的,它只会对不存在数据依赖性的指令进行重排序。因此,在单个线程中,程序执行看起来是有序执行的,这一点要注意理解。事实上,这个规则是用来保证程序在单线程中执行结果的正确性,但无法保证程序在多线程中执行的正确性。

  第二条规则也比较容易理解,也就是说无论在单线程中还是多线程中,同一个锁如果出于被锁定的状态,那么必须先对锁进行了释放操作,后面才能继续进行 lock 操作。

  第三条规则是一条比较重要的规则,也是后文将要重点讲述的内容。直观地解释就是,如果一个线程先去写一个变量,然后一个线程去进行读取,那么写入操作肯定会先行发生于读操作。

  第四条规则实际上就是体现 happens-before 原则具备传递性。

8.4 .深入剖析 volatile 关键字

 8.4.1、volatile关键字的两层语义

  一旦一个共享变量(类的成员变量、类的静态成员变量)被 volatile 修饰后,那么就具备了两层语义:

  1)保证了不同线程对共享变量进行操作时的可见性,即一个线程修改了某个变量的值,这个新值对其他线程来说是 立即可见 的;

  2)禁止进行指令重排序。

  先看一段代码,假如 线程1 先执行,线程2 后执行:

//线程1
boolean stop = false;
while(!stop){
    doSomething();
}
//线程2
stop = true;

  这段代码是很典型的一段代码,很多人在中断线程时可能都会采用这种标记办法。但是事实上,这段代码会完全运行正确么?即一定会将线程中断么?不一定,也许在大多数时候,这个代码能够把线程中断,但是也有可能会导致无法中断线程(虽然这个可能性很小,但是只要一旦发生这种情况就会造成死循环了)。

  下面解释一下这段代码为何有可能导致无法中断线程。在前面已经解释过,如上图所示,每个线程在运行过程中都有自己的工作内存,那么 线程1 在运行的时候,会将 stop 变量的值拷贝一份放在自己的工作内存当中。那么,当 线程2 更改了 stop变量 的值之后,可能会出现以下两种情形:

  • 线程2 对变量的修改没有立即刷入到主存当中;
  • 即使 线程2 对变量的修改立即反映到主存中,线程1 也可能由于没有立即知道 线程2 对stop变量的更新而一直循环下去。

这两种情形都会导致 线程1 处于死循环。但是,用 volatile关键字 修饰后就变得不一样了,如下图所示:

  ① 使用 volatile 关键字会强制将修改的值立即写入主存;

  ② 使用 volatile 关键字的话,当 线程2 进行修改时,会导致 线程1 的工作内存中缓存变量stop的缓存行无效(反映到硬件层的话,就是CPU的 L1 或者 L2 缓存中对应的缓存行无效);

  ③ 由于 线程1 的工作内存中缓存变量stop的缓存行无效,所以,线程1 再次读取变量stop的值时会去主存读取。

  综上,在 线程2 修改 stop 值时(当然这里包括两个操作,修改 线程2 工作内存中的值,然后将修改后的值写入内存),会使得 线程1 的工作内存中缓存变量 stop 的缓存行无效,然后 线程1 读取时,会发现自己的缓存行无效从而去对应的主存读取最新的值 。简化一下,通过使用 volatile 关键字,如下图所示,线程会及时将变量的新值更新到主存中,并且保证其他线程能够立即读到该值。这样,线程1 读取到的就是最新的、正确的值。  

  下面通过两个例子更好地了解关键字 volatile 的作用。下面先看 示例1:

 
//资源类
class MyList {
    // 临界资源
    private List list = new ArrayList();
    // 对临界资源的访问
    public void add() {
        list.add("rico");
    }
    public int size() {
        return list.size();
    }
}
// 线程B
class ThreadB extends Thread {
    private MyList list;
    public ThreadB(MyList list) {
        super();
        this.list = list;
    }
    @Override
    public void run() { // 任务 B
        try {
            while (true) {
                if (list.size() == 2) {
                    System.out.println("list中的元素个数为2了,线程b要退出了!");
                    throw new InterruptedException();
                }
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
// 线程A
class ThreadA extends Thread {
    private MyList list;
    public ThreadA(MyList list) {
        super();
        this.list = list;
    }
    @Override
    public void run() { // 任务 A
        try {
            for (int i = 0; i < 3; i++) {
                list.add();
                System.out.println("添加了" + (i + 1) + "个元素");
                Thread.sleep(1000);
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
// 测试
public class Test {
    public static void main(String[] args) {
        MyList service = new MyList();
        ThreadA a = new ThreadA(service);
        a.setName("A");
        a.start();
        ThreadB b = new ThreadB(service);
        b.setName("B");
        b.start();
    }
}

运行结果如下所示:

       第一个运行结果是在没有使用volatile关键字的情况下产生的,第二个运行结果是在使用volatile关键字的情况下产生的。特别地 “若将 类ThreadA 中的 run()方法中的 Thread.sleep(1000);去掉,上述两种运行结果都有可能出现。”事实上,之所以会出现这种情况,究其根本,是由线程获得CPU执行的不确定性引起的。也就是说,在使用volatile关键字修饰共享变量list的前提下,去掉代码Thread.sleep(1000);后,之所以也会出现第一种运行结果是因为存在这样一种情形:线程A 早已运行结束但线程B才刚刚开始执行或尚未开始执行,即串行执行的情形。总的来说,在类ThreadA 中的 run()方法中添加 Thread.sleep(1000);的原因就是 为了保证线程A、B 能交替执行,防止上述情形的出现。

示例2:

public class TestVolatile {
    public static void main(String[] args) {
        ThreadDemo td = new ThreadDemo();
        new Thread(td, "ThreadDemo").start();
        while (true) {
            // 加上下面三句代码的任意一句,程序都会正常结束:
            // System.out.println("!!");                              //...语句1
            // synchronized (TestVolite.class) {}                     //...语句2
            //TestVolite.test2();                                    //...语句3
            // 若只加上下面一句代码,程序都会死循环:
            // TestVolite.test1();                                  //...语句4
            if (td.flag) {
                System.out.println("线程 " + Thread.currentThread().getName()
                        + " 即将跳出while循环体... ");
                break;
            }
        }
    }
    public static void test1() {}
    public synchronized static void test2() {}
}
class ThreadDemo implements Runnable {
    public boolean flag = false;
    @Override
    public void run() {
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
        }
        flag = true;
        System.out.println("线程 " + Thread.currentThread().getName() + " 执行完毕: "
                + "置  flag= " + flag + " ...");
    }
}

  上述程序运行结果如下图:

 下面对该程序分以下 5 种情形进行修改并讨论,如下所示:

Case 1:只用 volatile 关键字修饰 类ThreadDemo 中的共享变量 flag

运行结果为: 

     

Case 2:只取消对语句1的注释

运行结果为:

       

Case 3:只取消对语句2的注释

运行结果为: 

      

Case 4:只取消对语句3的注释

运行结果为:  

       

Case 5:只取消对语句4的注释

运行结果为:          

 关于上面五种情形,情形1 和 情形5 很容易理解,此不赘述。

  但是,对于上面的 第 2、3、4 三种情形,可能有很多朋友就不能理解了,特别是 第2种情形。其实,这三种情形都反映了一个问题:在我们不使用 volatile 关键字修饰共享变量去保证其可见性时,那么线程是不是始终一直从自己的工作内存中读取变量的值呢? 什么情况下,线程工作内存中的变量值才会与主存中的同步并取得一致状态呢?

  事实上,除了 volatile 可以保证内存可见性外,synchronized 也可以保证可见性,因为每次运行synchronized块 或者 synchronized方法都会导致线程工作内存与主存的同步,使得其他线程可以取得共享变量的最新值。也就是说,synchronized 语义范围不但包括 volatile 具有的可见性,也包括原子性,但不能禁止指令重排序,这是二者一个功能上的差异。说到这里,朋友应该就理解了 情形3 和 情形4 了。但是,情形2 怎么也会导致类似于 情形3 和 情形4 的效果呢? 因为 System.out.println() 方法里面包含 synchronized块, 我们看完它的源码就大彻大悟了,如下:

public void println(String x) {  
    synchronized (this) {         // synchronized 块
        print(x);  
        newLine();  
    }  
}  

2、volatile 保证原子性吗?

  从上面知道, volatile 关键字保证了操作的可见性,但是 volatile 能保证对变量的操作是原子性吗?

  下面看一个例子:

//线程类
class MyThread extends Thread {
    // volatile 共享静态变量,类成员
    public volatile static int count;
    private static void addCount() {
        for (int i = 0; i < 100; i++) {
            count++;
        }
        System.out.println("count=" + count);
    }
    @Override
    public void run() {
        addCount();
    }
}
//测试类
public class Run {
    public static void main(String[] args) {
        //创建 100个线程并启动
        MyThread[] mythreadArray = new MyThread[100];
        for (int i = 0; i < 100; i++) {
            mythreadArray[i] = new MyThread();
        }
        for (int i = 0; i < 100; i++) {
            mythreadArray[i].start();
        }
    }
}/* Output(循环): 
       ... ...
       count=9835
 *///:~

  大家想一下这段程序的输出结果是多少?也许有些朋友认为是 10000。但是事实上运行它会发现每次运行结果都不一致,都是一个 小于 10000 的数字。可能有的朋友就会有疑问,不对啊,上面是对变量 count 进行自增操作,由于 volatile 保证了可见性,那么在每个线程中对 count 自增完之后,在其他线程中都能看到修改后的值啊,所以有 100个 线程分别进行了 100 次操作,那么最终 count 的值应该是 100*100=10000。

  这里面就有一个误区了,volatile 关键字能保证可见性没有错,但是上面的程序错在没能保证原子性。可见性只能保证每次读取的是最新的值,但是 volatile 没办法保证对变量的操作的原子性。在前面已经提到过,自增操作是不具备原子性的,它包括 读取变量的原始值、进行加1操作 和 写入工作内存 三个原子操作。那么就是说,这三个子操作可能会分割开执行,所以就有可能导致下面这种情况出现:

  假如某个时刻 变量count 的值为 10,线程1 对变量进行自增操作,线程1 先读取了 变量count 的原始值,然后 线程1 被阻塞了;然后,线程2 对变量进行自增操作,线程2 也去读取 变量count 的原始值,由于 线程1 只是对 变量count 进行读取操作,而没有对变量进行修改操作,所以不会导致 线程2 的工作内存中缓存变量 count 的缓存行无效,所以 线程2 会直接去主存读取 count的值 ,发现 count 的值是 10,然后进行加 1 操作。注意,此时 线程2 只是执行了 count + 1 操作,还没将其值写到 线程2 的工作内存中去!此时线程2 被阻塞,线程1 进行加 1 操作时,注意操作数count仍然是 10!然后,线程2 把 11 写入工作内存并刷到主内存。虽然此时 线程1 能感受到 线程2 对count的修改,但由于线程1只剩下对count的写操作了,而不必对count进行读操作了,所以此时 线程2 对count的修改并不能影响到 线程1。于是,线程1 也将 11 写入工作内存并刷到主内存。也就是说,两个线程分别进行了一次自增操作后,count 只增加了 1。下图演示了这种情形:

  进一步地,将上述代码修改成下面示例的样子以后,这个问题就迎刃而解:

class MyThread extends Thread {
    // 既然使用 synchronized关键字 ,就没必要使用 volatile关键字了
    public static int count;
    //注意必须添加 static 关键字,这样synchronized 与 static 锁的就是 Mythread.class 对象了,
    //也就达到同步效果了
    private synchronized static void addCount() {
        for (int i = 0; i < 100; i++) {
            count++;
        }
        System.out.println("count=" + count);
    }
    @Override
    public void run() {
        addCount();
    }
}
//测试类
public class Run {
    public static void main(String[] args) {
        //创建 100个线程并启动
        MyThread[] mythreadArray = new MyThread[100];
        for (int i = 0; i < 100; i++) {
            mythreadArray[i] = new MyThread();
        }
        for (int i = 0; i < 100; i++) {
            mythreadArray[i].start();
        }
    }
}
 

  使用 Lock 和 Java 1.5 所提供的 java.util.concurrent.atomic 包来保证线程安全性将在后面的博文中进行介绍。

8.5 使用 volatile 关键字的场景

  synchronized 关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率;而 volatile 关键字在某些情况下性能要优于 synchronized,但是要注意 volatile 关键字是无法替代 synchronized 关键字的,因为 volatile 关键字无法保证操作的原子性。通常来说,使用 volatile 必须具备以下两个条件:

  1)对变量的写操作不依赖于当前值;

  2)该变量没有包含在具有其他变量的不变式中。

  实际上,这些条件表明,可以被写入 volatile 变量的这些有效值 独立于任何程序的状态,包括变量的当前状态。事实上,上面的两个条件就是保证对 该volatile变量 的操作是原子操作,这样才能保证使用 volatile关键字 的程序在并发时能够正确执行。

特别地,关键字 volatile 主要使用的场合是:

  在多线程环境下及时感知共享变量的修改,并使得其他线程可以立即得到变量的最新值。

8.5.1 状态标记量

// 示例 1
volatile boolean flag = false;
while(!flag){
    doSomething();
}
public void setFlag() {
    flag = true;
}
// 示例 2
volatile boolean inited = false;
//线程1:
context = loadContext();  
inited = true;            
//线程2:
while(!inited ){
    sleep()
}
doSomethingwithconfig(context);

8.5.2 Double-Check (双重检查)

class Singleton{
    private volatile static Singleton instance = null;
    private Singleton() {
    }
    public static Singleton getInstance() {
        if(instance==null) {
            synchronized (Singleton.class) {
                if(instance==null)
                    instance = new Singleton();
            }
        }
        return instance;
    }
}

8.6 小结

  关键字volatile 与内存模型紧密相关,是线程同步的轻量级实现,其性能要比 synchronized关键字 好。在作用对象和作用范围上, volatile 用于修饰变量,而 synchronized关键字 用于修饰方法和代码块,而且 synchronized 语义范围不但包括 volatile拥有的可见性,还包括volatile 所不具有的原子性,但不包括 volatile 拥有的有序性,即允许指令重排序。因此,在多线程环境下,volatile关键字 主要用于及时感知共享变量的修改,并保证其他线程可以及时得到变量的最新值。可用以下文氏图表示 synchronized 和 volatile语义范围:

              

9 volatile关键字能否保证线程安全?

     单纯使用 volatile 关键字是不能保证线程安全的

  • volatile 只提供了一种弱的同步机制,用来确保将变量的更新操作通知到其他线程
  • volatile 语义是禁用 CPU 缓存,直接从主内存读、写变量。表现为:更新 volatile 变量时,JMM 会把线程对应的本地内存中的共享变量值刷新到主内存中;读 volatile 变量时,JMM 会把线程对应的本地内存设置为无效,直接从主内存中读取共享变量
  • 当把变量声明为 volatile 类型后,JVM 增加内存屏障,禁止 CPU 进行指令重排

10 什么是Assert?

 10.1 assert:断言

  1. 一种常用的调试方式,很多开发语言中都支持这种机制
  2. 通常在开发和测试时开启
  3. 可以用来保证程序最基本、关键的正确性
  4. 为了提高性能,发布版的程序通常关闭断言
  5. 断言是一个包含布尔表达式的语句,在执行这个语句时假定该表达式为 true;如果表达式计算为 false ,会报告一个 AssertionError
  6. 断言在默认情况下是禁用的,要在编译时启用断言,需使用source 1.4 标记,如 javac -source 1.4 TestAssert.java
  7. 要在运行时启用断言,需加参数 -ea 或 -enableassertions
  8. 要在运行时选择禁用断言,需加参数 -da 或 -disableassertions
  9. 要在系统类中启用或禁用断言,需加参数 -esa 或 -dsa

  10.2 Java 中断言有两种语法形式

  1. assert 表达式1;
  2. assert 表达式1 : 错误表达式 ;

    表达式1 是一个布尔值错误表达式可以得出一个值,用于生成显示调试信息的字符串消息

assert 1 > 0;
int x = 1;
assert x <0 : "大于0";

打印:

Exception in thread "main" java.lang.AssertionError: 大于0

        at Lee.interview.TestAssert.main(TestAssert.java:8)

11 synchronized关键字的作用是什么?

    Java 中关键字 synchronized 表示只有一个线程可以获取作用对象的锁,执行代码,阻塞其他线程。

作用:

•       确保线程互斥地访问同步代码

•       保证共享变量的修改能够及时可见

•       有效解决重排序问题

 用法:

•       修饰普通方法

•       修饰静态方法

•       指定对象,修饰代码块

 特点:

•       阻塞未获取到锁、竞争同一个对象锁的线程

•       获取锁无法设置超时

•       无法实现公平锁

•       控制等待和唤醒需要结合加锁对象的 wait() 和 notify()、notifyAll()

•       锁的功能是 JVM 层面实现的

•       在加锁代码块执行完或者出现异常,自动释放锁

 原理:

•       同步代码块是通过 monitorenter 和 monitorexit 指令获取线程的执行权

•       同步方法通过加 ACC_SYNCHRONIZED 标识实现线程的执行权的控制

12 synchronized 是可重入锁吗?为什么?

    12.1 什么是可重入锁?

      关于什么是可重入锁,顾名思义就是可重新进入,我们先来看一段维基百科的定义。

      若一个程序或子程序可以“在任意时刻被中断然后操作系统调度执行另外一段代码,这段代码又调用了该子程序不会出错”,则称其为可重入(reentrant或re-entrant)的。即当该子程序正在运行时,执行线程可以再次进入并执行它,仍然获得符合设计时预期的结果。与多线程并发执行的线程安全不同,可重入强调对单个线程执行时重新进入同一个子程序仍然是安全的。

通俗来说:当线程请求一个由其它线程持有的对象锁时,该线程会阻塞,而当线程请求由自己持有的对象锁时,如果该锁是重入锁,请求就会成功,否则阻塞。

    12.2 synchronized 是可重入锁吗?

       我先给大家一个结论:synchronized 是可重入锁!

       假设我们现在不知道它是不是一个可重入锁,那我们就应该想方设法来验证它是不是可重入锁?怎么验证呢?看下面的代码!

public class Xttblog extends SuperXttblog {
    public static void main(String[] args) {
        Xttblog child = new Xttblog();
        child.doSomething();
    }
    public synchronized void doSomething() {
        System.out.println("child.doSomething()" + Thread.currentThread().getName());
        doAnotherThing(); // 调用自己类中其他的synchronized方法
    }
    private synchronized void doAnotherThing() {
        super.doSomething(); // 调用父类的synchronized方法
        System.out.println("child.doAnotherThing()" + Thread.currentThread().getName());
    }
}
class SuperXttblog {
    public synchronized void doSomething() {
        System.out.println("father.doSomething()" + Thread.currentThread().getName());
    }

     上面的代码也不是随便写的,我是根据维基百科的定义写出这段代码来验证它。现在运行一下上面的代码,我们看一下结果:

child.doSomething()Thread-5492

father.doSomething()Thread-5492

child.doAnotherThing()Thread-5492

    现在可以验证出 synchronized 是可重入锁了吧!因为这些方法输出了相同的线程名称,表明即使递归使用synchronized也没有发生死锁,证明其是可重入的。

还看不懂?那我就再解释下!

      这里的对象锁只有一个,就是 child 对象的锁,当执行 child.doSomething 时,该线程获得 child 对象的锁,在 doSomething 方法内执行 doAnotherThing 时再次请求child对象的锁,因为synchronized 是重入锁,所以可以得到该锁,继续在doAnotherThing 里执行父类的 doSomething 方法时第三次请求 child 对象的锁,同样可得到。如果不是重入锁的话,那这后面这两次请求锁将会被一直阻塞,从而导致死锁。

        所以在 java 内部,同一线程在调用自己类中其他 synchronized 方法/块或调用父类的 synchronized 方法/块都不会阻碍该线程的执行。就是说同一线程对同一个对象锁是可重入的,而且同一个线程可以获取同一把锁多次,也就是可以多次重入。因为java线程是基于“每线程(per-thread)”,而不是基于“每调用(per-invocation)”的(java中线程获得对象锁的操作是以线程为粒度的,per-invocation 互斥体获得对象锁的操作是以每调用作为粒度的)。

12.3 可重入锁的实现原理?

    看到这里,你终于明白了 synchronized 是一个可重入锁。但是面试官要再问你,可重入锁的原理是什么?

对不起,你又卡壳了。

   那么我现在先给你说一下,可重入锁的原理。具体我们后面再写 ReentrantLock 的时候来验证或看它源码。

     重入锁实现可重入性原理或机制是:每一个锁关联一个线程持有者和计数器,当计数器为 0 时表示该锁没有被任何线程持有,那么任何线程都可能获得该锁而调用相应的方法;当某一线程请求成功后,JVM会记下锁的持有线程,并且将计数器置为 1;此时其它线程请求该锁,则必须等待;而该持有锁的线程如果再次请求这个锁,就可以再次拿到这个锁,同时计数器会递增;当线程退出同步代码块时,计数器会递减,如果计数器为 0,则释放该锁。

13 JDK6 对 synchronized 的锁优化

   1. 背景

          在 JDK 1.6 中对锁的实现引入了大量的优化。 目的是减少锁操作的开销。

   2. 锁优化

            在看下面的内容之间,希望大家对 Mark Word 有个大体的理解。Java 中一个对象在堆中的内存结构是这样的:

Mark Word 是这样的:

    2.1 适应性自旋锁

  自旋锁的思想:让一个线程在请求一个共享数据的锁时执行忙循环(自旋)一段时间,如果在这段时间内能获得锁,就可以避免进入阻塞状态

  自旋锁的缺点:需要进行忙循环操作占用 CPU 时间,它只适用于共享数据的锁定状态很短的场景

      若锁被其他线程长时间占用,会带来许多性能上的开销。所以自旋的次数不再固定。由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

      如果共享数据的锁定状态持续时间较短,切换线程不值得(会有上下文切换),可以利用自旋锁尝试一定的次数。

    2.2 锁消除

      JIT 编译时,会去除不可能存在竞争的锁。通过 JIT 的逃逸分析消除一些没有在当前同步块以外被其他线程共享的数据的锁的保护,通过逃逸分析在 TLAB 来分配对象,这样就不存在共享数据带来的线程安全问题。

    2.3 锁粗化

      减少不必要的紧连在一起的 lock,unlock 操作,将多个连续的锁扩展成一个范围更大的锁

   2.4 偏向锁(重入锁)

      为了在无线程竞争的情况下避免在锁获取过程中执行不必要的 CAS 原子指令,因为 CAS 原子指令虽然相对于重量级锁来说开销比较小但还是存在非常可观的本地延迟(因为 CAS 的底层是利用 LOCK 指令 + cmpxchg 汇编指令来保证原子性的,LOCK 指令会锁总线,其他 CPU 的内存操作将会被阻塞,因为 CPU 架构如果是 CMU 的话,控制信号、数据信号等是通过共享总线传到内存控制器中)。减少同一线程获取锁的代价,省去了大量有关锁申请的操作。

   核心思想

      如果一个线程获得了锁, 那么锁就进入偏向模式,此时 Mark Word 的结构也变为偏向锁结构,当该线程再次请求锁时,无需再做任何同步操作,即获取锁的过程只需要检查 Mark Word 的锁标记位为偏向锁以及当前线程 Id 等于 Mark Word 的 ThreadId 即可,这样就省去了大量有关锁申请的操作

2.5 轻量级锁

这种锁实现的背后基于这样一种假设,即在真实的情况下我们程序中的大部分同步代码一般都处于无锁竞争状态(即单线程执行环境),在无锁竞争的情况下完全可以避免调用操作系统层面的重量级互斥锁(重量级锁的底层就是这样实现的),只需要依靠一条 CAS 原子指令就可以完成锁的获取及释放。当存在锁竞争的情况下,执行 CAS 指令失败的线程将调用操作系统互斥锁进入到阻塞状态,当锁被释放的时候被唤醒。

    2.5.1 加锁的过程

主要分为 3 步:

     1、在线程进入同步块的时候,如果同步对象状态为无锁状态(锁标志为 01),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录的空间,用来存储锁对象目前的 Mark Word 的拷贝。拷贝成功后,虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针,并将 Lock Record 里的 owner 指针指向锁对象的 Mark Word。如果更新成功,则执行 2,否则执行 3。

      2、如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且锁对象的 Mark Word 中的锁标志位设置为 "00",即表示此对象处于轻量级锁定状态,这时候虚拟机线程栈与堆中锁对象的对象头的状态如图所示。

      3、如果这个更新操作失败了,虚拟机首先会检查锁对象的 Mark Word 是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重要量级锁,锁标志的状态值变为 "10",Mark Word 中存储的就是指向重量级锁的指针,后面等待锁的线程也要进入阻塞状态。而当前线程便尝试使用自旋来获取锁。自旋失败后膨胀为重量级锁,被阻塞。

2.5.2 解锁的过程

因为虚拟机线程栈帧中的 Displaced Mark Word 是最初的无锁状态时的数据结构,所以用它来替换对象头中的 Mark Word 就可以释放锁。如果锁已经膨胀为重量级,此时是不可以被替换的,所以替换失败,唤醒被挂起的线程。

3. 心得

   锁膨胀的过程

其实就是对象头中的 Mark Word 数据结构改变的过程。

4. 三种锁的对比

     4.1 偏向锁

           只需要判断 Mark Word 中的一些值是否正确就行。

           只有一个线程访问同步块时,使用偏向锁。

      4.2 轻量级锁

           需要执行 CAS 操作自旋来获取锁。

           如果执行同步块的时间比较少,那么多个线程之间执行使用轻量级锁交替执行。

     4.3 重量级锁

           会发生上下文切换,CPU 状态从用户态转换为内核态执行操作系统提供的互斥锁,所以系统开销比较大,响应时间也比较缓慢。

           如果执行同步块的时间比较长,那么多个线程之间刚开始使用轻量级锁,后面膨胀为重量级锁。(因为执行同步块的时间长,线程 CAS 自旋获得轻量级锁失败后就会锁膨胀)

5. 总结

14 synchronized实现原理?

     synchronized 可以保证方法或者代码块在运行时,同一时刻只有一个进程可以访问,同时它还可以保证共享变量的内存可见性。

Java 中每一个对象都可以作为锁,这是 synchronized 实现同步的基础:

  • 普通同步方法,锁是当前实例对象
  • 静态同步方法,锁是当前类的 class 对象
  • 同步方法块,锁是括号里面的对象

同步代码块:monitorenter 指令插入到同步代码块的开始位置,monitorexit指令插入到同步代码块的结束位置,JVM 需要保证每一个monitorenter都有一个monitorexit与之相对应。任何对象都有一个 Monitor 与之相关联,当且一个 Monitor 被持有之后,他将处于锁定状态。线程执行到monitorenter 指令时,将会尝试获取对象所对应的 Monitor 所有权,即尝试获取对象的锁。

同步方法:synchronized 方法则会被翻译成普通的方法调用和返回指令如:invokevirtual、areturn指令,在 VM 字节码层面并没有任何特别的指令来实现被synchronized修饰的方法,而是在 Class 文件的方法表中将该方法的access_flags字段中的synchronized 标志位置设置为 1,表示该方法是同步方法,并使用调用该方法的对象或该方法所属的 Class 在 JVM 的内部对象表示 Klass 作为锁对象。

synchronized 是重量级锁,在 JDK1.6 中进行优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。

相关文章参考

死磕synchronized底层实现--概论

死磕synchronized底层实现--偏向锁

死磕synchronized底层实现--轻量级锁

死磕synchronized底层实现--重量级锁

15 synchronized锁的升级原理是什么?

 锁的级别从低到高:

         无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁

 锁分级别原因:

      没有优化以前,synchronized 是重量级锁(悲观锁),使用 wait 和 notify、notifyAll 来切换线程状态非常消耗系统资源;线程的挂起和唤醒间隔很短暂,这样很浪费资源,影响性能。所以 JVM 对 synchronized 关键字进行了优化,把锁分为 无锁、偏向锁、轻量级锁、重量级锁 状态。

       锁升级的目的是为了减低锁带来的性能消耗,在 Java 6 之后优化 synchronized 为此方式。

       无锁:没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功,其他修改失败的线程会不断重试直到修改成功。

      偏向锁:对象的代码一直被同一线程执行,不存在多个线程竞争,该线程在后续的执行中自动获取锁,降低获取锁带来的性能开销。偏向锁,指的就是偏向第一个加锁线程,该线程是不会主动释放偏向锁的,只有当其他线程尝试竞争偏向锁才会被释放。

       偏向锁的撤销,需要在某个时间点上没有字节码正在执行时,先暂停拥有偏向锁的线程,然后判断锁对象是否处于被锁定状态。如果线程不处于活动状态,则将对象头设置成无锁状态,并撤销偏向锁;

       如果线程处于活动状态,升级为轻量级锁的状态。

        轻量级锁:轻量级锁是指当锁是偏向锁的时候,被第二个线程 B 所访问,此时偏向锁就会升级为轻量级锁,线程 B 会通过自旋的形式尝试获取锁,线程不会阻塞,从而提高性能。

        当前只有一个等待线程,则该线程将通过自旋进行等待。但是当自旋超过一定的次数时,轻量级锁便会升级为重量级锁;当一个线程已持有锁,另一个线程在自旋,而此时又有第三个线程来访时,轻量级锁也会升级为重量级锁。

        重量级锁:指当有一个线程获取锁之后,其余所有等待获取该锁的线程都会处于阻塞状态。

         重量级锁通过对象内部的监视器(monitor)实现,而其中 monitor 的本质是依赖于底层操作系统的 Mutex Lock 实现,操作系统实现线程之间的切换需要从用户态切换到内核态,切换成本非常高。

      锁状态对比:

 synchronized 锁升级的过程:

  • 在锁对象的对象头里面有一个 threadid 字段,未访问时 threadid 为空
  • 第一次访问 jvm 让其持有偏向锁,并将 threadid 设置为其线程 id
  • 再次访问时会先判断 threadid 是否与其线程 id 一致。如果一致则可以直接使用此对象;如果不一致,则升级偏向锁为轻量级锁,通过自旋循环一定次数来获取锁
  • 执行一定次数之后,如果还没有正常获取到要使用的对象,此时就会把锁从轻量级升级为重量级锁

16 synchronized和volatile关键字的作用

       一旦一个共享变量(类的成员变量、类的静态成员变量)被 volatile  修饰之后,那么就具备了两层语义:

      1)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。

     2)禁止进行指令重排序。

         volatile 本质是在告诉 jvm  当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取;synchronized  则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。

       1.volatile  仅能使用在变量级别;synchronized  则可以使用在变量、方法、和类级别的

       2.volatile  仅能实现变量的修改可见性,并不能保证原子性;synchronized  则可以保证变量的修改可见性和原子性

       3.volatile  不会造成线程的阻塞;synchronized  可能会造成线程的阻塞。

       4.volatile  标记的变量不会被编译器优化;synchronized  标记的变量可以被编译器优化

volatile和synchronized区别

    1、volatile不会进行加锁操作:

         volatile变量是一种稍弱的同步机制在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比synchronized关键字更轻量级的同步机制。

    2、volatile变量作用类似于同步变量读写操作:

         从内存可见性的角度看,写入volatile变量相当于退出同步代码块,而读取volatile变量相当于进入同步代码块。

   3、volatile不如synchronized安全:

         在代码中如果过度依赖volatile变量来控制状态的可见性,通常会比使用锁的代码更脆弱,也更难以理解。仅当volatile变量能简化代码的实现以及对同步策略的验证时,才应该使用它。一般来说,用同步机制会更安全些。

   4、volatile无法同时保证内存可见性和原子性:

         加锁机制(即同步机制)既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性,原因是声明为volatile的简单变量如果当前值与该变量以前的值相关,那么volatile关键字不起作用,也就是说如下的表达式都不是原子操作:“count++”、“count = count+1”。

   当且仅当满足以下所有条件时,才应该使用volatile变量:

       1、对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。

       2、该变量没有包含在具有其他变量的不变式中。

17 Java中的 <<、>>、>>> 是什么?

  1. << 表示左移,不分正负数,低位补0,左移1位表示乘以2
  2. >> 表示右移,如果该数为正,则高位补0,若为负数,则高位补1,右移1位表示除以2
  3. >>> 表示无符号右移,也叫逻辑右移,即若该数为正,则高位补0,而若该数为负数,则右移后高位同样补0

测试代码:

System.out.println("16 >> 3 : " + (16 >> 3));
System.out.println("16 >> 10 : " + (16 >> 10));
System.out.println("1 >> 1 : " + (1 >> 1));
System.out.println("16 >>> 2 : " + (16 >>> 2));
System.out.println("-16 >> 2 : " + (-16 >> 2));
System.out.println("-16 <<2 : " + (-16 <<2));
System.out.println("-16 >>> 2 : " + (-16 >>> 2));

打印结果:

16 <<1 : 32

16 >> 3 : 2

16 >> 10 : 0

1 >> 1 : 0

16 >>> 2 : 4

-16 >> 2 : -4

-16 <<2 : -64

-16 >>> 2 : 1073741820

简单理解:

<< 1  相当于乘以2

>> 1  相当于除以2

>>> 不考虑高位的正负号,正数的 >>> 等同于 >>

18 if-else-if-else与switch的区别

   18.1 if-else-if-else

  1. 适合分支较少
  2. 判断条件类型不单一
  3. 支持取 boolean 类型的所有运算
  4. 满足条件即停止对后续分支语句的执行

  18.2 switch

  1. 适合分支较多
  2. 判断条件类型单一,JDK 1.7 之前仅支持 int 和 enum,JDK 1.7 之后多支持了 String
  3. 没有 break 语句每个分支都会执行

19 while和do-while的区别

  1. while 先判断后执行,第一次判断为 false,循环体一次都不执行
  2. do-while 先执行后判断,最少执行1次

20 this和super关键字的作用

  this

  1. 对象内部指代自身的引用
  2. 解决成员变量和局部变量同名问题
  3. 可以调用成员变量
  4. 不能调用局部变量
  5. 可以调用成员方法
  6. 在普通方法中可以省略 this
  7. 在静态方法当中不允许出现 this 关键字

  super

  1. 代表对当前对象的直接父类对象的引用
  2. 可以调用父类的非 private 成员变量和方
  3. super(); 可以调用父类的构造方法,只限构造方法中使用,且必须是第一条语句

21 switch 是否能作用在 byte 上,是否能作用在 long 上,是否能作用在String上

   Java5 以前 switch(expr)中,expr 只能是  byte、short、char、int。

   从Java 5开始,Java中引入了枚举型,expr也可以是 enum类型。

   从Java 7开始,expr还可以是字符串(String),但是长整型(long)在目前所有的版本中都是不可以的

22  ==和equals区别

equals 和== 最大的区别是一个是方法一个是运算符。

  1. ==:如果比较的对象是基本数据类型,则比较的是数值是否相等;如果比较的是引用数据类型,则比较的是对象的地址值是否相等。
  2. equals()用来比较方法两个对象的内容是否相等

注意:equals 方法不能用于基本数据类型的变量,如果没有对 equals方法进行重写,则比较的是引用类型的变量所指向的对象的地址。

== 解读

对于基本类型和引用类型 == 的作用效果是不同的,如下所示:

•       基本类型:比较的是值是否相同;

•       引用类型:比较的是引用是否相同;

代码示例:

String x = "string";
String y = "string";
String z = new String("string");
System.out.println(x == y); // true
System.out.println(x == z); // false
System.out.println(x.equals(y)); // true
System.out.println(x.equals(z)); // true

代码解读:因为 x 和 y 指向的是同一个引用,所以 == 也是 true,而 new String()方法则重写开辟了内存空间,所以 == 结果为 false,而 equals 比较的一直是值,所以结果都为 true。

equals 解读

equals 本质上就是 ==,只不过 String 和 Integer 等重写了 equals 方法,把它变成了值比较。看下面的代码就明白了。

首先来看默认情况下 equals 比较一个有相同值的对象,代码如下:

package com.junli;
public class Cat {
    public Cat(String name) {
        this.name = name;
    }
    private String name;
    public String getName() {
        return name;
    }
    public void setName(String name) {
        this.name = name;
    }
    public static void main(String[] args) {
        Cat c1 = new Cat("俊仔");
        Cat c2 = new Cat("俊仔");
        System.out.println(c1.equals(c2)); // false
    }
}

输出结果出乎我们的意料,竟然是 false?这是怎么回事,看了 equals 源码就知道了,源码如下:

public boolean equals(Object obj) {
    return (this == obj);
}

原来 equals 本质上就是 ==。

那问题来了,两个相同值的 String 对象,为什么返回的是 true?代码如下:

String s1 = new String("老王");
String s2 = new String("老王");
System.out.println(s1.equals(s2)); // true

同样的,当我们进入 String 的 equals 方法,找到了答案,代码如下:

public boolean equals(Object anObject) {
    if (this == anObject) {
        return true;
    }
    if (anObject instanceof String) {
        String anotherString = (String)anObject;
        int n = value.length;
        if (n == anotherString.value.length) {
            char v1[] = value;
            char v2[] = anotherString.value;
            int i = 0;
            while (n-- != 0) {
                if (v1[i] != v2[i])
                    return false;
                i++;
            }
            return true;
        }
    }
    return false;
}

原来是 String 重写了 Object 的 equals 方法,把引用比较改成了值比较。

总结== 对于基本类型来说是值比较,对于引用类型来说是比较的是引用;而 equals 默认情况下是引用比较,只是很多类重新了 equals 方法,比如 String、Integer 等把它变成了值比较,所以一般情况下 equals 比较的是值是否相等。

23 Java 有没有goto语句

   goto 是 Java 中的保留字,在目前版本的 Java 中没有使用。根据  James Gosling(Java 之父)编写的《The Java Programming Language》一书的附录中给出了一个 Java  关键字列表,其中有  goto 和  const,但是这两个是目前无法使用的关键字,因此有些地方将其称之为保留字,其实保留字这个词应该有更广泛的意义,因为熟悉  C 语言的程序员都知道,在系统类库中使用过的有特殊意义的单词或单词的组合都被视为保留字。

24 &和&&的区别

    &运算符有两种用法:(1)按位与;(2)逻辑与。

    &&运算符是短路与运算。逻辑与跟短路与的差别是非常巨大的,虽然二者都要求运算符左右两端的布尔值都是true  整个表达式的值才是 true。

    &&之所以称为短路运算是因为,如果&&左边的表达式的值是false,右边的表达式会被直接短路掉,不会进行运算。很多时候我们可能都需要用&&而不是&,例如在验证用户登录时判定用户名不是  null 而且不是空字符串,应当写为 username != null &&!username.equals(""),二者的顺序不能交换,更不能用&运算符,因为第一个条件如果不成立,根本不能进行字符串的  equals 比较,否则会产生NullPointerException异常。

注意:逻辑或运算符(|)和短路或运算符(||)的差别也是如此。

25 break和continue的区别

        break 和 continue  都是用来控制循环的语句。

        break用于完全结束一个循环,跳出循环体执行循环后面的语句。

        continue用于跳过本次循环,执行下次循环。

26  访问权限修饰符public、private、protected, 以及不写(默认)时的区别

该题目比较简单,不同的权限修饰符的区别见下表。

修饰符

当前类

同包类

子类

其他包类

public

protected

×

default

×

×

private

×

×

×

   好了,今天的总结就到这里,3万5千多字了,对于基础扎实的朋友们,抽几个小时就可以学习复习完,加油,关注我,持续更新!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值