【阿里面试系列】Java线程的应用及挑战

文章简介

上一篇文章【「阿里面试系列」搞懂并发编程,轻松应对80%的面试场景】我们了解了进程和线程的发展历史、线程的生命周期、线程的优势和使用场景,这一篇,我们从Java层面更进一步了解线程的使用。关注我的技术公众号【架构师修炼宝典】一周出产1-2篇技术文章。Q群725219329分享并发编程,分布式,微服务架构,性能优化,源码,设计模式,高并发,高可用,Spring,Netty,tomcat,JVM等技术视频。

内容导航

  1. 并发编程的挑战
  2. 线程在Java中的使用

并发编程的挑战

引入多线程的目的在第一篇提到过,就是为了充分利用CPU是的程序运行得更快,当然并不是说启动的线程越多越好。在实际使用多线程的时候,会面临非常多的挑战

线程安全问题

线程安全问题值的是当多个线程访问同一个对象时,如果不考虑这些运行时环境采用的调度方式或者这些线程将如何交替执行,并且在代码中不需要任何同步操作的情况下,这个类都能够表现出正确的行为,那么这个类就是线程安全的
比如下面的代码是一个单例模式,在代码的注释出,如果多个线程并发访问,则会出现多个实例。导致无法实现单例的效果

public class SingletonDemo {
   private static SingletonDemo singletonDemo=null;
   private SingletonDemo(){}
    public static SingletonDemo getInstance(){
        if(singletonDemo==null){/***线程安全问题***/
           singletonDemo=new SingletonDemo();
        }
        return singletonDemo;
    }
}

通常来说,我们把多线程编程中的线程安全问题归类成如下三个,至于每一个问题的本质,在后续的文章中我们会单独讲解

  1. 原子性
  2. 可见性
  3. 有序性

上下文切换问题

在单核心CPU架构中,对于多线程的运行是基于CPU时间片切换来实现的伪并行。由于时间片非常短导致用户以为是多个线程并行执行。而一次上下文切换,实际就是当前线程执行一个时间片之后切换到另外一个线程,并且保存当前线程执行的状态这个过程。上下文切换会影响到线程的执行速度,对于系统来说意味着会消耗大量的CPU时间

减少上下文切换的方式

  1. 无锁并发编程,在多线程竞争锁时,会导致大量的上下文切换。避免使用锁去解决并发问题可以减少上下文切换
  2. CAS算法,CAS是一种乐观锁机制,不需要加锁
  3. 使用与硬件资源匹配合适的线程数

死锁

在解决线程安全问题的场景中,我们会比较多的考虑使用锁,因为它使用比较简单。但是锁的使用如果不恰当,则会引发死锁的可能性,一旦产生死锁,就会造成比较严重的问题:产生死锁的线程会一直占用锁资源,导致其他尝试获取锁的线程也发生死锁,造成系统崩溃

以下是死锁的简单案例

public class DeadLockDemo {
    //定义锁对象
    private final Object lockA = new Object();
    private final Object lockB = new Object();
    private void deadLock(){
        new Thread(()->{
            synchronized (lockA){
                try {
                    Thread.sleep(4000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lockB){
                    System.out.println("Lock B");
                }
            }
        }).start();
        new Thread(()->{
            synchronized (lockB){
                synchronized (lockA){
                    System.out.println("Lock A");
                }
            }
        }).start();
    }
    public static void main(String[] args) {
        new DeadLockDemo().deadLock();
    }
}

通过jstack分析死锁

1.首先通过jps获取当前运行的进程的pid

6628 Jps
17588 RemoteMavenServer
19220 Launcher
19004 DeadLockDemo

2.jstack打印堆栈信息,输入 jstack19004, 会打印如下日志,可以很明显看到死锁的信息提示

Found one Java-level deadlock:
=============================
"Thread-1":
  waiting to lock monitor 0x000000001d461e68 (object 0x000000076b310df8, a java.lang.Object),
  which is held by "Thread-0"
"Thread-0":
  waiting to lock monitor 0x000000001d463258 (object 0x000000076b310e08, a java.lang.Object),
  which is held by "Thread-1"

解决死锁的手段
1.保证多个线程按照相同的顺序获取锁
2.设置获取锁的超时时间,超过设定时间以后自动释放
3.死锁检测

资源限制

资源限制主要指的是硬件资源和软件资源,在开发多线程应用时,程序的执行速度受限于这两个资源。硬件的资源限制无非就是磁盘、CPU、内存、网络;软件资源的限制有很多,比如数据库连接数、计算机能够支持的最大连接数等
资源限制导致的问题最直观的体现就是前面说的上下文切换,也就是CPU资源和线程资源的严重不均衡导致频繁上下文切换,反而会造成程序的运行速度下降

资源限制的主要解决方案,就是缺啥补啥。CPU不够用,可以增加CPU核心数;一台机器的资源有限,则增加多台机器来做集群。

线程在Java中的使用

在Java中实现多线程的方式比较简单,因为Java中提供了非常方便的API来实现多线程。
1.继承Thread类实现多线程
2.实现Runnable接口
3.实现Callable接口通过Future包装器来创建Thread线程,这种是带返回值的线程
4.使用线程池ExecutorService

关注我的技术公众号【架构师修炼宝典】一周出产1-2篇技术文章。Q群725219329分享并发编程,分布式,微服务架构,性能优化,源码,设计模式,高并发,高可用,Spring,Netty,tomcat,JVM等技术视频。

继承Thread类

继承Thread类,然后重写run方法,在run方法中编写当前线程需要执行的逻辑。最后通过线程实例的start方法来启动一个线程

public class ThreadDemo extends Thread{
    @Override
    public void run() {
        //重写run方法,提供当前线程执行的逻辑
        System.out.println("Hello world");
    }
    public static void main(String[] args) {
        ThreadDemo threadDemo=new ThreadDemo();
        threadDemo.start();
    }
}

Thread类其实是实现了Runnable接口,因此Thread自己也是一个线程实例,但是我们不能直接用 newThread().start()去启动一个线程,原因很简单,Thread类中的run方法是没有实际意义的,只是一个调用通过构造函数传递寄来的另一个Runnable实现类的run方法,这块的具体演示会在Runnable接口的代码中看到

public
class Thread implements Runnable {
    /* What will be run. */
    private Runnable target;
    ...
    @Override
    public void run() {
        if (target != null) {
            target.run();
        }
    }
    ...

实现Runnable接口

如果需要使用线程的类已经继承了其他的类,那么按照Java的单一继承原则,无法再继承Thread类来实现线程,所以可以通过实现Runnable接口来实现多线程

public class RunnableDemo implements Runnable{
    @Override
    public void run() {
        //重写run方法,提供当前线程执行的逻辑
        System.out.println("Hello world");
    }
    public static void main(String[] args) {
        RunnableDemo runnableDemo=new RunnableDemo();
        new Thread(runnableDemo).start();
    }
}

上面的代码中,实现了Runnable接口,重写了run方法;接着为了能够启动RunnableDemo这个线程,必须要实例化一个Thread类,通过构造方法传递一个Runnable接口实现类去启动,Thread的run方法就会调用target.run来运行当前线程,代码在上面.

实现Callable接口

在有些多线程使用的场景中,我们有时候需要获取异步线程执行完毕以后的反馈结果,也许是主线程需要拿到子线程的执行结果来处理其他业务逻辑,也许是需要知道线程执行的状态。那么Callable接口可以很好的实现这个功能

public class CallableDemo implements Callable{
    @Override
    public String call() throws Exception {
        return "hello world";
    }
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        Callable callable=new CallableDemo();
        FutureTask task=new FutureTask<>(callable);
        new Thread(task).start();
        System.out.println(task.get());//获取线程的返回值
    }
}

在上面代码案例中的最后一行 task.get()就是获取线程的返回值,这个过程是阻塞的,当子线程还没有执行完的时候,主线程会一直阻塞直到结果返回

使用线程池

为了减少频繁创建线程和销毁线程带来的性能开销,在实际使用的时候我们会采用线程池来创建线程,在这里我不打算展开多线程的好处和原理,我会在后续的文章中单独说明。

public class ExecutorServiceDemo {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        //创建一个固定线程数的线程池
        ExecutorService pool = Executors.newFixedThreadPool(1);
        Future future=pool.submit(new CallableDemo()); 
        System.out.println(future.get());
    }
}

pool.submit有几个重载方法,可以传递带返回值的线程实例,也可以传递不带返回值的线程实例,源代码如下

/*01*/Future submit(Runnable task);
/*02*/ Future submit(Runnable task, T result);
/*03*/ Future submit(Callable task);

关注我的技术公众号【架构师修炼宝典】一周出产1-2篇技术文章。Q群725219329分享并发编程,分布式,微服务架构,性能优化,源码,设计模式,高并发,高可用,Spring,Netty,tomcat,JVM等技术视频。

image

你可能感兴趣的:(【阿里面试系列】Java线程的应用及挑战)