java基础之多线程篇

一、多线程基础

1、线程与进程

  • 进程:资源管理的最小单位,一个进程内有多个线程。
  • 线程:CPU调度的最小单位,一个线程就是一条指令流,由CPU调度一条条执行。

2、并行与并发

  • 并发:单核CPU通过切换上下文执行多线程任务。
  • 并行:多核CPU执行多线程任务,真正意义上的同一时刻运行。

3、线程的状态

线程一共有6种状态:

  • New(新建)
  • Runnable(可运行)
  • Bolocked(阻塞)
  • Waiting(等待)
  • Timed waiting(计时等待)
  • Terminated(终止)

在这里插入图片描述

4、线程的死锁

死锁发生在多个线程相互等待对方释放锁资源,导致所有线程都无法继续执行。

4.1 死锁是如何产生的

  • 互斥条件:资源不能被多个线程共享,一次只能由一个线程使用。如果一个线程已经占用了一个资源,其他请求该资源的线程必须等待,直到资源被释放。
  • 持有并等待条件:一个线程至少已经持有至少一个资源,且正在等待获取额外的资源,这些额外的资源被其他线程占有。
  • 不可剥夺条件:资源不能被强制从一个线程中抢占过来,只能由持有资源的线程主动释放。
  • 循环等待条件:存在一种线程资源的循环链,每个线程至少持有一个其他线程所需要的资源,然后又等待下一个线程所占有的资源。这形成了一个循环等待的环路。

4.2 如何避免死锁

想要避免死锁,至少需要破坏一个死锁发生的条件。

  • 破坏互斥条件:这通常不可行,因为加锁就是为了互斥。
  • 破坏持有并等待条件:一种方法是要求线程在开始执行前一次性地申请所有需要的资源。
  • 破坏非抢占条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
  • 破坏循环等待条件:对所有资源类型进行排序,强制每个线程按顺序申请资源,这样可以避免循环等待的发生。

二、多线程的使用

1、线程的创建

1.1 继承 Thread() 类

通过继承Thread() 类实现线程的创建是最简单的做法,但有部分局限性:

  • 任务逻辑写在Thread类的run方法中,有单继承的局限性。
  • 创建多线程时,每个任务有成员变量时不共享,必须加static才能做到共享(原因待考证)。
  • 无返回值。

我们启动线程一定要调用 start() 方法,而不是 run() 方法。
调用 join() 方法表明:主线程阻塞,join()的调用线程执行完成后,主线程恢复执行。

public class MyThread extends Thread {
    public MyThread() {
    }

    @Override
    public void run() {
        System.out.println("我继承了Thread实现多线程任务");
    }

    public static void main(String[] args) {
        MyThread mt = new MyThread();
        mt.start();
        mt.join();
    }
}

1.2 实现 Runnable() 接口

实现 Runnable() 接口的方式创建创建线程解决的单继承的局限性,但是同样无法接收返回值。
下面是一道简单例题:循环打印十次ABC

public class MyRunnable implements Runnable {
    /**
     * 循环打印ABC十次
     */
    private final Object pre;
    private final Object self;
    private final String word;

    public MyRunnable(String word, Object pre, Object self) {
        this.word = word;
        this.self = self;
        this.pre = pre;
    }

    @Override
    public void run() {
        int count = 10;
        while (count > 0) {
            synchronized (pre) {
                synchronized (self) {
                    System.out.print(word);
                    count--;
                    self.notifyAll();
                }
                try {
                    if (count > 0)
                        pre.wait();

                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Object l1 = new Object();
        Object l2 = new Object();
        Object l3 = new Object();

        MyRunnable mr1 = new MyRunnable("A", l3, l1);
        MyRunnable mr2 = new MyRunnable("B", l1, l2);
        MyRunnable mr3 = new MyRunnable("C", l2, l3);

        Thread t1 = new Thread(mr1);
        Thread t2 = new Thread(mr2);
        Thread t3 = new Thread(mr3);

        t1.start();
        Thread.sleep(50);
        t2.start();
        Thread.sleep(50);
        t3.start();

        t1.join();
        t2.join();
        t3.join();
    }
}

1.3 实现 Callable() 接口

实现 Callable() 接口能够接收返回值,但也有特殊的地方:

  • 重写的是 call() 方法而不是 run() 方法。
  • 线程的启动是将任务放入FutureTask<T>中,然后通过new Thread(ft).start() 启动。
public class MyCallable implements Callable<String> {

    public MyCallable() {
    }

    @Override
    public String call() throws Exception {
        System.out.println("我实现了Callable接口实现多线程任务");
        return "执行成功";
    }

    public static void main(String[] args) {
    	// 注意这里Callable实现多线程任务时如何启动
        MyCallable mc = new MyCallable();
        FutureTask<String> ft = new FutureTask<>(mc);
        new Thread(ft).start();
    }
}

2、线程的阻塞

这里聊一下几种简单的方式:

2.1 wait() + notify() / notifyAll()

  • wait():当前线程进入阻塞状态。
  • notify():随机将一个等待池中线程唤醒。
  • notifyAll():唤醒等待池中的所有线程。

在使用Synchronized同步锁时,我们可以才可以使用上述方法。
同时,这种等待-唤醒的机制也可以用于线程的通信。

2.2 await() + signal() / signalAll()

这几个方法功能和上面的没有区别,但是这些是在Lock锁种使用。

2.3 yield()

yield()方法会让运行中的线程切换到就绪状态,重新争抢cpu的时间片,争抢时是否能重新获取到时间片要看cpu的分配。

3、线程的常用方法

这里是一些线程中常见的其他方法,比较简单。
线程的一些常用方法

三、线程池

除了上述的创建线程的方法,我们还可以通过使用线程池来创建和使用线程。

1、什么是线程池

线程池可以理解为一个“池子”,里面装有已创建好的线程,当我们需要使用线程时从“池子”中取出线程直接使用,使用完毕后将线程放回“池子”等待再次被使用。线程池具有以下优点:

  • 降低资源消耗:通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
  • 提高响应速度:当任务到达时,任务可以不需要等待线程创建就能立即执行。
  • 提高线程的可管理性:线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以对线程进行统一的分配,调优和监控。

线程池只会在创建线程的时候判断核心线程,只要线程被创建出来了,线程之间不做区分,只保留最大核心线程数量的线程。

下面是线程池的具体运行流程:

2、线程池的创建

通过 ThreadPoolExecutor 类创建线程,下面是其最重要的构造方法代码

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler) {
    if (corePoolSize < 0 ||
        maximumPoolSize <= 0 ||
        maximumPoolSize < corePoolSize ||
        keepAliveTime < 0) {
        throw new IllegalArgumentException();
    }
    if (workQueue == null || threadFactory == null || handler == null) {
        throw new NullPointerException();
    }
    this.acc = System.getSecurityManager() == null ? null : AccessController.getContext();
    this.corePoolSize = corePoolSize;
    this.maximumPoolSize = maximumPoolSize;
    this.workQueue = workQueue;
    this.keepAliveTime = unit.toNanos(keepAliveTime);
    this.threadFactory = threadFactory;
    this.handler = handler;
}

这个构造方法共有7个参数:

  • corePoolSize:核心线程数。
  • maximumPoolSize:最大线程数。
  • keepAliveTime:非核心线程空闲存活时间。
  • unit:时间单位。
  • BlockingQueue<Runnable> workQueue:阻塞队列。
  • ThreadFactory threadFactory:线程工厂,用于创建线程。
  • RejectedExecutionHandler handler:拒绝策略(后面详细讲解)。

3、常见的线程池

3.1 定长线程池:newFixedThreadPool

FixedThreadPool 线程池的核心线程数和最大线程数是一样的,它的特点是线程池中的线程数除了初始阶段需要从 0 开始增加外,之后的线程数量就是固定的。如果任务数超过线程数,线程池会把超出的任务放到任务队列中进行等待。如果任务队列满了,则会执行拒绝策略。

// 定长线程池的创建
ExecutorService cachedThreadPool = Executors.newFixedThreadPool(3);

// 底层创建逻辑
public static ExecutorService newFixedThreadPool(int nThreads) {
	// 这里可以看到核心线程数和最大线程数一样,所以不会创建非核心线程
	// 使用的是默认拒绝策略和默认线程创建工厂Executors.defaultThreadFactory()
    return new ThreadPoolExecutor(nThreads, nThreads,
                                  0L, TimeUnit.MILLISECONDS,
                                  new LinkedBlockingQueue<Runnable>());
}

3.2 单一线程池:SingleThreadExecutor

它会使用唯一的线程去执行任务,原理和 FixedThreadPool 是一样的,只不过这里线程只有一个,如果线程在执行任务的过程中发生异常,线程池也会重新创建一个线程来执行后续的任务。这种线程池由于只有一个线程,所以非常适合用于所有任务都需要按被提交的顺序依次执行的场景,而前几种线程池不一定能够保障任务的执行顺序等于被提交的顺序,因为它们是多线程并行执行的。

// 单一线程池的创建
ExecutorService singleThreadExecutor = Executors.newSingleThreadExecutor();

// 底层创建逻辑
public static ExecutorService newSingleThreadExecutor() {
    return new FinalizableDelegatedExecutorService
    	// 核心线程和最大线程数都为1
        (new ThreadPoolExecutor(1, 1,
                                0L, TimeUnit.MILLISECONDS,
                                new LinkedBlockingQueue<Runnable>()));
}

3.3 可缓存线程池:CachedThreadPool

CachedThreadPool 可缓存线程池,它线程数是几乎可以无限增加的(实际最大可以达到 Integer.MAX_VALUE,为 2^31-1,这个数非常大,所以基本不可能达到),而当线程闲置时还可以对线程进行回收。也就是说该线程池的线程数量不是固定不变的,当然它也有一个用于存储提交任务的队列,但这个队列是 SynchronousQueue,队列的容量为0,实际不存储任何任务,它只负责对任务进行中转和传递,所以效率比较高。当我们提交一个任务后,线程池会判断已创建的线程中是否有空闲线程,如果有空闲线程则将任务直接指派给空闲线程,如果没有空闲线程,则新建线程去执行任务,这样就做到了动态地新增线程。

ExecutorService cachedThreadPool = Executors.newCachedThreadPool();

public static ExecutorService newCachedThreadPool(ThreadFactory threadFactory) {
	// 这里可以看到,核心线程数为0,意味着如果超过空闲时间,线程池中的线程可能全部被销毁
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                  60L, TimeUnit.SECONDS,
                                  new SynchronousQueue<Runnable>(),
                                  threadFactory);
}

public class ThreadPoolExecutorDemo {
    public static void main(String[] args){
        //创建可缓存线程池
        ExecutorService cachedThreadPool = Executors.newCachedThreadPool();
        for (int i = 0; i < 10; i++) {
            try {
                //sleep可明显看到使用的是线程池里面以前的线程,没有创建新的线程
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            cachedThreadPool.execute(new Runnable() {
                @Override
                public void run() {
                    //打印正在执行的缓存线程信息
                    System.out.println(Thread.currentThread().getName() + "正在被执行1");
                }
            });
        }
    }
}

3.4 周期性线程池:ScheduledThreadPool

newScheduledThreadPool 可用于创建可定时调度的线程池,可设置在给定延迟时间后执行或定期执行某个线程任务。

public class Test{
    public static void main(String[] args) {
        ScheduledExecutorService pool = Executors.newScheduledThreadPool(2);

        // 创建一个延迟3秒执行的线程
        pool.schedule(new Runnable() {
            public void run() {
                System.out.println("delay 3 seconds" + Thread.currentThread().getName());
            }
        }, 3, TimeUnit.SECONDS);
        // 创建一个延迟3秒且每1秒执行一次的线程
        pool.scheduleAtFixedRate(new Runnable() {
            public void run() {
                System.out.println("delay 3 second and repeat execute every 1 seconds" + Thread.currentThread().getName());
            }
        }, 3, 1, TimeUnit.SECONDS);

        // 关闭线程池
        pool.shutdown();
    }
}

以上四种线程池都不推荐使用

3.5 自定义线程池

4、线程池的拒绝策略

4.1 四种默认拒绝策略

  • ThreadPoolExceutor.AbortPolicy 丢弃任务并抛出RejectedExecutionException异常。
  • ThreadPoolExecutor.DiscardPolicy:丢弃任务,但是不抛出异常。
  • ThreadPoolExecutor.DiscardOldestPolicy:丢弃队列最前面的任务,然后重新提交被拒绝的任务
  • ThreadPoolExecutor.CallerRunsPolicy:由调用线程(提交任务的线程)处理该任务

4.2 自定义拒绝策略

四、线程安全

1、线程的三大特性

1.1 原子性

多个操作作为一个整体,不能被分割与中断,也不能被其他线程干扰。

1.2 可见性

一个线程修改的共享变量,其他线程能够立刻看到。

1.3 有序性

程序按照代码的先后顺序来执行,不会发生指令重排序。

2、常用的锁

2.1 CAS 自旋锁

CAS即Compare and Swap,比较并替换。
CAS机制中使用了3个基本操作数:内存地址V,旧的预期值A,要修改的新值B。更新一个变量的时候,只有当变量的预期值A和内存地址V当中的实际值相同时,才会将内存地址V对应的值修改为B。
从思想上来说,synchronized属于悲观锁,悲观的认为程序中的并发情况严重,所以严防死守,CAS属于乐观锁,乐观地认为程序中的并发情况不那么严重,所以让线程不断去重试更新。synchronized转变为重量级锁之前,也会采用CAS机制。而我们熟知的AQS底层也是通过CAS和Synchronized来保证线程安全的。

CAS的缺点:

  • CPU开销过大:在并发量比较高的情况下,如果许多线程反复尝试更新某一个变量,却又一直更新不成功,循环往复,会给CPU带来很到的压力。
  • 无法保证代码块的原子性:CAS机制只能保证一个变量的原子性操作,而不能保证整个代码块的原子性。
  • ABA问题:假设t1线程工作时间为10秒,t2线程工作时间为2秒,那么可能在A的工作期间,主内存中的共享变量 A已经被t2线程修改了多次,只是恰好最后一次修改的值是该变量的初始值,虽然用CAS判定出来的结果是期望值,但结果却是错的。(通过添加版本号解决)

2.2 Synchronized

Synchronized是Java中的关键字,该关键字是依靠JVM来进行识别,是虚拟机级别的(区别于Lock锁API级别的)。

synchronized是隐式锁,不需要手动开启和关闭锁;synchronized有代码块锁和方法锁。

同步锁也叫对象锁,是锁在对象上的,不同的对象就是不同的锁。

让同一个时刻最多只有一个线程能持有对象锁,其他线程在想获取这个对象锁就会被阻塞,不用担心上下文切换的问题。

注意:锁是加在对象上的,须确保是同一对象,锁才能生效

Synchronized有一个锁升级过程:

  • 无锁
  • 偏向锁
  • 轻量级锁
  • 重量级锁

锁可以升级不能降级,但轻量级锁状态可以被重置成无锁状态。
在这里插入图片描述

在这里插入图片描述

2.3 ReentrantLock

Lock锁是API级别的,提供了相应的接口和对应的实现类。这种方式实现锁会更加的灵活(区别于synchronized锁是JVM级别的)。

Lock锁是显式锁,需要手动开启和关闭锁(别忘了最后一定要关闭锁);Lock锁只有代码块锁。

	// count++ 不是原子性操作, 所以volatile不能保证线程安全
    private static int count = 0; 

    public static void main(String[] args) throws InterruptedException {
    // 可重入锁,建议撸一遍源码
        Lock lock = new ReentrantLock(); 

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 500000; i++) {
                lock.lock();
                try {
                    count++;
                } catch (Exception e){
                    e.printStackTrace();
                } finally {
                    lock.unlock();
                }

            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 500000; i++) {
                lock.lock();
                try {
                    count--;
                } catch (Exception e){
                    e.printStackTrace();
                } finally {
                    lock.unlock();
                }
            }
        });

        t1.start();
        t2.start();

        t1.join();
        t2.join();
        System.out.println(count);
    }

2.4 Volatile

Volatile是Java中的一个关键字,它常被称为轻量级锁,在某些情况下也可以用于保证线程安全。

2.4.1 Volatile底层原理

观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令
lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:

  • 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
  • 它会强制将对缓存的修改操作立即写入主存;
  • 如果是写操作,它会导致其他CPU中对应的缓存行无效。

当程序在运行过程中,会将运算所需要的数据从主内存中拷贝一份到高速缓存(私有的本地内存—Local Memory)中以便提高运算效率,但在多线程情况下会造成缓存一致性问题(通常称这种被多个线程访问的变量为共享变量)。
tu

2.4.2 volatile的两大特性
  • 保证可见性

(1)当写一个volatile变量时,JMM会把该线程本地内存中的变量强制刷新到主内存中去。
(2)这个写操作会导致其他线程中的volatile变量缓存无效。

  • 禁止指令重排序

(1)重排序操作不会对存在数据依赖关系的操作进行重排序。
  比如:a=1;b=a; 这个指令序列,由于第二个操作依赖于第一个操作,所以在编译时和处理器运行时这两个操作不会被重排序。
(2)重排序是为了优化性能,但是不管怎么重排序,单线程下程序的执行结果不能被改变
  比如:a=1;b=2;c=a+b这三个操作,第一步(a=1)和第二步(b=2)由于不存在数据依赖关系, 所以可能会发生重排序,但是c=a+b这个操作是不会被重排序的,因为需要保证最终的结果一定是c=a+b=3。

但是,想要保证线程安全要同时满足原子性可见性以及有序性,所以volatile只适用于原子性操作中(如i++不是原子性操作,不能使用volatile,可用synchronize、AtomicInteger等)。 volatile 常用于多线程环境下的单次操作(单次读或者单次写)。

2.4.3 volatile的使用条件

使用重量级锁可以保证线程安全,但是会影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized等锁,但是要注意volatile是无法替代synchronized的,因为volatile无法保证操作的原子性。通常来说,使用volatile必须具备以下2个条件:

  • 对变量的写操作不依赖于当前值。
  • 该变量没有包含在具有其他变量的不变式中。

3、线程本地变量 ThreadLocal

保证多线程安全的方式是不让多线程去操作临界资源,每个线程去操作属于自己的数据。

public class MyThreadLocal {

    static ThreadLocal tl1 = new ThreadLocal();
    static ThreadLocal tl2 = new ThreadLocal();

    public static void main(String[] args) {
        tl1.set("main1");
        tl2.set("main2");

        new Thread(() -> {
            tl1.set("thread1");
            tl2.set("thread2");

            System.out.println("thread" + tl1.get());
            System.out.println("thread" + tl2.get());
        }).start();

        System.out.println("main" + tl1.get());
        System.out.println("main" + tl2.get());
    }
}

实现原理:

// 通过ThreadLocal将数据存在ThreadLocalMap中。
// ThreadLocalMap 是一个内部类,用一个Entry(弱引用,解决了key的内存泄漏问题)数组通过key-value的形式存储数据。
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) {
                @SuppressWarnings("unchecked")
                T result = (T)e.value;
                return result;
            }
        }
        return setInitialValue();
    }

ThreadLocal内存泄漏问题:

  • 原因:如果ThreadLocal引用丢失,key(ThreadLocal对象)因为是弱引用会被回收掉,但是如果线程还未被回收,key对应的value未被回收,导致内存泄漏。
  • 解决方案:使用完ThreadLocal对象后,掉用remove方法,移除Entry即可。
    在这里插入图片描述
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值