Volatile关键字
如何保证变量的可见性
在Java中,Volatile关键字可以保证变量的可见性,如果我们将变量声明为volatile,这就指示JVM,这个变量是共享且不稳定的,**每次使用它都到主存中进行读取(禁止读取本地内存的共享变量副本)**
。
volatile关键字能保证数据的可见性,但是不能保证数据的原子性。sychronized关键字两者都能保证。
如何禁止指令重排序
在Java中,volatile关键字除了保证变量的可见性,还有一个重要的作用就是防止JVM指令重排序。如果,我们将变量声明为volatile,在对这个变量进行读写操作的时候,会通过插入特定的内存屏障的方式来禁止指令重排序,下面结合单例模式进行讲解。
双重校验锁实现对象单例(线程安全):
public class Singleton {private volatile static Singleton uniqueInstance;private Singleton() {}public static Singleton getUniqueInstance() {//先判断对象是否已经实例过,没有实例化过才进入加锁代码if (uniqueInstance == null) {//类对象加锁synchronized (Singleton.class) {if (uniqueInstance == null) {uniqueInstance = new Singleton();}}}return uniqueInstance;}
}
uniqueInstance 采用volatile关键字修饰也是很有必要的,uniqueInstance = new Singleton();这段代码其实是分为三步执行的:
- 为uniqueInstance分配内存空间
- 初始化uniqueInstance
- 将uniqueInstance指向分配的内存空间。
但是由于JVM具有指令重排的特性,执行顺序有可能变成了1->3->2,指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致线程获得一个还没有初始化的实例。例如,线程T1执行了1和3.此时T2调用getUniqueInstance()发现uniqueInstance不为空,因此返回uniqueInstance,但此时的uniqueInstance尚未被初始化。
volatile可以保证原子性吗
volatile关键字可以保证变量的可见性,但不能保证对变量的操作是原子性的。
public class VolatileAtomicityDemo {public volatile static int inc = 0;public void increase() {inc++;}public static void main(String[] args) throws InterruptedException {ExecutorService threadPool = Executors.newFixedThreadPool(5);VolatileAtomicityDemo volatileAtomicityDemo = new VolatileAtomicityDemo();for (int i = 0; i < 5; i++) {threadPool.execute(() -> {for (int j = 0; j < 500; j++) {volatileAtomicityDemo.increase();}});}// 等待1.5秒,保证上面程序执行完成Thread.sleep(1500);System.out.println(inc);threadPool.shutdown();}
}
在这段代码中,5个线程分别进行了500次操作,那么最终inc的值应该输出2500.但是实际运行会发现每次输出的 结果都是小于2500的。因为volatile关键字虽然可以保证内存的可见性,但是无法保证内存的原子性。代码中的inc++操作实际是一个复合操作,包括三步:
- 读取inc的值
- 对inc加1
- 将inc的值写回内存
volatile无法保证这三个操作的原子性,有可能出现下面的情况:
- 线程1对inc进行读取操作之后,还未对其进行修改,线程2又读取了inc的值并对其进行修改,再将inc的值写回内存。
- 线程2操作完毕后,线程1对inc的值进行修改,在将inc的值写回内存。
这就导致了两个线程分别对inc进行了一次自增操作后,inc的值实际上只增加了1.
乐观锁和悲观锁
悲观锁
什么是悲观锁
悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁。这样其他线程想要获取到这个资源就会阻塞直到锁被上一个持有者释放,也就是说,共享资源每次只给一个线程使用,其他线程阻塞,用完后再把资源转让给其他线程。
Sychrnoized和ReentrantLock等独占锁都属于悲观锁的思想。
public void performSynchronisedTask() {synchronized (this) {// 需要同步的操作}
}
private Lock lock = new ReentrantLock();
lock.lock();
try {// 需要同步的操作
} finally {lock.unlock();
}
高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销,并且,悲观锁可能会存在死锁问题,影响代码的正常运行。
Sychronized关键字
Sychronized是什么?有什么用?
Sychronized是Java中的一个关键字,主要解决的是多个线程之间访问资源的同步性,可以保证被他修饰的方法或者代码块在任意时刻只能有一个线程执行。
在Java早期版本中,Sychronized属于重量级锁,效率低下。这是因为监视器锁是依赖于底层的操作系统的Mutex Lock来实现的,Java的线程是映射到操作系统的原生线程上的,如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对较长的时间成本。在Java6之后,sychronized引入了大量的优化如
自旋锁、锁消除、锁粗化、偏向锁、轻量级锁
等技术来减少操作的开销,这些优化让sychronized性能提升了很多。
偏向锁:由于偏向锁增加了JVM的复杂性,同时也没有为所有应用都带来性能提升,在JDK15中,偏向锁被默认关闭(可以使用-XX:+UseBiasedLocking 启用偏向锁),JDK18,偏向锁已经被彻底废弃。
如何使用Sychronized
Sychronized关键字使用方式有下面几种:
- 修饰实例方法
- 修饰静态方法
- 修饰代码块
1. 修饰实例方法(锁当前对象实例)
给当前对象实例加锁,进入同步代码前要获得当前对象实例的锁。
synchronized void method() {//业务代码
}
2. 修饰静态方法(锁当前类)
给当前类加锁,会作用于类的所有对象实例,进入同步代码前要获得当前类的锁 。
这是因为静态成员不属于任何一个实例对象,归整个类所有,不依赖于类的特定实例,被类的所有实例共享。
synchronized void method() {//业务代码
}
静态synchronized方法和非静态synchronized方法之间的调用不互斥,即如果一个线程A调用一个实例对象的非静态synchronized方法,而线程B调用这个实例对象所属类的静态synchronized方法,是允许的,不会发生互斥现象,因为访问静态synchronized方法占用的锁是当前类的锁,访问非静态synchronized占用的锁是当前实例对象锁。
3. 修饰代码块(锁指定对象/类)
- synchronized(object) 表示进入同步代码库前要获得给定对象的锁。
- synchronized(类.class) 表示进入同步代码前要获得给定class的锁。
synchronized (this) {// this 锁的是当前对象,并非class//业务代码
}
总结:
- synchronized关键字加到static静态方法和synchronized(class)代码块上都是给Class类上锁
- synchronized关键字加到实例方法上是给对象实例上锁。
- 尽量不要使用synchronized(String a) 因为JVM中,字符串常量池具有缓存功能。
构造方法可以用sychronized修饰吗
构造方法不能使用sychronized修饰,不过,可以在构造方法内部使用sychronized代码块。
另外,构造方法本身是线程安全的,但如果在构造方法中涉及到共享资源的操作,就需要采取适当的同步措施来保证整个构造过程的线程安全。
sychronized底层原理
synchronized 同步语句块的情况
通过使用javap -c -s -v -l SynchronizedDemo.class,得到如下的编译文件
public class SynchronizedDemo {public void method() {synchronized (this) {System.out.println("synchronized 代码块");}}
}
从上面可以看出:synchronized同步语句块的实现使用的是monitorenter和monitorexit指令,其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块结束位置。
上面的字节码中包含一个monitorenter和两个monitorexit指令,这是为了保证锁在同步代码块代码正常执行以及异常的两种情况下都能正确释放。
当执行monitorenter指令时,线程试图获取锁也就是获取 对象监视器monitor的持有权。
在Java虚拟机中,monitor是基于C++实现的,由ObjectMonitor实现的,每个对象中都内置了ObjectMonitor。
另外,wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException异常。
在执行monitorenter时,会尝试获取对象的锁,如果锁的计数器为0则表示锁可以被获取,获取锁后将计数器加1.
对象锁的持有者线程可以执行monitorexit指令来释放锁,在执行monitorexit指令后,将锁的计数器减1.表示锁被释放,其他线程可以尝试获取锁。
如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另一个线程释放为止。
synchronized 修饰方法的情况
public class SynchronizedDemo2 {public synchronized void method() {System.out.println("synchronized 方法");}
}
synchronized修饰的方法并没有monitorenter和monitorexit指令,取而代之的是ACC_SYNCHRONIZED标识,这个标识指明该方法是一个同步方法。JVM通过ACC_SYNCHRONIZED访问标志来辨别一个方法是否为同步方法,从而执行相应的同步调用。
如果是实例方法,JVM会尝试获取实例对象的锁;如果是静态方法,JVM会尝试获取当前class的锁。
synchronized底层实现原理总结:
- synchronized同步语句块的实现使用的是monitorenter和monitorexit指令,其中monitorenter指明同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置。
- synchronized修饰的方法并没有monitorenter和monitorexit指令,取而代之的是ACC_SYNCHRONIZED标识,这个标识指明该方法是一个同步方法。
但是两者的本质都是获取对象监视器monitor
。
synchronized和volatile的区别
- volatile关键字是线程同步的轻量级实现,所以性能好,但是volatile关键字只能用于变量,而synchronized关键字可以修饰方法以及代码块。
- volatile保证的是数据的可见性,但不能保证数据的原子性,而synchronized都能保证。
- volatile关键字解决的是多个线程之间共享变量的可见性,而synchronized保证的是多个线程之间访问资源的互斥以及同步。
ReentrantLock
ReentrantLock是什么
ReentrantLock实现了Lock接口,是一个可重入且独占式的锁,和synchronized关键字类似,不过ReentrantLock更灵活、强大、增加了轮询、超时、中断、公平锁和非公平锁等高级功能。
ReentrantLock里面有一个内部类Sync,Sync继承AQS(AbstractQueuedSynchronizer),添加锁和释放锁的大部分操作都是在Sync中实现的。Sync有公平锁FairSync和非公平锁 NonFairSync 两个类。
ReentrantLock 默认使用非公平锁,也可以通过构造器来显式的指定使用公平锁。
// 传入一个 boolean 值,true 时为公平锁,false 时为非公平锁
public ReentrantLock(boolean fair) {sync = fair ? new FairSync() : new NonfairSync();
}
公平锁和非公平锁有什么区别
- 公平锁:锁被释放后,先申请的线程先得到锁,性能较差,因为公平锁为了保证时间上的绝对顺序,上下文切换更频繁。
- 非公平锁:锁被释放之后,后申请的线程可能会先获取到锁,是随机或者按照其他优先级排序的,性能更好,但可能会出现饥饿问题。
synchronized 和 ReentrantLock 有什么区别
1. 两者都是可重入锁。2. synchronized依赖于JVM,而ReentrantLock依赖于API3. ReentrantLock功能更强大,提供了很多高级特性。
1.两者都是可重入锁
可重入锁也叫递归锁,指的是线程可以再次获取自己内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其想要再次获取这个对象锁的时候还是可以获取的。如果不可重入锁的话,就会造成死锁。
JDK 提供的所有现成的 Lock 实现类,包括 synchronized 关键字锁都是可重入的。
public class SynchronizedDemo {public synchronized void method1() {System.out.println("方法1");method2();}public synchronized void method2() {System.out.println("方法2");}
}
由于synchronized是可重入的,同一个线程在调用method1()时可以直接获取当前对象的锁,执行method2()时可以再次获取这个对象的锁,不会产生死锁问题。假如synchronized是不可重入的,由于该对象的锁已被当前线程持有且无法释放,这就导致线程在执行method2()时获取锁失败,会出现死锁问题。
2. synchronized依赖于JVM,而ReentrantLock依赖于API
synchronized同步语句块的实现使用的是monitorenter和monitorexit指令,其中monitorenter指明同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置。synchronized修饰的方法并没有monitorenter和monitorexit指令,取而代之的是ACC_SYNCHRONIZED标识,这个标识指明该方法是一个同步方法;ReentrantLock是JDK层面实现的,需要lock和unlock方法进行加锁和解锁。
3. ReentrantLock功能更强大,提供了很多高级特性
- 等待可中断:ReentrantLock提供了一种能够中断等待锁的机制,通过lock.lockInterruptibly()来实现这个机制。即正在等待的线程可以选择放弃等待,改为处理其他事情。
- 可实现公平锁:ReentrantLock默认情况是非公平的,可以通过 ReentrantLock类的ReentrantLock(boolean fair)构造方法来指定是否是公平的。
- 可实现选择性通知:synchronized关键字与wait和notify结合可以实现等待/通知机制。ReentrantLock借助于Condition接口和newCondition方法也可以实现。
Condition接口
Condition是JDK1.5之后才有的,具有很好的灵活性,比如可以实现多路通知功能也就是在一个Lock对象中可以创建多个Condition实例(即对象监视器),线程对象可以注册在指定的Condition中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。在使用notify/notifyAll方法进行通知时,被通知的线程是由JVM选择的,用ReentrantLock类结合Condition实例可以实现“选择性通知”。而synchronized关键字相当于整个Lock对象中只有一个Condition实例,所有的线程都注册在他一个身上,如果执行notifyAll时就会通知所有处于等待状态的线程,这样会造成很大的效率问题。而Condition实例的signalAll()方法,只会唤醒注册在该Condition实例中的所有等待线程。
Condition实例
package example;import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;public class ReentrantLockConditionExample {public static final ReentrantLock reentrantLock = new ReentrantLock();public static final Condition condition = reentrantLock.newCondition();public static boolean metCondition = false;public void method1() {reentrantLock.lock();if (!metCondition) {try {System.out.println("线程等待");//await操作释放线程当前持有的锁condition.await();} catch (InterruptedException e) {throw new RuntimeException(e);}}System.out.println("线程被唤醒。。。");reentrantLock.unlock();}public void method2() {reentrantLock.lock();metCondition = true;System.out.println("唤醒线程。。。");//signal之后唤醒等待在condition上的所有线程,被唤醒的线程会尝试重新获取锁,获取到所以后继续执行condition.signalAll();reentrantLock.unlock();}
}class Test {public static void main(String[] args) throws InterruptedException {ReentrantLockConditionExample example = new ReentrantLockConditionExample();Thread waitThread1 = new Thread(() -> {example.method1();});Thread waitThread2 = new Thread(() -> {example.method1();});Thread signalThread = new Thread(() -> {example.method2();});waitThread1.start();waitThread2.start();Thread.sleep(5000);signalThread.start();}
}
可中断锁和不可中断锁有什么区别
- 可中断锁:获取锁的过程中可以被中断,不需要一直等到获取锁之后才能进行逻辑处理。ReentrantLock就是可中断锁。
- 不可中断锁:一旦线程申请了锁,就只能等待拿到锁之后才能进行其他逻辑处理,synchronized就是属于不可中断锁。
乐观锁
什么是乐观锁
乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停的执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源是否被其他线程修改了(具体方法可以使用版本号机制或者CAS算法)。
总结:高并发的场景下,乐观锁相比悲观锁来说,不存在锁竞争造成线程阻塞,也不会有死锁问题。在性能上往往更胜一筹。但是,如果冲突频繁发生(写占比非常多的情况下),会频繁失败和重试,这样同样非常影响性能,导致CPU飙升。
理论上来说:
- 悲观锁通常多用于写比较多的情况,这样可以避免频繁失败和重试影响性能,悲观锁的开销是固定的。
- 乐观锁通常多用于写比较少的情况,这样可以避免频繁加锁影响性能。
如何实现乐观锁
乐观锁一般使用版本号机制或者CAS算法实现。
版本号机制
一般是在数据表中加上一个数据版本号version字段,表示数据被修改的次数。当数据被修改时,version值会加1. 当线程A要更新数据值时,在读取数据的同时也会读取version值,在提交更新时,若刚才读取到的version值为当前数据库中version值相等时才更新,否则放弃此次操作。
CAS算法
CAS全称是Compare And Swap(比较和交换),用于实现乐观锁,CAS的思想就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。
CAS是一个原子操作,底层依赖于一条CPU的原子指令。
原子操作:即最小不可拆分的操作,也就是说操作一旦开始,就不能被打断,直到操作完成。
CAS涉及到三个操作数
- V 要更新的变量值
- E 预期值
- N 拟写入的新值
当且仅当V的值等于E时,CAS通过原子方式用新值N来更新V的值。如果不等,说明已经有其他线程更新了V,则当前线程放弃更新。
举例说明:
线程A要修改变量 i 的值为6,i原值为1(假设不存在ABA问题,V =1,E=1,N=6)
- i与1进行比较,如果相等,则说明没被其他线程修改,可以被设置为6.
- i与1进行比较,如果不相等,则说明没被其他线程修改,当前线程放弃更新,CAS操作失败。
当多个线程同时使用CAS操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。
CAS算法存在的问题
1. ABA问题
如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到仍然是A值,那我们就能说明它没有被其他线程修改过了吗
?答案是不能,因为在这段时间他的值可能被修改为其他值,然后又改回了A,那CAS操作就会误认他从来没有被修改过,这就是CAS的“ABA”问题。
ABA问题的解决思路是在前面追加上版本号或者时间戳,JDK1.5以后的AtomicStampedReference类就是用来解决ABA问题的,其中的CompareAndSet()方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
2. 循环时间开销大
CAS经常会用到自旋
操作来进行重试,也就是不成功就一直循环执行直到成功
,如果长时间不成功,会给CPU带来非常大的执行开销。
如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用:
- 可以延迟流水线执行指令,使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。
- 可以避免在退出循环的时候因为内存顺序冲突而引起CPU流水线被清空,从而提高CPU的执行效率。
3. 只能保证一个共享变量的原子操作
CAS只对单个共享变量有效,当操作涉及多个共享变量时CAS无效,但是从JDK1.5开始,提供了AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作,所以我们可以使用锁或者AtomicReference类把多个共享变量合并成一个共享变量来操作。