多线程初阶-进阶

本文详细介绍了线程的基本概念,包括为何需要线程、线程与进程的区别,以及线程的创建、使用和状态。讨论了线程安全问题,如内存可见性和指令重排序,并介绍了`synchronized`和`volatile`关键字的作用。此外,文章还涵盖了阻塞队列的概念、消息队列的使用场景和实现,以及线程池的原理和应用。最后,讨论了常见的锁策略,如乐观锁、悲观锁以及`synchronized`的优化机制。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

一.线程基本概念

1.为啥要有线程

(1)并发编程,随着多核cpu的发展成为刚需。

(2)多进程虽然也可以实现并发,但是进程的创建和销毁,太重量了。

2.线程和进程的区别和联系

(1)进程太重量了,创建、调度、销毁的速度慢,都会有很大的开销。线程比较轻量,创建、调度、销毁的速度快,开销比进程少很多。

(2)进程包含线程,一个进程可以包含一个线程,也可以包含多个线程。

(3)一个线程启动的时候,开销比较大一点,后续的线程开销就比较小。

(4)多进程之间,数据是分开的,共享复杂,同步简单;多线程之间,数据是共享的,共享简单,同步困难。

(5)多进程占用内存多,切换困难,CPU利用率低;多线程占用内存少,切换容易,CPU利用率高。

(6)进程之间不会互相影响;一个线程挂掉可能导致整个进程挂掉。

3.线程的使用
(1)线程创建

a.集成Thread类,重写run方法。

b.实现Runnable接口,重写run方法。

c.继承Thread,重写run方法,使用匿名内部类。

d.实现Runnable接口,重写run方法,使用匿名内部类。

e.使用Lambda表达式。

(2)Thread的常用属性/方法

ID getId()

名称 getName()

状态 getState() 线程状态

是否后台(守护)线程 IsDaemon()

前台线程,会阻止线程结束,前台线程的工作没做完,进程是完不了的。

后台进程,不会组织进程结束,后台线程没有结束,也是可以结束进程的。

是否存活 IsAlive()

是否被中断 IsInter

(3)线程的终止/中断

a.使用自己创建的标志位。

b.Thread提供了内置的标志位,可以使用isInterruptted方法判断标志位,使用Interrupt来设置标志位(还能把线程从休眠中唤醒)。

备注:对于终止,不是线程t1调用线程t2的Interrupt,只是t1通知t2要结束了,是否结束取决于t2本身。

(4)线程等待

join方法

在t1线程调用t2的join方法,就是让t1等待t2执行完了,再继续执行。

(5)获取线程引用

Thread.currentThread()

谁调用这个方法,就能获取到哪个线程的引用。

(6)休眠线程 sleep

本质上是让当前调用这个方法的线程,暂时不参与系统的调度执行(把这个线程的PCB放到了一个表示阻塞状态的队列,等到sleep时间到,操作系统会把这个PCB拿回到就绪队列)。

4.线程状态

本质是为了支撑线程调度的实现,java中的线程状态与操作系统不同。

NEW Thread对象有了,但是内核的PCB还没有(还没有调用start方法)。

TERMINATED 内核的PCB没有了(线程执行完),Thread对象还在。

RUNNABLE 就绪+运行状态(线程正在CPU运行 + 线程在排队即将到CPU执行)。

WAITING wait方法触发的方法阻塞。

TIMED_WAITING sleep触发的线程阻塞。

BLOCKED synchronized触发的线程阻塞。

创建线程后NEW状态,调用start()方法,线程转为RUNNABLE 态。

Runnable态调用sleep方法进入TIMED_WAITING态,sleep时间到进入RUNNABLE 态。

Runnable态调用wait方法/join方法进入WAITING态,调用notify后进入RUNNABLE 态。

线程获取锁,未抢占上时,进入阻塞BLOCKED态,获取到锁后进入RUNNABLE 态。

线程执行完成后,进入TERMINATED态。

5.线程安全

程序在多线程环境下,不出问题,就可以视为线程安全。

反之,程序在单线程和多线程环境下运行结果不一致,就为线程不安全。

线程不安全的原因:

(1)根本原因,线程是抢占式执行,随机调度的。

(2)多个线程同时修改同一个变量。

(3)修改操作,不是原子性。

典型就是++

a.load操作,把内存数据读取到CPU寄存器。

b.add操作,在寄存器+1。

c.save操作,把寄存器的内容写回到内存。

把修改行为变为原子操作,就是解决线程安全问题的关键方法。比如:synchronized。

(4)内存可见性

一个线程读,一个线程写,频繁读取同一个内存变量,就可能触发编译器的优化,后续的读取内存,被优化为直接读取寄存器。

如果另一个线程对这个内存变量进行写操作,读取的线程可能不能及时感知到变化。

解决办法为用volatile修饰这个变量,此时这个变量就不会优化,每次读取都是从内存中读取。

(5)指令重排序

本质上也是编译器优化的问题。

在单例模式中,就有一个典型的例子。

如果时按照1、3、2在多线程情况下执行,有可能执行1、3之后,切换到其他线程,其他线程就会得到一个非空的instance引用,但指向的对象是一个不完整的对象。

注意:加锁后,不是线程一直在cpu上,但是切换调度照常,只是其他线程尝试加锁,就会阻塞。

6.synchronized

加锁,核心是把一组不是原子的操作,变成了“原子”操作,解决线程安全问题的核心手段。

理解“锁对象”

必须是两个线程针对同一个锁对象进行加锁,才会产生阻塞等待。

死锁问题

(1)一个线程一把锁,连续加锁两次,如果锁是不可重复锁,就会死锁,但是synchronized是可重入锁。

(2)两个线程,两把锁,线程1获取锁A,线程2获取锁B,此时线程1尝试获取B,线程2尝试获取A。

(3)N个线程,M把锁,典型例子:哲学家就餐问题。

如何避免死锁?

一种简单有效的方法,打破循环等待,针对锁进行编号,并且要求按照固定顺序加锁。

7.volatile

内存可见性问题,静止指令重排序。

8.wait notify

控制线程之间的执行顺序。

搭配synchronized,wait要做的事情,释放锁,等待notify通知,收到通知之后重新获取锁(注意:notify是随机唤醒)。

二.阻塞队列

1.什么是阻塞队列

阻塞队列,也是一个队列,先进先出。

阻塞队列,是特殊队列,虽然也是先进先出,但是带有特殊的功能:阻塞。

(1)如果队列为空,执行出队列操作,就会阻塞,阻塞到另一个线程往队列添加元素(队列不空)为止。

(2)如果队列满了,执行入队列操作,也会阻塞,阻塞到另一个线程从队列取走元素(队列不满)为止。

特殊的队列,不一定遵守先进先出,比如优先级队列,PriorityQueue。

2.消息队列
消息队列是什么

消息队列,也是特殊队列,相当于在阻塞队列的基础上,加上“消息的类型”,按照制定类别进行先进先出。

此时谈到的消息队列仍然是一个“数据结构”,因为“消息队列”太香,有大佬把这样的“数据结构”单独实现成了一个程序。这个程序可以通过网络的方式和其他程序通信。

这时,这个消息队列就可以单独部署到一组服务器(分布式)。

存储能力和转发能力都大大提升,很多大型项目都能看到这样消息队列的身影。

此时,这个消息队列,就已经成了可以和mysql、redis相提并论的“中间件”了。

rabbit mq、active mq、rocket mq、kafka.......这些都是业界知名的消息队列。

为什么消息队列这么香?

和阻塞队列的阻塞特性关系非常大,基于这样的特性,可以实现“生产者消费者模型”。

什么是生产者消费者模型呢?

例如包饺子有两种方式。

两个人,每个人擀一个饺子皮,包一个饺子。此时两个人就会对擀面杖进行抢占。

一个人擀饺子皮,一个人包饺子。擀饺子皮的就是生产者,包饺子的就是消费者。

生产者消费者模型给程序带来两个特别大的好处。

(1)实现了发送方和接收方之间的“解耦”。低耦合就是一个模块改动对另一个模块影响不大,高耦合就是一个模块改动后,对另一个模块影响很大。写代码追求的就是“低耦合”。解耦就是降低耦合的过程。

开发中的经典场景:服务器之间的相互调用。

此时A服务器把请求转发给了B处理,B处理完了把结果反馈给A,此时就可以视为A调用了B。

这里的耦合度高,A服务器要调用B服务器,A必须知道B的操作,如果B服务器挂掉,很容易引起A服务器的bug。

如果此时添加一个C服务器,A服务器就需要添加不少代码。

针对上述场景,使用生产者消费者模型就可以有效的降低耦合。

此时,A和B之间的耦合就降低了很多,A不知道B,A只知道队列(A中没有任何与B相关的代码),B也是如此。如果B服务器挂掉,对A服务器没有任何影响,因为队列没有受到影响,A依然可以给队列插入元素。如果队列满,就先阻塞。如果A服务器挂掉,对B服务器没有任何影响,因为队列没有受到影响,B依然可以给队列取元素。如果队列空,就先阻塞。

如果需要添加C,让C服务器作为消费者,从队列取元素,A服务器依然时没有什么感知的。

(2)可以做到“削峰填谷”,保证系统的稳定性。

让系统趋于稳定状态。

3.使用和实现消息队列

(1)使用标准库提供的阻塞队列

ArrayBlockingQueue 基于数组的阻塞队列

LinkedBlockingQueue 基于链表的阻塞队列

PriorityBlockingQueue 带有优先级的阻塞队列(基于“堆”实现的既有阻塞功能又带有优先级功能)

普通的队列Queue提供的主要方法有三个:

a.入队列,offer

b.出队列,poll

c.取队首元素,peek

阻塞队列提供的主要方法有两个:

a.入队列,put

b.出队列,take

都带有阻塞功能

public static void main(String[] args) throws InterruptedException {
        BlockingQueue<String> blockingQueue = new LinkedBlockingQueue<>();
        blockingQueue.put("hello");
        String take = blockingQueue.take();
        System.out.println(take);
        String take2 = blockingQueue.take();
        System.out.println(take);
    }

使用阻塞队列实现生产则消费者

    public static void main(String[] args) {
        //创建阻塞队列
        BlockingQueue<Integer> blockingQueue = new LinkedBlockingQueue<>();
        //创建两个线程作为生产者和消费者
        //生产者
        Thread customer = new Thread(() -> {
            while (true){
                try {
                    Integer result = blockingQueue.take();
                    System.out.println("消费元素:" + result);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        customer.start();
        //消费者
        Thread producer = new Thread(() -> {
            int count = 0;
            while(true){
                try {
                    blockingQueue.put(count);
                    System.out.println("生产元素:" + count);
                    count++;
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        producer.start();

    }

(2)编写简单的阻塞队列,暂不考虑泛型

实现一个阻塞队列,要先写一个普通队列。可以用数组也可以用链表。

基于链表实现,使用头删和尾插。一个链表的头删,时间复杂度是O(1);链表的尾插,时间复杂度可以是O(1),用一个额外的引用来记录当前尾节点。

基于数组实现,也叫做环形队列。默认情况下,这个数组的每个元素虽然已经开辟了内存空间,但是视为上面的元素是无效元素。创建两个下标[head,tail),head和tail指向0号元素,添加一个元素tail往后走一位,如果到数组边界则指头部元素,头删时head向后走一位。head和tail重合时,可能是空,也可能是满。

区分空和满的方法

a.浪费一个元素。

b.引入一个size,来记录个数。

基于数组实现阻塞队列。

package thread;

class MyBlockingQueue{
    private int[] items = new int[1000];
    private int head = 0;
    private int tail = 0;
    private int size = 0;

    //入队列
    public void put(int value) throws InterruptedException {
        synchronized (this){
            while (size == items.length){
                //队列满了,产生阻塞
                this.wait();
            }
            items[tail] = value;
            tail++;
            //针对tail处理
//        tail = tail % items.length;
            //%操作效率更低,可读性更差
            if (tail >= items.length){
                tail = 0;
            }
            size++;
            this.notify();
        }
    }

    //出队列
    public Integer take() throws InterruptedException {
        int result = 0;
        synchronized (this){
            while (size == 0){
                //队列空,阻塞
                this.wait();
            }
            result = items[head];
            head++;
            if (head >= items.length){
                head = 0;
            }
            size--;
            this.notify();
        }
        return result;
    }
}


public class ThreadDemo23 {
    public static void main(String[] args) {
        MyBlockingQueue myBlockingQueue = new MyBlockingQueue();
        Thread customer = new Thread(() -> {
            while(true){
                try {
                    int result = myBlockingQueue.take();
                    System.out.println("消费:" + result);
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        customer.start();

        Thread product = new Thread(() -> {
            int count = 0;
            while (true){
                try {
                    System.out.println("生产:" + count);
                    myBlockingQueue.put(count);
                    count++;
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        product.start();
    }
}
4.定时器

指定特定时间段之后,执行代码。

在网络编程中,出现“卡了”、“连不上”的情况,可以使用定时器“止损”。

和阻塞队列类似,java标准库也提供了定时器。

public static void main(String[] args) {
        System.out.println("程序启动");
        //标准库定时器
        Timer timer = new Timer();
        //shedule 安排    效果是给定时器注册一个任务,不会立即执行,而是在一段规定的时间后
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("运行定时器任务");
            }
        },3000);
    }

自己编写定时器,满足以下条件。

(1)让被注册的任务能够在指定时间执行。单独在定时器内部创建一个线程,让这个线程周期性的扫描,判定任务是否是到时间了。

(2)一个定时器是可以注册N个任务的,N个任务会按照最初约定的时间,按顺序执行。N个任务需要使用数据结构保存。当前场景,使用优先级队列是最优选择。时间小的作为优先级高的,队首元素就是这个队列中最先要执行的任务,此时,扫描线程只需要扫描队首元素即可,不需要遍历整个队列。

(3)优先级队列会在多线程环境下使用,需要关注线程安全。推荐使用java标准库提供的:PriorityBlockingQueue 带有优先级的阻塞队列。

//用这个类表示保存的任务
class MyTask implements Comparable<MyTask>{
    //要执行的任务内容
    private Runnable runnable;
    //任务啥时候执行(用毫秒时间戳表示)
    private long time;
    public MyTask(Runnable runnable, long time) {
        this.runnable = runnable;
        this.time = time;
    }
    //获取当前任务时间
    public long getTime() {
        return time;
    }
    //执行任务内容
    public void run() {
        runnable.run();
    }

    @Override
    public int compareTo(MyTask o) {
        //this比o小,返回<0
        //this比o大,返回>0
        //this和o相等,返回0
        //当前要实现的,是队首元素是时间最小的
        return (int)(this.time - o.time);
    }
}

class MyTimer{
    //扫描线程
    private Thread t = null;
    //阻塞优先级队列,保存任务
    private PriorityBlockingQueue<MyTask> queue = new PriorityBlockingQueue<>();

    public MyTimer(){
        t = new Thread(() -> {
            //如果时间没到,就会一直重复取出来塞回去,称为”忙等“
            while (true){
                //取出队首元素,检查是否到时间,如果时间没到就塞回队列,如果时间到了就进行执行
                try {
                    MyTask myTask = queue.take();
                    long curTime = System.currentTimeMillis();
                    if (curTime < myTask.getTime()){
                        //未到时间,不必执行。
                        queue.put(myTask);
                        //在put之后进行一个wait
                        synchronized (this){
                            this.wait(myTask.getTime() - curTime);
                        }
                    } else {
                        //时间到了,执行任务
                        myTask.run();
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        t.start();
    }

    //指定两个参数
    //1.任务内容
    //2.任务在多少毫秒之后执行
    public void schedule(Runnable runnable, long ofter){
        //时间换算
        MyTask task = new MyTask(runnable,System.currentTimeMillis() + ofter);
        queue.put(task);
        synchronized (this){
            this.notify();
        }
    }
}

三.线程池

1.什么是线程池?

当我们需要创建销毁线程的时候,发现开销还是比较大的

为了进一步提高效率:

(1)搞一个“轻量级线程”=》协程/纤程(目前未加入java标准库)

(2)使用线程池,降低创建/销毁线程的开销

线程池内部创建了一些线程,使用的时候拿出来使用,不使用时还回去。这两个操作比操作系统创建和销毁线程的开销小很多。

创建/销毁线程需要操作系统内核操作。“拿”和“还”代码就可以实现。

2.使用线程池

在java标准库中,提供了线程池。

创建拥有10个线程的线程池

public class Demo {
    public static void main(String[] args) {
        //创建了一个线程池,线程数目固定十个
        //工厂模式:使用普通方法,代替构造方法,创建对象
        ExecutorService pool = Executors.newFixedThreadPool(10);
        for (int i = 0; i < 1000; i++) {
            int n = i;
            //i是主线程main的局部变量,随着代码块的结束而销毁
            //run方法属于Runnable,方法的执行的时机,是未来的某个节点(线程池的队列中排到他,他就执行)
            //有可能for循环结束了,线程池队列还没有排完,此时i已经销毁了
            //为了避免作用域的差异,于是就有了变量捕获,让run方法把刚才主线程的i给给往当前run的栈上拷贝一份
            pool.submit(new Runnable() {
                @Override
                public void run() {
                    System.out.println("hello" + n);
                }
            });
        }
    }
}

运行程序后发现,main线程结束了,但是整个进程没有结束。线程池内的线程都是前台线程,会阻止进程结束(定时器Timer也是如此)。

java标准库的线程池有很多种。

Executors.newCachedThreadPool()

线程数量动态变化。如果任务多了,就多增加几个线程;如果任务少了,就少搞几个线程。

Executors.newFixedThreadPool()

创建固定数目的线程池。

Executors.newSingleThreadExecutor()

线程池里面只有一个线程。

Executors.newScheduledThreadPool()

类似于定时器,也是让任务延时执行,只不过执行的时候不是由扫描线程自己执行,而是由单独的线程自己执行了,而是由单独的线程池来执行。

3.什么是ThreadPoolExecutor?

上述线程池,本质上都是通过包装ThreadPoolExecutor来实现的。

这个类在java.util.concrrent包,简称juc,Java并发包。

corePoolSize 核心线程。

maximumPoolSize 最大线程数。

keepAliveTime 其他线程可以没有任务的最大时间。

unit 时间单位(s,ms)。

workQueue 线程池的任务队列。

threadFactory 线程工厂,用于创建线程。

handler 描述了线程池的拒绝策略,也是一个特殊对象,如果线程6任务满了,继续添加任务会有啥样行为。

标准库提供的四个拒绝策略。

第一个拒绝策略:如果任务太多,队列满了,直接抛出异常。

第二个拒绝策略:如果队列满了,多出来的任务谁加了,谁加了,谁负责执行。

第三个拒绝策略:如果队列满了,丢弃最早的任务。

第四个拒绝策略:丢弃最新的任务。

ThreadPoolExecutors相当于把线程分为两类,一类是核心线程,一类是其他线程,两种之和为最大线程数。允许核心线程无任务,其他线程没有任务时间长了就会被销毁。整体策略是,核心线程保底,其他线程动态调整。

实际开发中,线程池的线程数设定:

不同程序特定不同,设置的线程数也是不同的。

考虑两个极端情况。

(1)CPU密集型,每个线程要执行的任务都是狂转CPU(进行一系列算数运算),此时线程池线程数,最多也不应该超过CPU核数。设置的更多也没有。CPU密集型,一直占用CPU,CPU的坑已经满了。

(2)IO密集型,每个线程干的事情就是等待IO(读写硬盘,读写网卡,等待用户输入),不吃CPU。此时这样的线程会处于阻塞状态,不参与CPU调度,线程可以多一点,不受制于CPU。

然而实际开发中,没有程序符合两种理想型。真实的程序,往往一部分要吃CPU,一部分要等待IO。实践中,通过测试/实验的方式。

4.自己写一个线程池

写的线程池是固定数目的线程池。

一个线程池内部至少有两部分:

(1)阻塞队列,保存任务。

(2)若干个工作线程。

class MyThreadPool{
    //任务
    private BlockingQueue<Runnable> queue = new LinkedBlockingQueue<>();

    //线程数目
    public MyThreadPool(int n){
        //创建n个线程
        for (int i = 0; i < n; i++) {
            Thread t = new Thread(() -> {
                while (true){
                    try {
                        Runnable runnable = queue.take();
                        runnable.run();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }

                }
            });
            t.start();
        }
    }

    //注册任务给线程池
    public void submit(Runnable runnable){
        try {
            queue.put(runnable);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}


public class Demo {
    public static void main(String[] args) {
        MyThreadPool pool = new MyThreadPool(1000);
        for (int i = 0; i < 1000; i++) {
            int n = i;
            pool.submit(new Runnable() {
                @Override
                public void run() {
                    System.out.println("hello" + n);
                }
            });
        }
    }
}

四.常见锁策略

    • 乐观锁和悲观锁

乐观锁:预测竞争不是很激烈(做的工作可能相对更少)。

悲观锁:预测竞争会很激烈(做的工作可能相对更多)。

背后做的工作是截然不同的。

这里不绝对,悲观和乐观唯一的区别就是,看预测锁竞争激烈程度的结论。

2.轻量级锁和重量级锁

轻量级锁:加锁解锁开销比较小,效率更高。多数情况下,乐观锁,也是一个轻量级锁(不能完全保证)。

重量级锁:加锁解锁开销比较多,效率更低。多数情况下,悲观锁,也是一个重量级锁(不能完全保证)。

3.自旋锁和挂起等待锁

自旋锁,是一种典型的轻量级锁。

挂起等待锁,是一种典型的重量级锁。

4.互斥锁和读写锁

互斥锁:类似synchronized,提供加锁和解锁操作,如果一个线程加锁了,另一个线程也尝试加锁,就会阻塞等待。

读写锁:提供了三种操作:针对读加锁;针对写加锁;解锁。

多线程针对同一个变量并发读,这个时候没有线程安全操作,没有线程安全问题,也不需要加锁操作。

读锁和读锁之间,没有互斥;写锁和写锁之间,存在互斥;写锁和读锁之间,存在互斥。

在java标准库提供了读写锁的具体实现(两个类,读锁类,写锁类)。

5.公平锁和非公平锁

此处把公平定义为“先来后到”。

公平锁:当解锁后,就按照队列中,最早等待的加锁。

非公平锁:当解锁后,线程一起竞争。

操作系统和java的synchronized 原生都是“非公平锁”。

操作系统针对加锁的控制,本身就是依赖于线程调度顺序的,这个调度顺序是随机的,不会考虑线程等待多久了。

6.可重入锁和不可重入锁

不可重入锁:一个线程针对一把锁,连续加锁两次,出现死锁。

可重入锁:一个线程针对一把锁,连续加锁多次不会触发死锁。

7.CAS

CAS全称Compare and swap,字面意思:比较并交换。

假设内存中存在原数据V(变量),寄存器内部有A、B两个值。

比较V和A,如果相等,就将V的值与B的值交换。

上述CAS的过程,并非通过一段代码实现的,而是通过一条CPU指令实现的,具有原子性。

(1)实现原子类:java标准库提供的类。原子类这里的实现,每次修改之前,确认一下是不是要修改的值。

package thread;

import java.util.concurrent.atomic.AtomicInteger;

public class Demo {
    public static void main(String[] args) throws InterruptedException {
        //这些原子类,基于CAS实现了自增自减等操作,此时这些操作不需要加锁,也是线程安全的。
        AtomicInteger count = new AtomicInteger(0);

        //使用原子类,来解决线程安全问题
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    //因为java不支持运算符自增自减,所以使用方法
                    count.getAndIncrement();//相当于count++
//                    count.incrementAndGet();//相当于++count
//                    count.getAndDecrement();//相当于count--
//                    count.decrementAndGet();//相当于--count
                }
            }
        });

        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    count.getAndIncrement();//相当于count++
                }
            }
        });

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

(2)实现自旋锁

五.synchronized

1.synchronized的锁策略

(1)默认是一个乐观锁,如果发现锁竞争激烈就变成悲观锁,如果不激烈就变回乐观锁。

(2)默认是一个轻量级锁,如果发现锁竞争激烈就变成重量级锁,如果不激烈就变回轻量级锁。

(3)轻量级锁基于自旋锁实现;重量级锁基于挂起等待锁实现。

(4)不是读写锁。

(5)是非公平锁。

(6)是可重入锁。

2.synchronized的优化机制
(1)锁升级/膨胀

无锁->偏向锁->轻量级锁->重量级锁

进行加锁的时候,首先会进入偏向锁状态,并不是真正的加锁,只是占个位置,有需要再真加锁,没有需要就算了。

偏向锁这个过程,相当于“懒汉模式”的懒加载一样,“非必要不加锁”。

synchronized的时候并不是真的加锁,先进入偏向锁状态,做个标记(非常轻量)。如果整个使用过程中,都没有出现锁竞争,在synchronized执行完之后,取消偏向锁。

但是,如果执行过程中,还有另一个线程也尝试加锁,在他加锁之前,迅速的把偏向锁升级为轻量级锁,另一个线程也就进入阻塞等待状态了。

此时synchronized相当于通过自旋的方式,进行加锁(CAS的自旋一样)。

如果很快其他线程解锁,自旋是非常划算的;如果迟迟拿不到锁,一直自旋,并不划算。

synchroized自旋不是无休止的自旋,自旋到一定程度,就会再次升级为重量级锁(挂起等待锁)。

如果线程进入重量级锁的加锁,并且发生锁竞争,此时线程就会被放到阻塞队列,暂时不参于CPU调度。直到锁被释放,这个线程才有机会被调度到,并且获取到锁。

(2)锁消除

编译器智能的判定,看当前的代码是否要加锁,如果下面场景不需要加锁,程序员也加了,就自动把锁干掉了。

比如:StringBuffer 关键方法都带有synchronized,但如果在单线程环境下,编译器就会把这些多线程干掉。

(3)锁粗化

锁的粒度:synchronized包含的代码越多,粒度就越粗;包含的代码越少,粒度就越细。

通常情况下,认为锁的粒度细一点比较好,加锁的部分代码是不能并发执行的,锁的粒度越细,能并发的代码就越多,反之越少。

但有些情况下,锁的粒度粗一点比较好。

两次加锁解锁之间,间隔非常小,比如直接加一次大锁。每次加锁都是有开销的。

六.JUC的常见类

1.Callable

类似于Runnable接口。

Runnable用来描述一个任务,描述的任务没有返回值。

Callable也用来描述一个任务,描述的任务有返回值。

如果需要使用一个线程单独计算出某个结果,使用Callable比较好一点。

使用Callable时,需要用到一个辅助类FutureTask,它可以帮助我们获取结果。

public class Demo {
    public static void main(String[] args) {
        //使用Callable计算1+2+3+...+1000
        Callable<Integer> callable = new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                int sum = 0;
                for (int i = 0; i < 1000; i++) {
                    sum += i;
                }
                return sum;
            }
        };
        FutureTask<Integer> futureTask = new FutureTask<>(callable);
        Thread t = new Thread(futureTask);
        t.start();
        try {
            Integer sum = futureTask.get();
            System.out.println(sum);
        } catch (InterruptedException | ExecutionException e) {
            e.printStackTrace();
        }
    }
}

FutureTask的get方法会阻塞,等到Callable结束。

2.ReentrantLock

标准库提供的另一种锁,顾名思义,时“是可重入的”锁。

synchronized是基于代码块的方式加锁解锁的。

ReentrantLock更传统,使用lock方法和unlock方法来加锁解锁的。

public class Demo {
    public static void main(String[] args) {
        ReentrantLock reentrantLock = new ReentrantLock();
        reentrantLock.lock();
        
        reentrantLock.unlock();
    }
}

中间任意一个条件都需要unlock。中间抛出异常或者return,也会导致unlock出现问题。

解决方法,务必放到finally。

上面这个问题是ReentrantLock的劣势,但也有优势。

(1)ReentrantLock提供了公平锁的构造方式,构造方法参数true使用公平锁,无参数或者false为非公平锁。

(2)sychronized提供的加锁方式就是“四等”,只要获取不到锁,就一直阻塞等待。ReentrantLock提供了更灵活的方式:trylock。方法无参数版本,能加锁就加锁,加不上就放弃。有参数,指定超时时间,加不上锁就等待一会儿,超时时间超过后就放弃。trylock有一个返回值,加锁成功1。

public static void main(String[] args) {
        ReentrantLock reentrantLock = new ReentrantLock();
        boolean result = reentrantLock.tryLock();

        try{

        }finally {
            if (result){
                reentrantLock.unlock();
            }
        }
    }

(3)ReentrantLock提供了一个更强大、更方便的等待通知机制。

synchronized搭配的是wait、notify,notify唤醒的时候是随机唤醒一个wait的线程。

ReentrantLock搭配的是Condition类,进行唤醒的时候可以唤醒指定的线程。

总结:虽然ReentrantLock有一定优势,但是我们实际开发中还是以synchronzied为主。

3.信号量semaphore

操作系统的信号量和java的信号量是一个东西,java的信号量就是把操作系统原生的信号量封装了一下。

信号量本质上就是一个计数器,描述了“可用资源的个数”。P操作,申请一个可用资源,计数器需要-1;V操作,释放一个可用资源,计数器就要+1(信号量不能为负数)。

P操作如果计数器为0了,继续P操作,就会出现阻塞等待的情况。

P操作使用acquire申请,V操作使用release释放。

考虑一种特殊情况:计数器初始值为1的信号量。针对这个信号量,只有0和1两种取值。执行一次P操作,减1;执行一次V操作,加1。如果进行过一次P操作,再进行P操作就会阻塞等待。

锁可以视为初始值为1的信号量,二元信号量。锁是信号量的一种特殊情况,信号量就是锁的一般表达。

代码中也可以使用semaphore实现类似于锁的效果,来保证线程安全。

4.CountDownLatch

主要提供了两个方法:

(1)await 所有线程被阻塞。

(2)countDown 当所有线程都调用countDown时,await被唤醒。

实际开发中,CountDownLatch有很多使用场景。多线程下载。

七.多线程环境

1.ArrayList在多线程环境下的使用

(1)自己加锁。使用synchronized或者ReentrantLock。

(2)标准库提供了Collections.synchronizedList

使用这个方法把集合类套一层,提供一些ArrayList相关方法,同时是带锁的。

(3)CopyOnWriteArrayList,CopyOnWrite简称COW,也叫做“写实拷贝”。

如果针对这个ArrayList进行读操作,不进行任何额外操作。

如果进行写操作,就拷贝一份新的ArrayList,针对新的进行修改,修改过程中如果有读操作,就继续读旧的数据,当修改完了,就用新的替换旧的(本质就是一个引用之间的赋值,原子的)。

优点,不用加锁。缺点是要求ArrayList比较小。

比如服务器的热加载(reload),这样的功能可以不重启服务器,实现配置的更新。

2.多线程环境使用哈希表

标准库的HashMap是线程不安全的。

HashTable是线程安全的,给关键方法加了synchronized。

更推荐使用ConcurrentHashMap,更优化的线程安全哈希表。

(1)最大的优化,ConcurrentHashMap相比HashTable大大缩小了锁冲突的概率(大锁变小锁)。

HashTable直接在方法上加synchronized,等于在this加锁,只要操作哈希表的任意元素,都会产生锁,也就都可能发生锁冲突。

但是有些元素在进行并发操作的时候,并不会产生线程安全问题,也就不需要使用锁控制。

ConcurrentHashMap的做法是每个链表都有各自的锁,每个链表的头节点作为锁对象(两个线程针对同一个锁对象进行加锁才会有锁竞争、阻塞等待)。

(2)ConcurrentHashMap做了一个激进的操作,针对读操作,不加锁,只针对写操作加锁。

读和读之间,没有冲突;

写和写之间,有冲突;

读和写之间,没有冲突;

写操作在ConcurrentHashMap是原子的,通过volatile+原子的写操作。

(3)ConcurrentHashMap充分使用CAS,通过这个进一步消减加锁操作的数目。比如维护元素个数。

(4)针对扩容,采取“化整为零”的方式。

HashMap/HashTable扩容:

创建一个更大的数组空间,把旧的数组上的链表上的每一个元素搬运到新的数组上(删除+插入),这个扩容操作会在某次put的时候触发,如果元素个数特别多,就会导致搬运操作比较耗时。

ConcurrentHashMap扩容:

创建新的数组,旧的数组保留。每次put操作都往新数组上添加,同时进行一部分搬运(把一小部分旧的元素搬运到新数组上),每次get时,新数组和旧数组都查询,每次remove的时候,只是把元素删了就行。一段时间后,旧数组搬空后,再释放数组。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值