java线程进阶

学习目标

  1. 线程的上下文切换

  2. 线程的安全(同步)问题

  3. 线程安全问题的解决方法

  4. ThreadLocal的介绍

线程的上下文切换

前提:一个CPU的内核一个时间只能运行一个线程中的一个指令

线程并发:CPU内核会在多个线程间来回切换运行,切换速度非常快,达到同时运行的效果

问题1:

线程切换回来后,如何从上次执行的指令后执行?

程序计数器(每个线程都有,用于记录上次执行的行数)

问题2:

线程执行会随时切换,如何保证重要的指令能完全完成?

线程安全问题

如何减少上下文切换

减少上下文切换的方法有无锁并发编程、CAS算法、使用最少线程和使用协程。

    无锁并发编程。多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以用一 些办法来避免使用锁,如将数据的ID按照Hash算法取模分段,不同的线程处理不同段的数据。
    CAS算法。Java的Atomic包使用CAS算法来更新数据,而不需要加锁。
    使用最少线程。避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这 样会造成大量线程都处于等待状态
    协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换

线程的安全(同步)问题

CPU在多个线程间切换,可能导致某些重要的指令不能完整执行,出现数据的问题。

出现线程安全问题的三个条件:

  • 多个线程

  • 同一个时间

  • 执行同一段指令或修改同一个变量

案例的实现

/**
 * 银行转账的案例
 */
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();
        }
    }
}

线程安全问题的解决方法

解决多线程的并发安全问题,java无非就是加锁,具体就是两个方法

(1) Synchronized(java自带的关键字)

(2) lock 可重入锁 (可重入锁这个包java.util.concurrent.locks 底下有两个接口,分别对应两个类实现了这个两个接口: 

       (a)lock接口, 实现的类为:ReentrantLock类 可重入锁;

       (b)readwritelock接口,实现类为:ReentrantReadWriteLock 读写锁)

也就是说有三种:

(1)synchronized 是互斥锁;

(2)ReentrantLock 顾名思义 :可重入锁

(3)ReentrantReadWriteLock :读写锁

同步方法

给方法添加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());
    }

锁对象:

  • 非静态方法 --> this

  • 静态方法 ---> 当前类.class

同步代码块

粒度比同步方法小,粒度越小越灵活,性能更高

给一段代码上锁

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会判断锁对象是否有其它线程持有,如果其它线程持有,当前线程就无法执行,等待锁释放

如果锁没有其它线程持有,当前线程就持有锁,执行代码

底层汇编实现:

monitorenter
....
monitorexit

同步锁

在java.concurrent并发包中的

Lock接口

基本方法:

  • lock() 上锁

  • unlock() 释放锁

常见实现类

  • ReentrantLock 重入锁

  • WriteLock 写锁

  • ReadLock 读锁

  • ReadWriteLock 读写锁

使用方法:

  1. 定义同步锁对象(成员变量)

  2. 上锁

  3. 释放锁

//成员变量
Lock lock = new ReentrantLock();
​
//方法内部上锁
lock.lock();
try{
    代码...
}finally{
    //释放锁
    lock.unlock();
}

三种锁对比:

  • 粒度

    同步代码块/同步锁 < 同步方法

  • 编程简便

    同步方法 > 同步代码块 > 同步锁

  • 性能

    同步锁 > 同步代码块 > 同步方法

  • 功能性/灵活性

    同步锁(有更多方法,可以加条件) > 同步代码块 (可以加条件) > 同步方法

悲观锁和乐观锁

何谓悲观锁与乐观锁

   乐观锁对应于生活中乐观的人总是想着事情往好的方向发展,悲观锁对应于生活中悲观的人总是想着事情往坏的方向发展。这两种人各有优缺点,不能不以场景而定说一种人好于另外一种人。

悲观锁

总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。

乐观锁

总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。

两种锁的使用场景

从上面对两种锁的介绍,我们知道两种锁各有优缺点,不可认为一种好于另一种,像乐观锁适用于写比较少的情况下(多读场景),即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。但如果是多写的情况,一般会经常产生冲突,这就会导致上层应用会不断的进行retry,这样反倒是降低了性能,所以一般多写的场景下用悲观锁就比较合适。

悲观锁

认为线程的安全问题非常容易出现,会对代码上锁

前面所讲的锁机制都属于悲观锁

悲观锁的锁定和释放需要消耗比较多的资源,降低程序的性能

乐观锁

认为线程的安全问题不是非常常见的,不会对代码上锁

有两种实现方式:

  • 版本号机制

    利用版本号记录数据更新的次数,一旦更新版本号加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++ 分解为三个指令:

  1. 从内存中读取count的值

  2. 计算count+1的值

  3. 将计算结果赋值给count

这三个指令不是原子性的,A线程读取count值10,加1后得到11,准备赋值给count;B线程进入读取count也是10,加1得到11,赋值给count为11;切换会A线程,赋值count为11。

解决方案:

  1. 悲观锁,使用同步方法、同步块、同步锁

  2. 乐观锁

    使用原子整数

原子类

AtomicInteger类

AtomicInteger介绍

AtomicInteger是一个提供原子操作的Integer类,通过线程安全的方式操作加减。
AtomicInteger使用场景

AtomicInteger提供原子操作来进行Integer的使用,因此十分适合高并发情况下的使用。

案例演示

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());
    }
}

ThreadLocal

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());
    }
}

ThreadLocal底层的实现

维持线程封闭性的一种规范方法是使用ThreadLocal。它提供了set和get等访问方法,这些方法为每个使用该变量的线程都存有一份独立的副本,因此get方法总是返回由当前执行线程在调用set时设置的最新值。那么,我们就看看关于这两个方法的JDK源码:

public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
    }
public T get() {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {
                T result = (T)e.value;
                return result;
            }
        }
        return setInitialValue();
    }

用文字描述就是:get方法里通过Thread.currentThread()获取当前执行的线程,该方法内部包含一个ThreadLocalMap对象,针对每个thread保留一个entry,该entry的值就是当前线程保留的一个变量副本。


应用场景:ThreadLocal对象通常用于防止对可变的单实例变量或全局变量进行共享。例如,由于JDBC的连接对象不是线程安全的,因此,当多线程应用程序在没有协同的情况下,使用全局变量时,就不是线程安全的。通过将JDBC的连接对象保存到ThreadLocal中,每个线程都会拥有属于自己的连接对象副本。
 

最后案列实现

编写懒汉式的单例模式,创建100个线程,每个线程获得一个单例对象,看是否存在问题(打印对象的hashCode,看是否相同)

分析问题原因,解决问题


public class Demo {
    public static void main(String[] args) {
        for (int i = 0; i < 100; i++) {
            new Thread(()->{
                Singleton singleton = Singleton.getInstance();
                System.out.println(singleton.hashCode());
            }).start();
        }
    }


    static class Singleton{
        private static Singleton instance;
        private  Singleton (){

        }

        public static Singleton getInstance() {
            if (instance == null) {
                instance = new Singleton();
            }
            return instance;
        }
    }


}

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值