欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

面试系列(六):多线程

程序员文章站 2024-03-25 19:17:40
...

差点把多线程给忘了。。。。  多线程基本上去每个公司面试都会问到……

 

 

1、谈谈你对多线程的理解

线程:表示程序的执行流程,是CPU调度执行的基本单位

多线程:指的是一个程序(一个进程)运行时产生了不止一个线程,使用多线程的好处,在于并行的执行多任务,彼此独立,可以提高执行效率。

 

2、实现多线程的方式

在java中实现多线程有多种途径:继承Thread类,实现Runnable接口,实现Callable接口,线程池负责创建。

一个线程对象只能启动一个线程,无论你调用多少遍start()方法,结果只有一个线程。

Thread.start()方法(native)启动线程,使之进入就绪状态,当cpu分配时间该线程时,由JVM调度执行run()方法。 (调用start时不一定立即执行)

 

比较推荐实现Runnable接口的方式,原因如下:

(1)适合多个相同程序代码的线程去处理同一资源的情况,把虚拟CPU(线程)同程序的代码,数据有效的分离,较好地体现了面向对象的设计思想。 (可联想到模拟火车站卖票的例子)

 

(2)可以避免由于Java的单继承特性带来的局限。我们经常碰到这样一种情况,即当我们要将已经继承了某一个类的子类放入多线程中,由于一个类不能同时有两个父类,所以不能用继承Thread类的方式,那么,这个类就只能采用实现Runnable接口的方式了。 (单继承多实现)

 

(3)有利于程序的健壮性,代码能够被多个线程共享,代码与数据是独立的。当多个线程的执行代码来自同一个类的实例时,即称它们共享相同的代码。多个线程操作相同的数据,与它们的代码无关。当共享访问相同的对象时,即它们共享相同的数据。当线程被构造时,需要的代码和数据通过一个对象作为构造函数实参传递进去,这个对象就是一个实现了Runnable接口的类的实例。

 

3、线程的状态

1)6种状态

新建(New)---使用new来新建一个线程

可运行(Runnable)----调用start()方法,线程处于运行或可运行状态

阻塞(Blocked)---线程需要获得内置锁,当该锁被其他线程使用时,此线程处于阻塞状态

等待(Waiting)---当线程等待其他线程通知调度表可以运行时,此时线程处于等待状态

计时等待(Timed Waiting)---当线程调用含有时间参数的方法(如sleep())时,线程可进入计时等待状态

终止(Terminated)--当线程的run()方法结束或者出现异常时,线程处于终止状态

2)sleep和wait的区别?

sleep()方法是属于Thread类中的; 而wait()方法,则是属于Object类中的。

sleep()方法导致了程序暂停执行指定的时间,占着cpu去睡觉,其他线程不能占用cpu,os认为该线程正在工作,不会让出系统资源,当指定的时间到了又会自动恢复运行状态。在调用sleep()方法的过程中,线程不会释放对象锁。

 

而当调用wait()方法的时候,线程会放弃对象锁,进入等待此对象的等待锁定池,wait是进入等待池等待,让出系统资源,其他线程可以占用cpu,一般wait不会加时间限制,只有针对此对象调用notify()方法后本线程才进入对象锁定池准备获取对象锁进入运行状态。

 

4、线程的安全

1)synchronized关键字是多线程并发环境的执行有序性的方式之一,当一段代码会修改共享变量,这一段代码成为互斥区或临界区,为了保证共享变量的正确性,synchronized标示了临界区。

2)成员(全局)变量的类用于多线程时是不安全的,不安全体现在这个成员变量可能发生非原子性的操作,而变量定义在方法内也就是局部变量是线程安全的。

3)生产--消费者模式

其实是一种很经典的线程同步模型,很多时候,并不是光保证多个线程对某共享资源操作的互斥性就够了,往往多个线程之间都是有协作的。

class Plate {
	 
    List<Object> eggs = new ArrayList<Object>();
 
    public synchronized Object getEgg() {
        while(eggs.size() == 0) {
            try {
                wait();
            } catch (InterruptedException e) {
            }
        }
 
        Object egg = eggs.get(0);
        eggs.clear();// 清空盘子
        notify();// 唤醒阻塞队列的某线程到就绪队列
        System.out.println("拿到鸡蛋");
        return egg;
    }
 
    public synchronized void putEgg(Object egg) {
        while(eggs.size() > 0) {
            try {
                wait();
            } catch (InterruptedException e) {
            }
        }
        eggs.add(egg);// 往盘子里放鸡蛋
        notify();// 唤醒阻塞队列的某线程到就绪队列
        System.out.println("放入鸡蛋");
    }
   
}

 class AddThread extends Thread{
    private Plate plate;
    private Object egg=new Object();
    public AddThread(Plate plate){
        this.plate=plate;
    }
   
    public void run(){
        for(int i=0;i<5;i++){
            plate.putEgg(egg);
        }
    }
}

 class GetThread extends Thread{
    private Plate plate;
    public GetThread(Plate plate){
        this.plate=plate;
    }
   
    public void run(){
        for(int i=0;i<5;i++){
            plate.getEgg();
        }
    }
}

 测试下:

public static void main(String args[]){
        try {
            Plate plate=new Plate();
            Thread add=new Thread(new AddThread(plate));
            Thread get=new Thread(new GetThread(plate));
            add.start();
            get.start();
            add.join();
            get.join();//等到取和拿线程执行完毕后再继续往下执行System.out.println("测试结束");
        } catch (Exception e) {
            e.printStackTrace();
        }
        System.out.println("测试结束");
    }

 打印结果:

放入鸡蛋

拿到鸡蛋

放入鸡蛋

拿到鸡蛋

放入鸡蛋

拿到鸡蛋

放入鸡蛋

拿到鸡蛋

放入鸡蛋

拿到鸡蛋

测试结束

 

4)显示地调用Lock(实现类比如ReentrantLock)

Lock bankLock = new ReentrantLock();

bankLock.lock();

//....

bankLock.unlock();//通常在finally里释放锁

 

5)ThreadLocal,顾名思义,它不是一个线程,而是线程的一个本地化对象。当工作于多线程中的对象使用ThreadLocal维护变量时,ThreadLocal为每个使用该变量的线程分配一个独立的变量副本。所以每一个线程都可以独立地改变自己的副本,而不会影响其他线程所对应的副本。

从线程的角度看,这个变量就像是线程的本地变量。

public class ThreadLocalTest {
	public static void main(String [] args) {  
        SequenceNumber sn = new SequenceNumber();  
          
//      ③ 3个线程共享sn,各自产生***    
        TestClient tc1 = new TestClient(sn);  
        TestClient tc2 = new TestClient(sn);  
        TestClient tc3 = new TestClient(sn);  
          
        tc1.start();  
        tc2.start();  
        tc3.start();  
          
    }  
}

class SequenceNumber {  
//  ①通过匿名内部类覆盖ThreadLocal的initialValue()方法,指定初始值    
    private static ThreadLocal<Integer> seqNum = new ThreadLocal<Integer>() {  
        public Integer initialValue() {  
            return 0;  
        }  
    };  
      
    public int getNextNum() {  
        seqNum.set(seqNum.get()+1);  
        return seqNum.get();  
    }  
      
}  

class TestClient extends Thread {  
    
    private SequenceNumber sn;  
      
    public TestClient(SequenceNumber sn) {  
        this.sn = sn;  
    }  
      
    public void run() {  
//      ④每个线程打出3个序列值    
        for(int i = 0; i<3; i++) {  
            System.out.println("thread[" + Thread.currentThread().getName() + "] sn[" + sn.getNextNum() +"]");  
        }  
    }  
}  

 结果:

thread[Thread-1] sn[1]

thread[Thread-0] sn[1]

thread[Thread-2] sn[1]

thread[Thread-0] sn[2]

thread[Thread-1] sn[2]

thread[Thread-0] sn[3]

thread[Thread-2] sn[2]

thread[Thread-1] sn[3]

thread[Thread-2] sn[3]

 

5、高并发

注:以下部分不要求全部答到,可以选择一个点答就行~

1)数据结构

java.util.concurrent包中提供了一些适合多线程程序使用的高性能数据结构,包括队列和集合类对象等。

1、队列

a、BlockingQueue接口:线程安全的阻塞式队列;当队列已满时,向队列添加会阻塞;当队列空时,取数据会阻塞。(非常适合消费者-生产者模式)

阻塞方式:put()、take()。

非阻塞方式:offer()、poll()。

实现类:基于数组的固定元素个数的ArrayBolockingQueue和基于链表结构的不固定元素个数的LinkedBlockQueue类。

b、BlockingDeque接口: 与BlockingQueue相似,但可以对头尾进行添加和删除操作的双向队列;方法分为两类,分别在队首和对尾进行操作。

实现类:标准库值提供了一个基于链表的实现,LinkedBlockgingDeque。

2、集合类

在多线程程序中,如果共享变量是集合类的对象,则不适合直接使用java.util包中的集合类。这些类要么不是线程安全,要么在多线程下性能比较差。

应该使用java.util.concurrent包中的集合类。

a、ConcurrentMap接口: 继承自java.util.Map接口

putIfAbsent():只有在散列表不包含给定键时,才会把给定的值放入。

remove():删除条目。

replace(key,value):把value 替换到给定的key上。

replace(key, oldvalue, newvalue):CAS的实现。

实现类:ConcurrentHashMap(若干个segements,每个segement都有自己的锁,常见的HashMap可以看作只有一个segement的ConcurrentHashMap):

创建时,如果可以预估可能包含的条目个数,可以优化性能。(因为动态调整所能包含的数目操作比较耗时,这个HashMap也一样,只是多线程下更耗时)。

创建时,预估进行更新操作的线程数,这样实现中会根据这个数把内部空间划分为对应数量的部分。(默认是16,如果只有一个线程进行写操作,其他都是读取,那么把值设为1 可以提高性能)。

注:当从集合中创建出迭代器遍历Map元素时,不一定能看到正在添加的数据,只能和集合保证弱一致性。(当然使用迭代器不会因为查看正在改变的Map,而抛出java.util.ConcurrentModifycationException)

b、CopyOnWriteArrayList接口:继承自java.util.List接口。

是一种用于程序设计中的优化策略。其基本思路是,从一开始大家都在共享同一个内容,当某个人想要修改这个内容的时候,才会真正把内容Copy出去形成一个新的内容然后再改,这是一种延时懒惰策略;

顾名思义,在CopyOnWriteArrayList的实现类,所有对列表的更新操作都会新创建一个底层数组的副本,并使用副本来存储数据;对列表更新操作加锁,读取操作不加锁。

适合多读取少修改的场景,如果更新操作多,那么不适合用,同样迭代器只能表示创建时列表的状态,更新后使用了新的底层数组,迭代器还是引用旧的底层数组。

 

2)多线程任务的执行

过去线程的执行,是先创建Thread类,再调用start方法启动,这种做法要求开发人员对线程进行维护,在线程较多时,一般创建一个线程池同一管理,同时降低重复创建线程的开销

在J2SE5.0中,java.util.concurrent包提供了丰富的用来管理线程和执行任务的实现。

1、基本接口(描述任务)

a、Callable接口:

Runnable接口受限于run方法的类型签名,而Callable只有一个方法call(),可以有返回值,可以抛出受检异常。

b、Future接口:

过去,需要异步线程的任务执行结果,要求主线程和任务执行线程之间进行同步和数据传递。

Future简化了任务的异步执行,作为异步操作的一个抽象。调用get()方法可以获取异步的执行结果,如果任务没有执行完,会等待,直到任务完成或被取消,cancel()可以取消。

——Callable(一个产生结果)和Future(一个拿到结果)。 

FutureTask实现了两个接口,Runnable和Future,所以它既可以作为Runnable被线程执行,又可以作为Future得到Callable的返回值:

public class CallableAndFuture {
    public static void main(String[] args) {
        Callable<Integer> callable = new Callable<Integer>() {
            public Integer call() throws Exception {
                return new Random().nextInt(100);
            }
        };
        FutureTask<Integer> future = new FutureTask<Integer>(callable);
        new Thread(future).start();
        try {
            Thread.sleep(5000);// 可能做一些事情
            System.out.println(future.get());
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
    }
}

 下面来看另一种方式使用Callable和Future,通过ExecutorService的submit方法执行Callable,并返回Future,代码如下

public class CallableAndFuture {
    public static void main(String[] args) {
        ExecutorService threadPool = Executors.newSingleThreadExecutor();
        Future<Integer> future = threadPool.submit(new Callable<Integer>() {
            public Integer call() throws Exception {
                return new Random().nextInt(100);
            }
        });
        try {
            Thread.sleep(5000);// 可能做一些事情
            System.out.println(future.get());
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
    }
}

 

c、Delayed接口:

延迟执行任务,getDelay()返回当前剩余的延迟时间,如果不大于0,说明延迟时间已经过去,应该调度并执行该任务。

2、组合接口(描述任务)

a、RunnableFuture接口:继承自Runnable接口和Future接口。

当来自Runnalbe接口中的run方法成功执行之后,相当于Future接口表示的异步任务已经完成,可以通过get()获取运行结果。

b、ScheduledFuture接口:继承Future接口和Delayed接口,表示一个可以调用的异步操作。

c、RunnableScheduledFuture接口:继承自Runnable、Delayed和Future,接口中包含isPeriodic,表明该异步操作是否可以被重复执行。

3、Executor接口、ExcutorServer接口、ScheduleExecutorService接口和CompletionService接口(描述任务执行)

a、executor接口,execute()用来执行一个Runnable接口的实现对象,不同的Executor实现采取不同执行策略,但提供的任务执行功能比较弱。

b、excutorServer接口,继承自executor;

提供了对任务的管理:submit(),可以吧Callable和Runnable作为任务提交,得到一个Future作为返回,可以获取任务结果或取消任务。

提供批量执行:invokeAll()和invokeAny(),同时提交多个Callable;invokeAll(),会等待所有任务都执行完成,返回一个包含每个任务对应Future的列表;invokeAny(),任何一个任务成功完成,即返回该任务结果。

提供任务关闭:shutdown()、shutdownNow()来关闭服务,前者不允许新的任务提交,后者试图终止正在运行和等待的任务,并返回已经提交单没有被运行的任务列表。(两个方法都不会等待服务真正关闭,只是发出关闭请求。)。shutdownDow,通常做法是向线程发出中断请求,所以确保提交的任务实现了正确的中断处理逻辑。

c、ScheduleExecutorService接口,继承自excutorServer接口:支持任务的延迟执行和定期执行,可以执行Callable或Runnable。

schedule(),调度一个任务在延迟若干时间之后执行;

scheduleAtFixedRate():在初始延迟后,每隔一段时间循环执行;在下一次执行开始时,上一次执行可能还未结束。(同一时间,可能有多个)

scheduleWithFixedDelay:同上,只是在上一次任务执行完后,经过给定的间隔时间再开始下一次执行。(同一时间,只有一个)

以上三个方法都返回ScheduledFuture接口的实现对象。

d、CompletionService接口,共享任务执行结果。

通常在使用ExecutorService接口,通过submit提交任务,并得到一个Future接口来获取任务结果,如果任务提交者和执行结果的使用者是程序的不同部分,那就要把Future在不同部分进行传递;而CompletionService就是解决这个问题,程序不同部分可以共享CompletionService,任务提交后,执行结果可以通过take(阻塞),poll(非阻塞)来获取。

标准库提供的实现是 ExecutorCompletionService,在创建时,需要提供一个Executor接口的实现作为参数,用来实际执行任务。

 

6、线程池

Java通过Executors提供四种线程池,分别为:

newCachedThreadPool创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。

newFixedThreadPool 创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待。

newScheduledThreadPool 创建一个定长线程池,支持定时及周期性任务执行。

newSingleThreadExecutor 创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。

用法举例:

ExecutorService cachedThreadPool = Executors.newCachedThreadPool();  
  for (int i = 0; i < 10; i++) {  
   final int index = i;  
   try {  
    Thread.sleep(index * 1000);  
   } catch (InterruptedException e) {  
    e.printStackTrace();  
   }  
   cachedThreadPool.execute(new Runnable() {  
    public void run() {  
     System.out.println(index);  
    }  
   });  
  }  

 

 ps: Servlet是线程安全的么?

Servlet是单实例多线程的。

Servlet不是线程安全的。

要解释为什么Servlet为什么不是线程安全的,需要了解Servlet容器(即Tomcat)是如何响应HTTP请求的。

当Tomcat接收到Client的HTTP请求时,Tomcat从线程池中取出一个线程,之后找到该请求对应的Servlet对象并进行初始化,然后调用service()方法。要注意的是每一个Servlet对象在Tomcat容器中只有一个实例对象,即是单例模式。如果多个HTTP请求请求的是同一个Servlet,那么这两个HTTP请求对应的线程将并发调用Servlet的service()方法。