第01章 快速入门
第01节 线程的上下文切换
多线程执行是cpu抢占时间片的方式执行。多线程创建并切换到另一个线程的过程,称之为线程的上下文切换。cup在多个线程的来回切换的过程中会造成性能的降低。
第02节 线程的安全问题
1. 首先要理解什么是线程安全问题?
就是在多线程环境中 , 在某个线程的指令没有执行完毕,cup切换到另外的线程执行指令,导致某些重要的指令没有执行,导致了数据有误。
出现线程安全问题的三个条件:
- 多个线程
- 同一个时间
- 执行同一段指令或修改同一个变量
2. 银行转账案例
/**
* 银行转账的案例
*/
public class BankDemo {
//模拟100个银行账户
private int[] accounts = new int[100];
{
//初始化账户
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("余额不足");
}
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());
}
/**
* 计算总余额
* @return
*/
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 < 50; i++) {
new Thread(() -> {
int from = random.nextInt(100);
int to = random.nextInt(100);
int money = random.nextInt(2000);
bank.transfer(from,to,money);
}).start();
}
}
}
执行结果
我们通过执行的结果发现问题,银行的总账的数据不是固定的,数据有误,为什么会造成这种现象。因为在多线程的案例中,某个线程A在执行转账操作的时候,收款用户可能还没有收到转账的时候,cpu就被其他的线程B抢占,导致之前的线程A还没有完成一整套的转账业务。数据就会产生错误。
第02章 线程安全问题的解决方法
最实际的解决办法就是给我们的线程上锁,让当前的线程执行一整套指令后才允许其他线程来抢占cpu。
几种不同上锁方法:
- 同步方法
- 同步代码块
- 同步锁
1. 同步方法
给方法添加synchronized关键字,给整个方法上锁,当cpu调用当前线程的方法时其他的线程无法抢占,直到该方法执行完毕释放锁,其他线程才可以抢占cpu。
案例代码
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());
}
2. 同步代码块
给某一段代码上锁,锁对象:任何对象都可以作为锁,对象不能是局部变量
语法:
synchronized(锁对象){
代码
}
案例代码
//同步代码块
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会判断锁对象是否有其它线程持有,如果其它线程持有,当前线程就无法执行,等待锁释放如果锁没有其它线程持有,当前线程就持有锁,执行代码
3. 同步锁
在java.concurrent并发包中的
Lock接口
基本方法:
- lock() 上锁
- unlock() 释放锁
常见实现类
- ReentrantLock 重入锁
- WriteLock 写锁
- ReadLock 读锁
- ReadWriteLock 读写锁
使用方法:
- 定义同步锁对象(成员变量)
- 上锁
- 释放锁
语法
//成员变量
Lock lock = new ReentrantLock();
//方法内部上锁
lock.lock();
try{
代码...
}finally{
//释放锁
lock.unlock();
}
三种锁对比:
-
粒度 (粒度越小越灵活)
同步代码块/同步锁 < 同步方法
-
编程简便
同步方法 > 同步代码块 > 同步锁
-
性能
同步锁 > 同步代码块 > 同步方法
-
功能性/灵活性
同步锁(有更多方法,可以加条件) > 同步代码块 (可以加条件) > 同步方法
第03章 乐观锁与悲观锁
1. 何为乐观锁和悲观锁
乐观锁和悲观锁是两种思想,用于解决并发场景下的数据竞争问题。
- 乐观锁:乐观锁在操作数据时非常乐观,认为别人不会同时修改数据或者说线程安全问题不会频繁出现。因此乐观锁不会上锁,只是在执行更新的时候判断一下在此期间别人是否修改了数据:如果别人修改了数据则放弃操作,否则执行操作。
乐观锁的两种实现方式
版本号机制
- 利用版本号记录数据更新的次数,一旦更新版本号加1,线程修改数据后会判断版本号是否是自己更新的次数,如果不是就不更新数据。
CAS (Compare And Swap)比较和交换算法
- 通过内存的偏移量获得数据的值
- 计算出一个预计的值
- 将提交的实际值和预计值进行比较,如果相同执行修改,如果不同就不修改
2.悲观锁:悲观锁在操作数据时比较悲观,认为别人会同时修改数据。因此操作数据时直接把数据锁住,直到操作完成后才会释放锁;上锁期间其他人不能修改数据。
2. 悲观锁和乐观锁对比
- 悲观锁更加重量级,占用资源更多,应用线程竞争比较频繁的情况,多写少读的场景
- 乐观锁更加轻量级,性能更高,应用于线程竞争比较少的情况,多读少写的场景
3. 案例引入
多个线程对同一数据进行累加。
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的值
- 计算count+1的值
- 将计算结果赋值给count
这三个指令不是原子性的,当A线程读取count值10,加1后得到11,准备赋值给count;B线程抢占cpu进入读取count也是10,加1得到11,赋值给count为11;切换会A线程,赋值count为11。
解决方案:
-
悲观锁,使用同步方法、同步块、同步锁
-
乐观锁
以下主要通过乐观锁的两种方式来解决此问题
1. 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());
}
}
我的理解是AtomicInteger类底层使用的是CAS (Compare And Swap)比较和交换算法,通过内存的偏移量获得数据的值然后计算出一个预计的值最后将提交的实际值和预计值进行比较,如果相同执行修改,如果不同就不修改。
跟进源码我们会发现实际上它的底层是会循环判断我们的实际值和预期值是否相同 如果不相同就会一直进行运算,直到预期值和实际值相同。
这样的算法可能会产生ABA问题
举例
有两个线程,同时操作一个变量,线程1执行时间比线程2执行时间长,线程2执行快
线程1读取值,此时读到的值是A,这时候线程被挂起,
线程2也读到值,并将A修改为X,然后又做了操作,X又改为Z,最后又将Z改为A;线程2交出执行权;
线程1此时拿到执行权了,此时进行compareAndSwap,发现内存值和期望值是一样,于是正常执行,但是内存值在这期间已经被操作过;
通俗的来说就是我们的线程的的实际值经过其他的线程的数据转化恰好成为了我们线程的预期值,导致我们的算法误以为我们的数据是没有经过修改的于是继续正常执行。
这就是ABA问题;
2. 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因为他们执行的都是各自的线程局部变量,互不影响。