目录

线程池

模拟实现线程池

定时器

实现定时器

时间轮

常见的锁策略

乐观锁 vs 悲观锁

重量级锁 vs 轻量级锁

挂起等待锁 vs 自旋锁

普通互斥锁 vs 读写锁

可重入锁 vs 不可重入锁

公平锁 vs 非公平锁

总结

synchronized 原理

基本特点

加锁工作过程

​编辑偏向锁


线程池

上古时期,服务器处理多个客户端的请求,是基于多进程的模型。每次有个客户端请求过来了,服务器这边都创建一个进程给这个客户端提供服务(读取请求,解析请求,返回响应…….)。后来,频繁创建销毁进程,效率比较低,所以引入了线程

引入线程后,模型就成了,每个客户端都分配一个线程提供服务。随着客户端数量越来越多,发现频繁创建销毁线程,也变的比较低效了,又引入了线程池

线程池最主要的还是解决服务器开发的问题,假设线程池里提前准备好10个线程,有100个客户端吧请求发过来,把这100个客户端的请求封装成任务(Runnable)添加到线程池里,线程池中由这10个线程负责处理这100个任务,这个过程就不涉及线程的创建销毁了

模拟实现线程池

1. 核心操作为 submit,将任务加入线程池中,任务就是 runnable

2. 使用 Worker 类描述一个工作线程,使用 Runnable 描述一个任务

3. 使用一个 BlockingQueue 组织所有的任务

4. 每个 worker 线程要做的事情:不停的从 BlockingQueue 中取任务并执行

5. 指定一下线程池中的最大线程数 maxWorkerCount;当当前线程数超过这个最大值时,就不再新增线程了

// 实现一个固定线程个数的线程池
class MyThreadPool {
    private BlockingQueue<Runnable> queue = null;
    public MyThreadPool(int n) {
        // 初始化线程池,创建固定个数的线程
        // 这里使用ArrayBlockingQueue作为任务队列, 容量为1000
        queue = new ArrayBlockingQueue<>(1000);

        // 创建 N 个线程
        for (int i = 0; i < n; i++) {
            Thread t = new Thread(() -> {
                try {
                    while (true) {
                        Runnable task = queue.take();
                        task.run();
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
            // t.setDaemon(true); // 设置为后台线程
            t.start();
        }
    }

    public void submit(Runnable task) throws InterruptedException {
        // 将任务放入队列中
        queue.put(task);
    }
}

public class Demo35 {
    public static void main(String[] args) throws InterruptedException {
        MyThreadPool pool = new MyThreadPool(10);

        // 向线程池提交任务
        for (int i = 0; i < 100; i++) {
            int id = i;
            pool.submit(() -> {
                System.out.println(Thread.currentThread().getName() + " id=" + id);
            });
        }
    }
}

虽然100个任务已经结束了但是进程仍然没有结束,此时线程池里的这些线程,还在 take 阻塞,说明线程池中的线程,是前台线程,会阻止进程结束!

补充知识:

手动停止上述程序,显示的是数字 130,这个数字表示程序的退出码

操作系统中约定了:退出码为 0,表示正常结束;非0表示异常结束(不同的数字表示不同的原因)

正常结束:

定时器

定时器是软件开发中的一个重要组件,类似于一个 “闹钟”,达到一个设定的时间之后,就执行某个指定好的代码

定时器是一种实际开发中非常常用的组件。比如网络通信中,如果对方 500ms 内没有返回数据,则断开连接尝试重连。比如一个Map,希望里面的某个 key 在 3s 之后过期(自动删除)。类似这样的场景就需要用到定时器

标准库中的定时器

  • 标准库中提供了一个 Timer 类,Timer 类的核心方法为 schedule
  • schedule 包含两个参数,第一个参数指定即将要执行的任务代码,第二个参数指定多长时间之后执行(单位为毫秒)
public class Demo37 {
    public static void main(String[] args) {
        Timer timer = new Timer();
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("hello 3000");
            }
        }, 3000);
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("hello 2000");
            }
        }, 2000);

        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("hello 1000");
            }
        }, 1000);
        System.out.println("hello main");
    }
}

1. TimerTask 实现了 Runnable,核心还是重写 run 方法

2. 和线程池一样,Timer中也包含前台线程,会阻止进程结束

实现定时器

1. 创建一个类,表示一个任务

2. 定时器中,要能够管理多个任务,必须使用一些集合类把这多个任务给管理起来。这里使用优先级队列(不要使用 PriorityBlockingQueue,容易死锁)

ArrayList 是否可行呢?

不可行。当管理多个任务的时候,需要确保,时间最早的任务最先执行,ArrayList 要通过遍历的方式,找到时间最早的任务,指定的时间可能不准

3. 实现 schedule 方法。把任务添加到队列中即可

4. 额外创建一个线程,负责执行队列中的任务。和线程池不同,线程池是只要队列不为空,就立即取任务并执行。这里需要看队首元素的时间是否到了,时间到才能执行,时间不到不能执行

// 基于抽象类的方式定义 MyTimerTask 的写法
// 这样的定义虽然确实可以, 但写起来有点麻烦,还有另外的写法
//abstract class MyTimerTask implements Runnable {
//    @Override
//    public abstract void run();
//}


class MyTimerTask implements Comparable<MyTimerTask> {
    private Runnable task;
    // 记录任务要执行的时刻
    private long time;

    public MyTimerTask(Runnable task, long time) {
        this.task = task;
        this.time = time;
    }

    @Override
    public int compareTo(MyTimerTask o) {
        return (int) (this.time - o.time);
        // return (int) (o.time - this.time);
    }

    public long getTime() {
        return time;
    }

    public void run() {
        task.run();
    }
}

// 自己实现一个定时器
class MyTimer {
    private PriorityQueue<MyTimerTask> queue = new PriorityQueue<>();

    // 直接使用 this 作为锁对象, 当然也是 ok 的
    private Object locker = new Object();

    public void schedule(Runnable task, long delay) {
        synchronized (locker) {
            // 以入队列这个时刻作为时间基准.
            MyTimerTask timerTask = new MyTimerTask(task, System.currentTimeMillis() + delay);
            queue.offer(timerTask);
            locker.notify();
        }
    }

    public MyTimer() {
        // 创建一个线程, 负责执行队列中的任务
        Thread t = new Thread(() -> {
            try {
                while (true) {
                    synchronized (locker) {
                        // 取出队首元素
                        // 这里使用 while
                        while (queue.isEmpty()) {
                            locker.wait();
                        }
                        MyTimerTask task = queue.peek();
                        if (System.currentTimeMillis() < task.getTime()) {
                            // 当前任务时间, 如果比系统时间大, 说明任务执行的时机未到
                            locker.wait(task.getTime() - System.currentTimeMillis());
                        } else {
                            // 时间到了, 执行任务
                            task.run();
                            queue.poll();
                        }
                    }
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        t.start();
    }
}

public class Demo38 {
    public static void main(String[] args) {
        MyTimer timer = new MyTimer();
        timer.schedule(new Runnable() {
            @Override
            public void run() {
                System.out.println("hello 3000");
            }
        }, 3000);
        timer.schedule(new Runnable() {
            @Override
            public void run() {
                System.out.println("hello 2000");
            }
        }, 2000);
        timer.schedule(new Runnable() {
            @Override
            public void run() {
                System.out.println("hello 1000");
            }
        }, 1000);

        // 带有线程池的计时器
        Executors.newScheduledThreadPool(4);
    }
}

说明:

1. 优先级队列必须要明确的比较规则。如果元素是 数字 或 String 这种本身就有明确比较规则的对象,可以不额外指定

2. 调用schedule是一个线程,定时器内部又有一个线程,多个线程操作同一个队列,一定涉及到线程安全问题,需要使用锁

3. 锁加到下图这个位置,可能会因为 synchronized 阻塞,使得时间存在一点误差,所以建议把 timeTask 也放到锁里

4. 标准库提供的 Timer 和自己实现的 MyTimer 差不多,都是使用一个线程,负责扫描队首元素并执行的。如果任务少或任务的时间分散问题不大,如果任务特别多,时间非常集中一个线程就可能执行不过来。更好的做法是 一个线程负责扫描,扫描到需要执行的任务,添加到另一个线程池的任务队列中,由多个线程负责执行

5. 标准库中带有线程池的计时器:Executors.newScheduledThreadPool();

时间轮

除了基于堆(优先级队列)的方式来实现的定时器之外,还有一种方案,基于 “时间轮”

时间轮类似循环队列(数组),每个元素是一个 “时间单位”,每个元素又是一个链表。每到一个时间单位,光标指向下一个元素,同时把这个元素上对应链表中的任务都执行一遍

优势:性能更高(优先级队列有堆调整的开销)

劣势:时间精度不如优先级队列

时间轮更适合任务特别多的情况

优先级队列更适合精度高的情况

由于定时器,是一个非常重要的组件,以至于在分布式系统中,会把定时器专门提取出来,封装成一个单独的服务器(和消息队列很像)


常见的锁策略

如果你认为标准库提供的锁不够用,想自己实现一把锁,这时需要关注锁策略(其实 synchronized 已经非常好用了,足矣覆盖绝大多数的使用场景)

“锁策略” 不是和Java强相关的。其他语言,但凡涉及到并发编程,涉及到锁,都可以谈到锁策略

锁策略,就是锁在加锁时特点和行为

乐观锁 vs 悲观锁

描述的是加锁时遇到的场景

悲观锁:加锁的时候,预测接下来的锁竞争的情况非常激烈,就需要针对这样的激烈情况额外做一些工作

有一把锁,有二十个线程尝试获取锁,每个线程加锁的频率都很高。一个线程加锁的时候,很可能锁被另一个线程占用着 。这种情况用悲观锁的方式处理

乐观锁:加锁的时候,预测接下来的锁竞争的情况不激烈,不需要做额外工作

有一把锁,假设只有两个线程尝试获取这个锁。每个线程加锁的频率都很低,一个线程加锁的时候,大概率另一个线程没有和他竞争。这种情况用乐观锁的方式处理

重量级锁 vs 轻量级锁

遇到场景之后的解决方案

重量级锁,在悲观的场景下,就要付出更多的代价(更低效)

轻量级锁,应对乐观的场景,付出的代价就会更小(更高效)

挂起等待锁 vs 自旋锁

自旋锁:轻量级锁的典型实现

是应用程序级别的。加锁的时候发现有竞争,一般不是进入阻塞,而是通过忙等的形式进行等待

忙等:连续测试一个变量直到某个值出现为止,称为忙等。忙了很长时间,又没有实质性的产出,还持续占着 CPU 而不释放,这个时候应该释放 CPU 让给其他进程

^

在乐观锁的场景下,遇到锁竞争的概率很小,就算遇到竞争,也能在短时间内就能拿到锁,获取锁的周期很短,所以使用忙等的方式影响不大

挂起等待锁:重量级锁的典型实现

是操作系统内核级别的。加锁的时候发现有竞争,就会使该线程进入阻塞状态,后续由内核进行唤醒

获取锁的周期更长,很难做到及时获取,所以这个过程就不必使用忙等的方式一直消耗 cpu 了,把 cpu 省出来做别的事情

synchronized 是悲观 / 乐观呢?

答:既是悲观又是乐观,可以自适应。

VM内部会统计每个锁竞争的激烈程度,如果竞争不激烈,synchronized 就会按照轻量级锁(自旋),如果竞争激烈,synchronized 就会按照重量级锁(挂起等待)

普通互斥锁 vs 读写锁

普通互斥锁:synchronized;只有加锁、解锁两个操作

读写锁:有两种加锁方式:读方式加锁、写方式加锁

1. 多个线程读取一个数据,本身就是线程安全的。多个线程读取,一个线程修改,肯定会涉及到线程安全问题。如果大部分操作在读,少数操作在写,把读和写都加普通的互斥锁,锁冲突非常严重,所以就引入了读写锁

2. 读写锁可以确保,读锁和读锁之间不是互斥的(不会产生阻塞),写锁和读锁之间,才会产生互斥,写锁和写锁之间,也有互斥。

3. 读写锁保证线程安全的前提下,降低锁冲突的概率,提高效率

4. 读写锁适合 读多,写少 的情况,这种情况在服务器开发中很常见

Java 标准库提供了 ReentrantReadWriteLock 类,实现了读写锁

这两个内部类各自提供了 lock / unlock 方法进行加锁解锁

可重入锁 vs 不可重入锁

一个线程,一把锁,连续加锁多次,是否会死锁,死锁了就是不可重入,没有死锁就是可重入

synchronized是 “可重入锁”

核心要点:

1. 在锁中记录当前是哪个线程拿到的这把锁

2. 使用计数器,记录当前加锁了多少次,在合适的时候进行解锁

公平锁 vs 非公平锁

假设三个线程 A,B,C。A 先尝试获取锁,获取成功,然后B再尝试获取锁,获取失败,阻塞等待;然后 C 也尝试获取锁,C也获取失败,也阻塞等待。当线程 A 释放锁的时候,B 和 C 谁先拿到锁呢?

公平锁:遵守 “先来后到”,B 比 C 先来的,当 A 释放锁的之后,B 就能先于 C 获取到锁
非公平锁:不遵守 “先来后到” B 和 C 都有可能获取到锁(synchronized 就是非公平锁)

要想实现公平锁,需要付出额外的代价,比如,需要使用一个队列,记录一下各个线程获取锁的顺序

总结

1. synchronized 在上述策略中,是什么样的锁?

是自适应的,乐观锁、悲观锁、重量级锁、轻量级锁、挂起等待锁、自旋锁,这些都可以是;

不是读写锁;是可重入锁,非公平锁

2. 介绍下读写锁?

读写锁就是把读操作和写操作分别进行加锁

  • 读锁和读锁之间不互斥
  • 写锁和写锁之间互斥
  • 写锁和读锁之间互斥

读写锁最主要用在“频繁读,不频繁写”的场景中

3. 什么是自旋锁,为什么要使用自旋锁策略呢,缺点是什么?

如果获取锁失败,立即再尝试获取锁,无限循环,直到获取到锁为止。第一次获取锁失败,第二次的尝试会在极短的时间内到来,一旦锁被其他线程释放,就能第一时间获取到锁

相比于挂起等待锁,

优点:没有放弃CPU资源,一旦锁被释放就能第一时间获取到锁,更高效;在锁持有时间比较短的场景下非常有效

缺点:如果锁的持有时间较长,就会浪费CPU资源

4. synchronized 是可重入锁么?

是可重入锁

可重入锁是指连续两次加锁不会导致死锁.

实现的方式是

1)在锁中记录当前是哪个线程拿到的这把锁

2)使用计数器,记录当前加锁了多少次,在合适的时候进行解锁

synchronized 原理

基本特点

结合上面的锁策略,可以总结出 synchronized 具有以下特性(JDK1.8):

1. 开始时是乐观锁,如果锁冲突频繁,就转换为悲观锁

2. 开始是轻量级锁实现,如果锁被持有的时间较长,就转换成重量级锁

3. 实现轻量级锁时,大概率用的是自旋锁策略

4.是一种不公平锁

5. 是一种可重入锁

6. 不是读写锁

加锁工作过程

JVM 将 synchronized 锁分为无锁、偏向锁、轻量级锁、重量级锁状态。会根据情况,依次进行升级


偏向锁

1)加锁时,刚一上来不是真加锁,而是只是简单做一个标记,这个标记非常轻量,相比于加锁解锁来说,效率高很多。

2)如果没有其他线程来竞争这个锁,最终当前线程执行到解锁代码,也就只是简单清除上述标记即可(不涉及真加锁,真解锁)

3)如果有其他线程来竞争,会抢先一步,在另一个线程拿到锁之前,抢先拿到锁,此时才是真正加上锁(其他线程只能阻塞等待),偏向锁就升级成轻量级锁了

4)偏向锁也是懒汉模式思想的体现

锁升级的时机/条件:

无锁 => 偏向锁:代码进入 synchronized 的代码块

偏向锁 => 轻量级锁:拿到偏向锁的线程运行过程中,遇到了其他线程尝试竞争这个锁

轻量级锁 => 重量级锁:JVM发现,当前竞争锁的情况非常激烈

JVM 中,只能 “锁升级” 不能 “锁降级” 。可能是因为,实现降级的收益不大,并且会使 JVM 相关代码的复杂度更高

Logo

助力广东及东莞地区开发者,代码托管、在线学习与竞赛、技术交流与分享、资源共享、职业发展,成为松山湖开发者首选的工作与学习平台

更多推荐