并发编程
问题引入
什么是单例模式?
Gof23设计模式之一,单例模式是保证一个类只有一个实例
好处:
- 节约内存资源
- 便于维护
- 满足某些特定的业务需要,如:太阳类只有一个实例
怎么实现单例模式?
1) 将构造方法私有化
2) 定义静态的实例
3) 定义静态方法返回实例
单例模式分为几种?
饿汉式
定义静态实例时就创建对象
懒汉式
定义静态实例不创建对象,调用静态方法判断对象为空再创建对象
/**
* 单例模式
*/
public class MySingleton {
//静态实例
private static MySingleton instance = null;
//私有构造方法
private MySingleton(){
System.out.println("执行了构造方法");
}
//返回静态实例的方法
public static MySingleton getInstance(){
if(instance == null){ //问题所在行
instance = new MySingleton();
}
return instance;
}
public static void main(String[] args) {
//模拟多线程调用单例模式
for (int i = 0; i < 100; i++) {
new Thread(() -> {
MySingleton instance = MySingleton.getInstance();
System.out.println(instance.hashCode());
}).start();
}
}
}
出现问题:
多线程环境下创建多个对象
分析问题:
第18行代码判断对象为空,A线程进入,判断对象为空,准备创建对象,B线程抢占CPU进入,A线程阻塞,B线程判断为空,创建对象,A线程获得cpu后继续创建对象
多线程的调度是抢占式的,无法保证程序代码能完整执行
并发编程的三大特性
-
原子性
线程执行的程序指令能完整的执行,全部执行,或全部不执行
-
可见性
对于某个数据,某个线程进行的修改,其它所有线程都可见
-
有序性
线程中的程序指令是按最初编写的顺序执行的
原子性
线程安全问题:多线程的调度是抢占式的,无法保证程序代码能完整执行,则会出现数据不一致的问题
解决线程安全问题:通过上锁机制
synchronized关键字
翻译过来是:同步
可以给方法或代码上锁,让线程有序的完整的执行
方法上锁(同步方法),当第一个线程执行方法时,持有锁,其它线程无法进入方法,线程执行完后,自动释放锁,其它线程才能进入
public synchronized 返回值 方法名(...){
}
代码上锁(同步代码块)
public 返回值 方法名(...){
synchronized(锁对象){
代码块....
}
}
任何成员变量对象都可以作为锁对象
- 可以创建新对象作为锁
- 成员方法还可以使用this作为锁
- 静态方法可以使用 类名.class 作为锁
同步方法如果是非静态的,默认将this作为锁,如果是静态,默认将类名.class作为锁
synchronized能保证线程安全,单会带来性能上的损失
双检锁单例模式
/**
* DCL double check lock 双检锁单例模式
*/
public class MySingleton {
//静态实例
private static volatile MySingleton instance = null;
//私有构造方法
private MySingleton(){
System.out.println("执行了构造方法");
}
//返回静态实例的方法
public static MySingleton getInstance(){
//判断当对象不为空时,不执行同步块,从而提高性能
if(instance == null) {
//保证判断和创建对象原子执行
synchronized (MySingleton.class) {
if (instance == null) {
instance = new MySingleton();
}
}
}
return instance;
}
}
为什么synchronized会降低性能
synchronized属于互斥锁,一个线程持有锁时,会阻塞其它线程
线程的两个状态:
-
用户态
JVM能够管理的状态
-
内核态
JVM不能管理,由操作系统管理
上下文切换
线程在抢占资源时,发现资源上锁,线程从用户态转为内核态进行等待,线程获得锁,重新执行前会从内核态转换为用户,转换过程会降低性能,
切换的过程中需要保存或读取程序计数器的代码行数和寄存器的数据,比较消耗时间
synchronized的优化
jdk1.6对synchronized关键字进行了优化
-
锁消除
如果jvm发现同步方法或同步块中没有线程竞争的资源,会消除锁
public class Demo1 { public synchronized void test(){ System.out.println("hello world"); } }
-
锁膨胀
如果jvm发现在大量循环中使用锁,会优化将锁放到循环外部
public class Demo1 { public void test2(){ for (int i = 0; i < 100; i++) { synchronized (this){ //...... } } //jvm优化 --> 锁膨胀 // synchronized (this){ // for (int i = 0; i < 100; i++) { // //..... // } // } } }
-
锁升级
锁的状态:
-
无锁
没有任何线程竞争情况下,不会加锁
-
偏向锁
如果只有一个线程使用锁,锁会在对象头中记录线程的id,如果是这个线程就直接放行
-
轻量级锁
出现少量竞争情况下,会通过CAS乐观锁机制进行线程的调度,不会出现上下文切换,会出现自旋等待(消耗cpu)
-
重量级锁
出现大量竞争情况下,会转换为重量级锁(互斥锁),线程出现上下文切换
synchronized在1.6后,上锁的过程叫锁升级: 无锁 —> 偏向锁 —> 轻量级锁 —> 重量级锁
只能升不能降级
-
synchronized的原理
自动上锁和释放锁实现的原理,一旦给方法或代码块加synchronized,JVM会启动Monitor监视器监控上锁的代码,线程进入后,监视器中计数器加1,其它线程进入时,监视器的计数器不为0,就不允许其它线程进入,线程执行完代码后,计数器减1,监视器再让其它线程进入
javap -c 类名: 显示编译代码
ReentantLock 类
是java.util.concurrent.lock 包提供工具类
ReentantLock 重入锁(递归锁)
重入锁: 发生方法递归情况下,持有锁的线程,可以重新持有该锁
非重入锁:方法递归的情况,持有锁的线程,不能重新持有该锁
创建方法1:
ReentantLock lock = new ReentrantLock();
创建方法2:
ReentantLock lock = new ReentrantLock(true/false);
布尔值由于指定该锁是公平或非公平锁,true公平,false非公平(默认)
公平锁: 会维护等待线程的队列,锁释放后,优先让等待时间长的线程拿到锁,降低线程的饥饿,也会降低程序的效率
非公平锁: 所有线程都去抢锁,谁抢到谁执行,有的线程会一直饥饿,效率高
使用方法:需要手动上锁和释放锁
lock.lock(); //上锁
try{
上锁的业务代码
}finally{
lock.unlock(); //释放锁
}
使用案例
public class LockDemo{
//创建重入锁
private ReentrantLock lock = new ReentrantLock();
public void testLock(){
//上锁
lock.lock();
try {
for (int i = 0; i < 100; i++) {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "--->" + i);
}
}finally {
//释放锁
lock.unlock();
}
}
public static void main(String[] args) {
LockDemo lockDemo = new LockDemo();
for (int i = 0; i < 5; i++) {
new Thread(() -> {
lockDemo.testLock();
}).start();
}
}
}
主要方法
方法名 | 作用 |
---|---|
lock() | 上锁 |
unlock() | 释放锁 |
getQueuedLength() | 获得公平锁线程排队长度 |
boolean tryLock() | 上锁并获得上锁是否成功 |
boolean tryLock(long,TimeUnit) | 在一定时间内上锁并获得上锁是否成功 |
Condition newCondition() | 获得条件对象 |
boolean isFair() | 是否公平锁 |
面试题:synchronized 和 ReentrantLock的区别
1) 上锁机制不同:synchronized 是JVM自动上锁和释放锁,ReentrantLock需要代码手动上锁和释放锁
2) 锁的类型不同:synchronized 是非公平锁,ReentrantLock 可以设置公平锁或非公平锁
3) 性能不同:ReentrantLock高于synchronized
4) 功能不同:ReentrantLock提供非常丰富的方法,功能大大强于synchronized
5) 编程难度不同:synchronized 更加简单,ReentrantLock更复杂
可见性
对于共享资源,一个线程修改后,其它的线程可以看到修改后的状态
原因:CPU有多个内核,每个内核中都有独立的存储单元(寄存器、L1L2L3缓存),每个内核都执行线程,线程中的数据会从主内存中缓存到不同的内核中,线程修改一个内核中的数据,另一个内核不能及时修改
volatile关键字的作用:用于修饰变量,保证变量的可见性
被修饰的变量只保存在主内存中,所有线程都直接读写主内存,避免了可见性问
MESI 内存一致性协议
面试题: volatile和synchronized的区别
1) synchronized能实现原子性、可见性、有序性;volatile能实现可见性和有序性
2) synchronized更加重量级,消耗更多资源;volatile更加轻量级
3) synchronized用在方法或代码块上;volatile 只能用于变量
有序性
程序指令是按编写的顺序执行的
JVM会对程序指令进行优化,可能导致程序指令重排序
做菜: 买菜、洗碗、洗菜、切菜、炒菜 ----> 买菜、切菜、洗菜、炒菜、洗碗
Object obj = new Object();
创建对象的过程:
1) 分配内存创建对象
2) 对属性初始化
3) 将内存地址赋值给引用
指令重排可能出现: 1) 3) 2) 可能将没有完成初始化的对象交给用户,导致问题
//静态实例 volatile 防止指令重排
private static volatile MySingleton instance = null;
原子类
变量的++和–
分为三个步骤:
-
读取原始值
-
计算新值
-
保存为新值
多线程环境下,不能完整执行,可能导致线程安全问题
public class AtomicDemo {
static int count = 0;
public static void main(String[] args) {
for (int i = 0; i < 10000; i++) {
new Thread(() -> {
count++; // 1) 读取原始值 2) 计算原始值+1 3) 保存为新值
}).start();
}
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("count=" + count);
}
}
解决方案:
1) 上锁 (效率太低)
2) 使用原子类
常用的原子类:
- AtomicInteger 原子整数
- AtomicLong 原子长整数
- AtomicBoolean 原子布尔
AtomicInteger 用法
AtomicInteger count = new AtomicInteger(初始值);
自增: getAndIncrement() 或 IncrementAndGet()
自减: getAndDecrement() 或 DecrementAndGet()
原子类实现的原理:
使用乐观锁实现的
悲观锁,认为线程竞争比较激烈,会给代码上锁,线程会出现上下文切换,效率比较低
乐观锁,认为线程竞争比较少,不给代码上锁,线程不会出现上下文切换,效率高
Java中的乐观锁机制是CAS: 比 较和交换 Compare And Swap
对变量进行修改时,先读取变量的原始值,要修改时,再读取变量当前内存中的值,如果当前值和原始值相同,就用新值覆盖原始值;如果当前值和原始值不同,就表示出现其它线程修改了该值,放弃修改。
CAS机制可能出现ABA问题:假设原始值是A,线程1将其改为B,线程2将其改为A,前面的线程发现值相同,以为没有线程并发问题出现
如何解决ABA问题:引入版本号机制,给变量加版本号,每次修改版本号加1,比较时判断原始值和当前值是否相同还要判断版本号是否改变