初识多线程

在编程学习中,我们都要接触到多线程,多线程会帮系统性能带来很大提升,但是这种提升是风险与收益并存的。因此,学习并掌握多线程原理至关重要。

单线程与多线程

既然有多线程,那肯定是有单线程的。那单线程是什么呢?单线程就是在特定时间系统只做一件事,这就和人一样只能「一心一意」,不能「三心二意」。

平时开发中跑的 Java 程序大多都是单应用,对于一些没有什么访问量的系统来说,能勉强支撑。反之,访问量较大的话,一个线程难以处理,很影响用户的体验。就像一片麦子一个人割,和10个人一起割时间完全不同的。因此,我们就需要用到多线程来解决性能问题。

创建多线程

在 Java 设计之初,就考虑到了线程,因此「多线程」在 Java 中是提供了语言级别的支持。在 Java 中使用多线程也很方便。创建线程最简单的方法就是使用 new Thread(),如下代码所示

public class Demo {
    public static void main(String[] args) {
        new Thread(Demo::wheatHarvest);
        new Thread(Demo::hoeing);
    }

    private static void wheatHarvest(){
        System.out.println("开始割麦子");
    }

    private static void hoeing(){
        System.out.println("开始锄地");
    }
}

上面创建两个线程,一个给他 wheatHarvest()割麦子的任务,另一个给他 hoeing()锄地的任务。创建了两个线程之后,这两件事是同时执行的,并不会说非要割完麦子,才去锄地。但上面只是创建了线程,分配了要执行的任务,线程并没有执行,要让线程执行必须调用线程的 start()方法,线程才会执行。如下

new Thread(Demo::wheatHarvest).start();
new Thread(Demo::hoeing).start();

start 和 run 的区别

刚开始学习多线程的时候经常将这两个方法搞混,现在重新复习一下。

start 方法就是让线程开始执行,而 run 是要等待线程执行完才往下执行。如下,start 方法可以让割麦子和锄地同时运行

new Thread(Demo::wheatHarvest).start();
new Thread(Demo::hoeing).start();

而 run 方法则是,先割麦子,等麦子割完了,然后再去锄地

new Thread(Demo::wheatHarvest).run();
new Thread(Demo::hoeing).run();

在这个方法上实现中,run 方法和我们的单线程基本没区别,而且还额外增加了线程的开销。

线程安全问题

既然多线程能给系统性能带来提升,那我们就可以无脑使用吗?答案肯定不是的。既然享受了多线程给我们带来性能上的提升,同时我们也要承担多线程带来的安全问题。

问题来源

多线程问题的来源就是,所有被线程共享的变量。当多个线程去操作一个共享变量的时候,线程安全问题就会出现。我们看如下代码,我们使用多线程操作,把共享变量 i 自增1,把结果打印看看会发生什么

public class Demo {
    private static int i = 0;

    public static void main(String[] args) {
        for (int i = 0; i < 100; i++) {
            new Thread(Demo::modifySharedVariable).start();
        }

    }
    
    private static void modifySharedVariable() {
        i++;
        System.out.println(i);
    }
}

这份代码,肯定会有人认为程序会按顺序输出 1~100,实际上不是如此。你可以把代码复制到自己的环境中运行,得出的结果可能会出乎你的意料,输出的顺序都乱了,并且可能会出现重复输出。为什么会出现这种情况呢?这是因为 i++操作并非是一个原子操作

那什么是原子操作呢?原子操作就是一个不可分割的操作,之所以称作「原子操作」,我猜想和高中学的化学有关,原子是不可再分割的粒子,因此计算机就引用了这一概念。在多线程中,一个事情在某一时刻只能被一个线程操作,称作原子操作

回到刚才的 i++操作,表面上我们以为是一步操作,实际上它包含了三个步骤:第一步,取 i 的值。第二步,把 i 的值加 1。第三步,把修改后的值写回 i。

我们使用两个线程来举例,线程1,线程2。线程1先执行,执行到 i ++的第二步操作,先取 i 的值,取到 i 的值为 0,然后把值加1,这个时候值变为 2,但是请注意,这个值并没有写回 i 中。这时,线程2开始执行了,它把 i++ 操作执行完了。由于线程1并没有把值写回 i 中,因此线程2 取到 i 的值依然为 0,把值加 1,写回到 i 中,完成 i++ 操作,并执行输出语句,打印 i 的值为1,到此线程2完成了。此时线程1继续执行刚才未完成的步骤,把值写回到 i 中,刚才线程1计算的值为1,因此把 1 写回到 i 中,完成 i++操作,打印 i 的值仍然为1。经过上面的操作,会发现 i 被重复写入了,因此我们无法保证输出的结果是 1~100。

你可能会有疑问,为什么线程1执行的好好的,线程2突然就插一脚呢?

这是因为,在微观上(cpu 眼中),多线程问题来源就是 cpu 的上下文切换,每个线程都会占用固定的时间周期,超过时间换线程执行。上面的例子就刚好是 cpu 的上下文切换,导致了1 这个值重复写入到 i 中。

我们打开 QQ,打开微信,打开浏览器,我们都认为他们是在同时运行的。但实际上都是 cpu 在进行切换,一会切换到微信,一会切换到浏览器,一会到 QQ,由于这个速度很快,我们就主观的认为他们是在同时运行的。多线程也是如此。

线程不安全的表现(死循环,死锁,哲学家用餐)

著名的 HashMap 的死循环问题可以点击该链接

死锁详解

以下是一个简单的死锁 Demo

public class Demo {
    private static Object lock1 = new Object();
    private static Object lock2 = new Object();

    public static void main(String[] args) {
        new Thread1().start();
        new Thread2().start();
    }

    static class Thread1 extends Thread {
        @Override
        public void run() {
            synchronized (lock1) {
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                synchronized (lock2) {
                    System.out.println("拿到lock2");
                }
            }
        }
    }


    static class Thread2 extends Thread {
        @Override
        public void run() {
            synchronized (lock2) {
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                synchronized (lock1) {
                    System.out.println("拿到lock2");
                }
            }
        }
    }
}

以上这段代码运行之后,控制台并没有输出,但是程序还在一直执行。代码最初声明了两把锁 lock1 和 lock2,使用synchronized 关键字,去获取锁。例如 synchronized(lock1)表示,需要拿到 lock1 才会执行后面的代码块,执行完{}包裹的代码块,锁才会释放。需要注意的是,一把锁同时只能有一个线程拿到

因此上面的程序执行流程是,主线程开辟了两个线程,Thread1 和 Thread2,并开始执行。其中 Thread1 需要先获取 lock1,获取之后线程休眠 500ms,然后再去获取 lock2。Thread2 则是先去拿到 lock2,休眠 100ms,然后再去拿 lock1。当 Thread1 去拿 lock2 时,发现 lock2 被拿了,于是 Thread1 等待;Thread2 准备去拿 lock1 的时候发现,lock1 被拿走了,于是 Thread2 等待。Thread1 和 Thread2 都在等待彼此释放自己需要的锁,于是产生了死锁等待。

简单的死锁排查

既然死锁了,我们就要需要先拿到死锁的进程 id

在 Linux 中使用 ps aux | grep java,列出所有 java 进程的 id。或者使用 java 自带的 jps 命令,列出所有 java 进程。

之后使用 jstack 命令打印进程的栈信息,通过输出的栈信息来排查死锁。

一个经典的多线程问题哲学家用餐

预防死锁产生的原则:所有的线程按照相同的顺序获取资源的锁。上面的例子 Thread1 和 Thread2 获取锁的顺序不不一致,Thread1 先拿 lock1,Thread2 先拿 lock2。假如两个线程都先去拿 lock1 或者 lock2,那就不会产生死锁了。

实现线程安全的基本手段

为了规避和解决线程带来的安全问题,我们可以采取一些措施

使用不可变类

使用 Integer / String 这些不可变类。

使用 synchronized 同步块

方法一:synchronized(一个对象)把这个对象当成锁

public class Demo {
    private static Object lock1 = new Object();
    private static int i = 0;

    public static void main(String[] args) {
        for (int i = 0; i < 100; i++) {
            new Thread(Demo::modifySharedVariable).start();
        }
    }
    
    private static void modifySharedVariable() {
        synchronized (lock1) {
            i++;
            System.out.println(i);
        }
    }
}

还是上面的代码,我们把声明了一个锁,并在 modifySharedVariable方法体中使用 synchronized关键字。这样线程每次执行这个方法的时候,都会先去获取 lock1,当代码块中的代码执行后,lock1 被释放,其他的线程才能继续拿 lock1 去执行。

方法二:static synchronized 方法,把 Class 对象当成锁

public class Demo {
    private static int i = 0;

    public static void main(String[] args) {
        for (int i = 0; i < 100; i++) {
            new Thread(Demo::modifySharedVariable).start();
        }
    }

    private synchronized static void modifySharedVariable() {
        i++;
        System.out.println(i);
        
    }
}

这次不声明锁,而是直接在 modifySharedVariable方法上使用 synchronized关键字。在 static 方法上使用 synchronized,实际上是把这个类的 Class 对象当成锁。因此每次访问这个方法都要去拿到 Class 对象,也保证了 i++ 顺序执行。

方法三:实例的 synchronized 方法把该类的实例当成锁。(调用的对象)

public class Demo {
    private static int i = 0;

    public static void main(String[] args) {
        Demo object = new Demo();

        for (int i = 0; i < 100; i++) {
            new Thread(object::modifySharedVariable).start();
        }
    }

    private synchronized void modifySharedVariable() {
        i++;
        System.out.println(i);

    }
}

当 synchronized 声明在普通的方法上,实际上是把调用的对象当成锁。上面的代码是通过 Demo 类的一个实例 object 来调用 modifySharedVariable 方法的。因此,object 就被当成锁,也保证了 i++ 顺序执行。上面的代码也可以改成这样

public class Demo {
    private static int i = 0;

    public static void main(String[] args) {
        Demo object = new Demo();

        for (int i = 0; i < 100; i++) {
            new Thread(object::modifySharedVariable).start();
        }
    }

    private void modifySharedVariable() {
        synchronized (this){
            i++;
            System.out.println(i);
        }
    }
}

使用 Collections 工具类

我们可以使用 Java 给我们提供的 Collection 的工具类,把不安全的 Collection 变成安线程全的。像 Collections.synchronizedList(),Collections.synchronizedSet() 等等

例如将普通的 Map 变成线程安全的 Map

Map<Integer, Integer> map = Collections.synchronizedMap(new HashMap<>());

 

使用 JUC(java.util.concurrent) 包

juc 包提供了很多线程安全的类,遇到线程安全问题,我们都可以把不是线程安全的类,换成线程安全的。

原子操作类:AtomicInteger、AtomicBoolean..,之前的我们得 i++ 操作不是原子的,可以使用原子操作类 AtomicInteger,来进行替换。使用 AtomicInteger 的 incrementAndGet方法,就可以实现原子自增1的操作。

线程安全集合:ConcurrentHashMap,ConcurrentLinkedQueue 等。在任何使用 HashMap 有线程安全问题的地方,都可以无脑使用ConcurrentHashMap 替换

ReentrantLock (可重入锁)

ReentrantLock 所做的事情和 synchronized 几乎一样。

区别在于 ReentrantLock 可以自己定义加锁和解锁时机。使用 synchronized 关键字,执行完代码块中的代码,锁就会释放,但是有的时候我们需要在其他地方释放锁,而不是执行完就释放。因此可以使用 ReentrantLock 加锁,在适当的时机解锁

public class Demo {
    private static final ReentrantLock reentrantLock = new ReentrantLock();
    private int i = 0;

    public static void main(String[] args) {

        Demo object = new Demo();
        for (int i = 0; i < 100; i++) {
            new Thread(object::modifySharedVariable).start();
        }
    }

    private void modifySharedVariable() {
        reentrantLock.lock();
        i++;
        reentrantLock.unlock();
        System.out.println(i);
    }
}

还是之前的例子,如果你对输出结果并不关心的话,进入到 modifySharedVariable方法后,先使用 reentrantLock.lock() 获取锁,等 i++ 操作结束之后,使用 reentrantLock.unLock() 释放锁。reentrantLock 获取锁和释放锁的操作时机,都可以根据实际情况自己定义。

Tips:可重入锁相关概念。如下 Demo

public class Demo {
    public static void main(String[] args) {
        Demo object = new Demo();
        object.a();
    }

    private synchronized void a() {
        System.out.println("a");
        b();
    }

    private synchronized void b() {
        System.out.println("b");
    }
}

a 和 b 方法都使用 synchronized 声明了,主线程调用声明了一个实例调用 a 方法。根据前面学的知识,调用 a 方法需要拿到实例锁,然后执行 a 方法。a 方法中又调用了 b 方法,b 方法也需要拿到实例锁,但是因为 a 已经拿到了实例锁,并且 synchronized 也是可重入锁,所以调用 a 方法中调用 b 方法无需再去获取实例锁,这就是可重入锁的概念。

可以点击查看 StackOverflow 上的大牛对于可重入锁的概念的理解 链接

Object 类里的线程方法

说方法之前,了解下 Java 线程中的 6 种状态

  1. 初始(NEW),创建一个线程对象,但没有调用 start 方法
  2. 运行(RUNNABLE),开始执行操作(得到 CPU 使用权)
  3. 阻塞(BLOCKED),线程阻塞与锁
  4. 等待(WAITING),需要其他线程唤醒,或中断
  5. 超时等待(TIMED_WAITING),可以指定时间后,自行返回
  6. 终止(TERMINATED),线程执行完毕

使用代码解释这几个状态

public class Demo {

    private static int i = 0;

    private static Object lock = new Object();

    public static void main(String[] args) {
        new Thread(Demo::modifySharedVariable).start();
        new Thread(Demo::modifySharedVariable).start();
        new Thread(Demo::modifySharedVariable).start();
    }

    public static void modifySharedVariable() {
        synchronized (lock) {
            i++;
            System.out.println(i);
            lock.wait();
        }
    }
}

用 new 一个线程的时候就是初始状态,调用了 start 方法,线程进入运行状态。假如第一个线程更快一步拿到 lock,这时其它两个线程就处于阻塞状态,当 lock 调用 wait() 方法,拿到 lock 的线程就进入等待状态,然后释放 lock,当线程方法执行完之后线程进入终止状态

wait() 方法

让当前线程进入等待状态。调用 wait 方法之前,必须先拿到锁。当调用 wait 方法之后,拿到的锁也就会释放。

notify()

随机唤醒一个处于等待状态的线程。

notifyAll()

唤醒所有处于等待状态的线程。

线程池与 Callable / Future

什么是线程池

在前面提到,我们每次使用线程都要先创建一个线程,然后使用给他分配任务,最后调用他的 start 方法执行这个任务。上面的步骤看起来没多大问题,仔细想想看,要是任务一多,每次分配任务的时候都要创建一个新的线程,这个创建线程的花销在 Java 世界中是很「昂贵的」。

类比到生活中,公司每次新接一个项目都去招一些人,做完项目就炒了,然后下次又来一个新的项目,又要去招人,这对于 HR 来说很麻烦。市面上的策略大多都是,招一群有潜力的人才,然后公司培养,有项目来就参与项目开发,下次再遇到新项目还是用之前招的人,这样就减少了公司频繁找人的开销。

线程池就是预先定义好若干个线程,每次需要线程的时候就去调用,避免了每次创建线程的开销,这与公司找人的策略是一样的。

定义线程池

使用 Executors 类去创建相应的线程池,并且可以配置线程的信息,使用 newFixedThreadPool方法,创建固定数量的线程池。

ExecutorService executorService = Executors.newFixedThreadPool(10);

Runable 与 Callable

在线程池中,都是使用 submit 方法提交并执行任务。使用 submit 方法的时候可以发现,它接收两个不同类型的参数。一个是与之前多线程相同的参数 Runable,而另一个则是 Callable。通过查看两者源代码可以发现,前者是没有返回值的,而 Callable 则有返回值。

我们可以发现 submit 方法返回了一个 Future 对象,Future 泛型的值与 Callable 里 call 方法返回的值是一样的。

Future

Future 表示异步计算的结果,也可以理解未来返回的结果。例如:我们让一个工人去割麦子,他去执行之后,我们只需要看看仓库是否增加了这么多麦子即可。工人割麦子的时候,我们可以去干其他事情,只需最后看结果就行。这个 Future 就是工人割的麦子总量。

Future 的常用 API

  • get() 方法即可拿到返回的数据(拿到麦子)
  • get(long timeout, TimeUnit unit) 给定等待时间,拿到结果(在规定时间内,检查割了多少麦子)
  • cancel() 方法取消当前线程的任务(不让工人割麦子)
  • isCancelled() 方法判断当前线程是否在正常结束之前被取消(不让工人割麦子之后,检查麦子是否割完)
  • isDone() 判断当前线程是否执行完成(检查麦子是否割完)

使用多线程实现 Word Count

WordCount 就是给定一段或者多段文本(假设每个单词之间都是用空格分隔),记录每个单词出现的次数。

实现思路:可以定义一个线程池,线程池中线程的数量可以根据参数传递。每个线程的任务就是读取文件的一行,然后统计该行每个单词出现的次数。最后把每个线程执行的结果汇总,这样就完成了。

先把整个思路的代码写好,通过参数 threadNum定义了线程池中线程的数量。使用 Map<String,Integer> 记录单词出现的次数,Future<Map<String, Integer>> 表示线程返回的结果, List<Future<Map<String, Integer>>> 就表示多个线程返回的集合。

由于有多个文件,因此使用 for 循环对每个文件都要进行统计操作。threadPool.submit(() -> workJob(file)) 提交了任务并执行,任务就是 workJob,即统计一行,单词出现的次数,返回的结果是Future<Map<String,Integer>>,再用刚才定义好的集合 futures,把所有线程返回的结果收集起来。

futures 收集完成后,开始遍历这个集合,把线程返回的结果进行统计合并,使用 mergeWorkResultIntoFileResult得到一个最终的结果,然后把最终的结果返回,程序执行结束。

public static Map<String, Integer> count(int threadNum, List<File> files) throws ExecutionException, InterruptedException {

        ExecutorService threadPool = Executors.newFixedThreadPool(threadNum);

        List<Future<Map<String, Integer>>> futures = new ArrayList<>();

        for (File file : files) {
            futures.add(threadPool.submit(() -> workJob(file)));
        }

        Map<String, Integer> finalResult = new HashMap<>();
        for (Future<Map<String, Integer>> workResult : futures) {
            mergeWorkResultIntoFileResult(workResult.get(), finalResult);
        }

        return finalResult;
    }

接下来是具体的方法实现,首先是 workJob() 方法。统计一行文本中,单词出现的次数。

先读取一行文本保存到字符串,然后 split() 方法对单词进行分割,得到单词数组 words。把单词数组遍历,map 记录单词出现的次数。其中 result.getOrDefault(word, 0) + 1 方法表示:从 map 中拿到 key 为 word 的值,如果存在这个 key,就对这个 key 的值进行 +1 操作,然后 put 进去。如果不存在这个 key,就使它的值默认为 0,然后再进行 +1 操作,put 进去。

private static Map<String, Integer> workJob(File file) throws IOException {
     BufferedReader bufferedReader = new BufferedReader(new FileReader(file));

     Map<String, Integer> result = new HashMap<>();
     String line;
     while ((line = bufferedReader.readLine()) != null) {

         String[] words = line.split(" ");
         for (String word : words) {
             result.put(word, result.getOrDefault(word, 0) + 1);
         }
     }
     return result;
 }

最后的工作 mergeWorkResultIntoFileResult(),把线程返回的结果,合并统计,得到最终结果。统计的操作和 workJob() 方法类似。把合并的结果返回即可。

private static Map<String, Integer> mergeWorkResultIntoFileResult(Map<String, Integer> workResult, Map<String, Integer> fileResult) {
    
    for (Map.Entry<String, Integer> entrySet : workResult.entrySet()) {
        String word = entrySet.getKey();
        int mergerResult = fileResult.getOrDefault(word, 0) + entrySet.getValue();
        fileResult.put(word, mergerResult);
    }

    return fileResult;
}

至此,一个使用多线程的 wordcount 功能实现。代码可以点击看源代码,后续会在代码中更新其他方法实现 WordCount。

多线程应用场景

  • 不推荐:对于 cpu 密集型应用稍有折扣。cpu 密集型操作会把 cpu 跑满,因此再使用多线程去操作,性能上很难有提升。

  • 推荐:IO 密集型操作(文件 IO,网络 IO),这两个操作相比 cpu 的执行速度慢如蜗牛,因此用多线程来执行,性能上会有很大提升。

  • 多线程性能提升的上限:单核 cpu 100%,如果是多核就是 N*100%。当 cpu 跑满的时候,就很难有「闲工夫」去处理其他请求。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值