CAS操作的流程为:
- 读取原值。
- 通过原子操作比较和替换。
虽然比较和替换是原子性的,但是读取原值和比较替换这两步不是原子性的,期间原值可能被其它线程修改。
ABA问题有些时候对系统不会产生问题,但是有些时候却也是致命的。
ABA问题的解决方法是对该变量增加一个版本号,每次修改都会更新其版本号。JUC包中提供了一个类AtomicStampedReference,这个类中维护了一个版本号,每次对值的修改都会改动版本号。
(2)自旋次数过多
CAS操作在不成功时会重新读取内存值并自旋尝试,当系统的并发量非常高时即每次读取新值之后该值又被改动,导致CAS操作失败并不断的自旋重试,此时使用CAS并不能提高效率,反而会因为自旋次数过多还不如直接加锁进行操作的效率高。
(3)只能保证一个变量的原子性
当对一个变量操作时,CAS可以保证原子性,但同时操作多个变量时CAS就无能为力了。
可以封装成对象,再对对象进行CAS操作,或者直接加锁。
四、多线程锁的升级原理是什么?
锁的状态总共有四种,无锁状态、偏向锁、轻量级锁、重量级锁。
随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级到重量级锁。但是锁的升级是单向的,只能升级不能降级。
1、无锁
没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功,其它修改失败的线程会不断重试直到修改成功。
无锁总是假设对共享资源的访问没有冲突,线程可以不停执行,无需加锁,无需等待,一旦发现冲突,无锁策略则采用一种称为CAS的技术来保证线程执行的安全性,CAS是无锁技术的关键。
2、偏向锁
对象的代码一直被同一线程执行,不存在多个线程竞争,该线程在后续执行中自动获取锁,降低获取锁带来的性能开销。偏向锁,指的是偏向第一个加锁线程,该线程是不会主动释放偏向锁的,只有当其他线程尝试竞争偏向锁才会被释放。
偏向锁的撤销,需要在某个时间点上没有字节码正在执行时,先暂停偏向锁的线程,然后判断锁对象是否处于被锁定状态,如果线程不处于活动状态,则将对象头设置成无锁状态,并撤销偏向锁。
如果线程处于活动状态,升级为轻量级锁的状态
3、轻量级锁
轻量级锁是指当锁是偏向锁的时候,被第二个线程B访问,此时偏向锁就会升级为轻量级锁,线程B会通过自旋的形式尝试获取锁,线程不会阻塞,从er提升性能。
当前只有一个等待线程,则该线程将通过自旋进行等待。但是当自旋超过一定次数时,轻量级锁边会升级为重量级锁,当一个线程已持有锁,另一个线程在自旋,而此时第三个线程来访时,轻量级锁也会升级为重量级锁。
注:自旋是什么?
自旋(spinlock)是指当一个线程获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。
4、重量级锁
指当有一个线程获取锁之后,其余所有等待获取该锁的线程都会处于阻塞状态。
重量级锁通过对象内部的监听器(monitor)实现,而其中monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态切换到内核态,切换成本非常高。
5、锁状态对比
偏向锁 | 轻量级锁 | 重量级锁 | |
使用场景 | 只有一个线程进入同步块 | 虽然很多线程,但没有冲突,线程进入时间错开因而并未争抢锁 | 发生了锁争抢的情况,多条线程进入同步块争用锁 |
本质 | 取消同步操作 | CAS操作代替互斥同步 | 互斥同步 |
优点 | 不阻塞,执行效率高(只有第一次获取偏向锁时需要CAS操作,后面只是比对ThreadId) | 不会阻塞 | 不会空耗CPU |
缺点 | 适用场景太局限。若竞争产生,会有额外的偏向锁撤销的消耗 |
长时间获取不到锁空耗CPU | 阻塞,上下文切换,重量级操作,消耗操作系统资源 |
6、锁消除
消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间,如StringBuffer的append是一个同步方法,但是在add方法中的StringBuffer属于一个局部变量,别切不会被其它线程所使用,因此StringBuffer不可能存在共享资源竞争的情景,JVM会自动将其锁消除。
五、Synchronized的特性
1、可重入性
synchronized的锁对象中有一个计数器(recursions变量)会记录线程获得几次锁;
可重入的好处:
(1)可以避免死锁;
(2)可以让我们更好的封装代码;
synchronized是可重入锁,每部锁对象会有一个计数器记录线程获取几次锁,在执行完同步代码块时,计数器的数量会-1,直到计数器的数量为0,就释放这个锁。
2、不可中断性
一个线程获得锁后,另一个线程想要获得锁,必须处于阻塞或等待状态,如果第一个线程不释放锁,第二个线程会一直阻塞或等待,不可被中断;
synchronized 属于不可被中断;
Lock lock方法是不可中断的;
Lock tryLock方法是可中断的;
六、Synchronized保证了原子性、可见性、有序性
1、Synchronized保证原子性
public class Test { private static int number = 0; private static Object obj = new Object(); public static void main(String[] args) throws InterruptedException { Runnable increment = () -> { for (int i = 0;i<1000;i++){ number++; } }; List list = new ArrayList<>(); for (int i = 0; i < 5; i++) { Thread t = new Thread(increment); t.start(); list.add(t); } for (Thread t : list) { t.join(); } System.out.println("number = " + number); } }
Synchronized保证原子性 Runnable increment = () -> { for (int i = 0;i<1000;i++){ synchronized (obj){ number++; } } };
2、Synchronized保证可见性
public class Test1 { public static boolean flag = true; public static void main(String[] args) throws InterruptedException { new Thread(()->{ while (flag){ } }).start(); Thread.sleep(2000); new Thread(()->{ flag = false; System.out.println(“线程修改了变量的值为false”); }).start(); } }
volatile即可解决这个问题!
public static volatile boolean flag = true;
Synchronized保证可见性
Synchronized保证可见性的原理,执行Synchronized时,会对应lock原子操作会刷新工作内存中共享变量的值。
3、Synchronized保证有序性
有序性:即程序执行的顺序按照代码的先后顺序执行。
在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。
在Java里面,可以通过volatile关键字来保证一定的“有序性”。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻只有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。
七、synchronized的三种应用方式
1、修饰普通方法
作用于当前方法加锁,进入同步代码前要获得当前实例的锁。
(1)synchronized保证线程安全
(2)synchronized什么情况下无法保证线程安全
(3)这时synchronized修饰在静态方法上,就可以解决这个问题了。
start()、run()、join()的区别:
start():线程不会立即启动。相当于是在就绪队列里面;
run():启动线程;
join():主要作用是同步,它可以使得线程之间的并行执行变为串行执行。
join方法的作用:
在A线程中调用了B线程的join方法,表示只有当B线程执行完毕后,A线程才能继续执行。注意调用的join方法是没有传参的,join方法其实可以传递一个参数给它,如果A线程中掉用B线程的join(10),则表示A线程会等待B线程执行10毫秒,10毫秒过后,A、B线程并行执行。需要注意的是,jdk规定,join(0)的意思不是A线程等待B线程0秒,而是A线程等待B线程无限时间,直到B线程执行完毕,即join(0)等价于join()。
2、修饰静态方法
作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁。
但我们应该意识到这种情况下可能会发现线程安全问题(操作了共享静态变量i)。
3、修饰同步代码块
指定加锁对象,对给定对象加锁,进入同步代码前要获得给定对象的锁。
除了使用关键字修饰实例方法和静态方法外,还可以使用同步代码块,在某些情况下,我们编写的方法体可能比较大,同时存在一些比较耗时的操作,而需要同步的代码又只有一小部分,如果直接对整个方法进行同步操作,可能会得不偿失,此时我们可以使用同步代码块的方式对需要同步的代码进行包裹,这样就无需对整个方法进行同步操作了,同步代码块的使用示例如下:
从代码可以看出,将synchronized作用域一个给定的实例对象instance,即当前实例对象就是锁对象,每次当线程进入synchronized包裹的代码块时就会要求当前线程持有instance实例对象锁,如果当前有其它线程正持有该对象锁,那么新到的线程必须等待,这样也就保证了每次只有一个线程执行i++操作。当然除了使用instance作为对象外,还可以使用this对象(代表当前实例)或者当前类的class对象作为锁,如下:
//this,当前实例对象锁 synchronized(this){ for(int j=0;j<1000000;j++){ i++; } } //class对象锁 synchronized(AccountingSync.class){ for(int j=0;j<1000000;j++){ i++; } }
八、理解Java对象头与Monitor
在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。
实例数据:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。
内存填充:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。
Java头对象是是实现synchronized锁对象的基础,一般而言,synchronized使用的锁对象是存储在Java对象头里的,jvm中采用2个字节来存储对象头(数组是三个字节),其主要结构是由Mark Word和Class Metadata Address组成,其结构说明如下表:
| 头对象结构 | 说明 |
| — | — |
| Mark Word | 存储对象的hashCode、锁信息或分代年龄或GC标志等信息 |
| Class Metadata Address | 类型指针指向对象的类元数据,JVM通过这个指针确定该对象是哪个类的实例。 |
| 锁状态 | 25bit | 4bit | 1bit是否是偏向锁 | 2bit 锁标志位 |
| — | — | — | — | — |
| 无锁状态 | 对象HashCode | 对象分代年龄 | 0 | 01 |
由于对象头的信息是与对象自身定义的数据没有关系的额外存储成本,因此考虑到JVM的空间效率,Mark Word 被设计成为一个非固定的数据结构,以便存储更多有效的数据,它会根据对象本身的状态复用自己的存储空间,如32位JVM下,除了上述列出的Mark Word默认存储结构外,还有如下可能变化的结构: