1 什么是线程安全问题
多个线程同时访问同一个资源(变量、对象、文件等)时就可能出现线程安全问题。
多个线程执行时是抢占式的,一个线程在执行一个操作时(调用方法,更新变量),可能会被其他线程打断,导致操作没有完全完成,可能会造成数据出现不一致的情况。
2.线程安全问题出现的条件
多个线程
同一个时间执行
同一段指令或修改同一个变量
线程安全案例:银行转账
package com.hopu.thread;
import java.util.Random;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class BankDemo {
//模拟100银行账号
private int [] accounts=new int[100];
// Lock lock=new ReentrantLock();
//初始化账号
{
for (int i = 0; i < accounts.length; i++) {
accounts[i]=10000;
}
}
//模拟转账
public void transfer(int from,int to,int money){
if (accounts[from]<money){
throw new RuntimeException("余额不足!");
}
// lock.lock();
// try {
accounts[from]-=money;
System.out.printf("从%d转出%d%n",from,money);
accounts[to]+=money;
System.out.printf("向%d转入%d%n",to,money);
System.out.println("银行总额为:"+getTotal());
// }finally {
// lock.unlock();
// }
}
//计算总额
public int getTotal(){
int sum=0;
for (int i = 0; i < accounts.length; i++) {
sum+=accounts[i];
}
return sum;
}
public static void main(String[] args) {
BankDemo bank=new BankDemo();
Random random=new Random();
for (int i = 0; i < 10; i++) {
new Thread(()->{
int from = random.nextInt(100);
int to=random.nextInt(100);
int money =random.nextInt(1000);
bank.transfer(from,to,money);
}).start();
}
}
}
执行效果:
从69转出587
向51转入587
银行总额为:997218
从81转出317
向53转入317
从47转出46
向34转入46
从18转出561
向56转入561
从55转出913
向53转入913
从8转出945
向80转入945
银行总额为:999055
银行总额为:998142
银行总额为:997581
Process finished with exit code 0
我们通过执行的结果发现问题,银行的总账的数据不是固定的,数据有误,为什么会造成这种现象。因为在多线程的案例中,某个线程A在执行转账操作的时候,就是转账转完了,收款方还没有接收到款, cpu就被其他的线程B抢占,导致之前的线程A还没有完成一整套的转账业务。数据就会产生错误。
线程安全的解决办法
可以通过上锁机制解决,主要是将资源上锁,让线程将业务完整的完成,不让其他线程介入。
几种不同上锁方法:
- 同步方法
- 同步代码块
- 同步锁
同步方法
给方法添加synchronized关键字 作用是给整个方法上锁
当前线程调用方法后,方法上锁,其它线程无法执行,调用结束后,释放锁
/**
* 模拟转账
*/
public synchronized void transfer(int from,int to,int money){
if(accounts[from] < money){
throw new RuntimeException("余额不足");
}
accounts[from] -= money;
System.out.printf("从%d转出%d%n",from,money);
accounts[to] += money;
System.out.printf("向%d转入%d%n",to,money);
System.out.println("银行总账是:" + getTotal());
}
同步代码块
给一段代码上锁
synchronized(锁对象){
代码
}
锁对象,可以对当前线程进行控制,如:wait等待、notify通知;
任何对象都可以作为锁,对象不能是局部变量
//同步代码块
synchronized (lock) {
accounts[from] -= money;
System.out.printf("从%d转出%d%n", from, money);
accounts[to] += money;
System.out.printf("向%d转入%d%n",to,money);
System.out.println("银行总账是:" + getTotal());
}
synchronized的基本的原理:
一旦代码被synchronized包含,JVM会启动监视器(monitor)对这段指令进行监控线程执行该段代码时,monitor会判断锁对象是否有其它线程持有,如果其它线程持有,当前线程就无法执行,等待锁释放
如果锁没有其它线程持有,当前线程就持有锁,执行代码
同步锁
在java.concurrent并发包中的
Lock接口
基本方法:
- lock() 上锁
- unlock() 释放锁
常见实现类
- ReentrantLock 重入锁
- WriteLock 写锁
- ReadLock 读锁
- ReadWriteLock 读写锁
使用方法:
- 定义同步锁对象(成员变量)
- 上锁
- 释放锁
//成员变量
Lock lock = new ReentrantLock();
//方法内部上锁
lock.lock();
try{
代码...
}finally{
//释放锁
lock.unlock();
}
三种锁对比:
-
粒度
同步代码块/同步锁 < 同步方法
-
编程简便
同步方法 > 同步代码块 > 同步锁
-
性能
同步锁 > 同步代码块 > 同步方法
-
功能性/灵活性
同步锁(有更多方法,可以加条件) > 同步代码块 (可以加条件) > 同步方法
悲观锁和乐观锁
悲观锁
认为线程的安全问题非常容易出现,会对代码上锁
前面所讲的锁机制都属于悲观锁
悲观锁的锁定和释放需要消耗比较多的资源,降低程序的性能
乐观锁
认为线程的安全问题不是非常常见的,不会对代码上锁
有两种实现方式:
-
版本号机制
利用版本号记录数据更新的次数,一旦更新版本号加1,线程修改数据后会判断版本号是否是自己更新的次数,如果不是就不更新数据。
-
CAS (Compare And Swap)比较和交换算法
- 通过内存的偏移量获得数据的值
- 计算出一个预计的值
- 将提交的实际值和预计值进行比较,如果相同执行修改,如果不同就不修改
悲观锁和乐观锁对比
- 悲观锁更加重量级,占用资源更多,应用线程竞争比较频繁的情况,多写少读的场景
- 乐观锁更加轻量级,性能更高,应用于线程竞争比较少的情况,多读少写的场景
public class AtomicDemo {
static int count = 0;
public static void main(String[] args) {
for (int i = 0; i < 100000; i++) {
new Thread(() ->{
count++;
}).start();
}
System.out.println(count);
}
}
问题:多线程同时执行++操作,最后结果少了
分析:
count++ 分解为三个指令:
- 从内存中读取count的值
- 计算count+1的值
- 将计算结果赋值给count
这三个指令不是原子性的,A线程读取count值10,加1后得到11,准备赋值给count;B线程进入读取count也是10,加1得到11,赋值给count为11;切换会A线程,赋值count为11。
解决方案:
-
悲观锁,使用同步方法、同步块、同步锁
-
乐观锁
使用原子整数
原子类
AtomicInteger类
原子整数,底层使用了CAS算法实现整数递增和递减操作
常用方法:
- incrementAndGet 原子递增
- decrementAndGet 原子递减
public class AtomicDemo {
static int count = 0;
static AtomicInteger integer = new AtomicInteger(0);
public static void main(String[] args) {
for (int i = 0; i < 10000; i++) {
new Thread(() ->{
count++;
//递增
integer.incrementAndGet();
}).start();
}
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("count:"+count);
System.out.println("atomic:"+integer.get());
}
}
CAS算法存在的问题
- ABA问题
线程1准备用CAS将变量的值由A替换为B,在此之前,线程2将变量的值由A替换为C,又由C替换为A,然后线程1执行CAS时发现变量的值仍然为A,所以CAS成功。但实际上这时的现场已经和最初不同了,尽管CAS成功,
但是这个是经过一系列操作之后的数值
- 如果预期值和实际值不一致处于循环等待状态,对CPU的消耗比较大
ThreadLocal
线程局部变量,会为每个线程单独变量副本,线程中的变量不会相互影响
以空间换时间
案例代码
public class ThreadLocalDemo {
static int count = 0;
//线程局部变量
static ThreadLocal<Integer> local = new ThreadLocal<Integer>(){
//设置初始值
@Override
protected Integer initialValue() {
return 0;
}
};
public static void main(String[] args) {
for (int i = 0; i < 5; i++) {
new Thread(()->{
count++;
local.set(local.get() + 1);
System.out.println(Thread.currentThread().getName() + "--->" + local.get());
}).start();
}
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(count);
System.out.println(local.get());
}
}
执行结果会发现System.out.println(local.get()); 打印的结果为0。
这是因为主线程并没有参与运算。
在子线程种打印会发现5个子线程的System.out.println(local.get()); 都是1因为他们执行的都是各自的线程局部变量,互不影响。
作业
- 了解什么是ABA问题
所谓ABA问题,就是比较并交换的循环,存在一个时间差,而这个时间差可能带来意想不到的问题。比如线程1和线程2同时也从内存取出A,线程T1将值从A改为B,然后又从B改为A。线程T2看到的最终值还是A,经过与预估值的比较,二者相等,可以更新,此时尽管线程T2的CAS操作成功,但不代表就没有问题。
-
编写懒汉式的单例模式,创建100个线程,每个线程获得一个单例对象,看是否存在问题(打印对象的hashCode,看是否相同)
分析问题原因,解决问题
package com.hopu.thread;
public class MySingleton {
//2.定义静态对象
private static MySingleton instance = null;
//1。将构造方法定义为私有
private MySingleton(){
}
//3.定义静态方法返回该对象
public static MySingleton getInstance(){
if(instance == null){
instance = new MySingleton();
}
return instance;
}
public static void main(String[] args) {
for (int i = 0; i < 50; i++) {
new Thread(()->{
System.out.println(instance.getInstance().hashCode());
}).start();
}
}
}
效果:
1274452530
1274452530
1274452530
1274452530
1274452530
1274452530
...