多线程进阶(Callable接口、JUC、线程安全的集合类以及死锁)


一、Callable接口

类似Runnable,用于定义任务的描述接口
但是提供一个返回值,可以用于获取线程执行的结果

例如:

int i=0;
Thread t = new Thread(new Runnable(){
	public void run(){
		//做很多事情
		i=1;
	}
})

想获取t线程修改后i的值:System.out.print(i)是典型的错误的写法(t是并发执行,t什么时候修改i不知道),此时,就可以使用Callable

Callable使用方式:

  1. 定义一个Callable(泛型)对象,重写带返回值的call方法
  2. 创建一个FutureTask未来的任务对象
  3. new Thread(futureTask)
  4. 返回值 = futureTask.get();当前线程阻塞等待FutureTask任务执行结束,并获取执行结果
public class 方式三_Callable {

    static int i=0;

    public static void main(String[] args) throws ExecutionException, InterruptedException {

        Callable<Integer> c = new Callable<Integer>() {
            @Override
            public Integer call() {
                //模拟执行一段任务
                try {
                    Thread.sleep(1000);
                    return 1;
                } catch (InterruptedException e) {
                    throw new RuntimeException("出错了");
                }
            }
        };
        FutureTask<Integer> task = new FutureTask<>(c);
        Thread t = new Thread(task);
        t.start();
        //想看看t执行的结果:get会让当前线程等待直到t线程执行完,并获取到callable的返回值
        System.out.println(task.get());
    }
}

创建线程的方式:
从理解上要认识,Java中,创建线程都是Thread,而Runnable,Callable都是任务的描述

  1. 继承Thread
  2. 实现Runnable接口
  3. 实现Callable接口:可用于获取线程的执行结果

Callable也可以用于线程池
在这里插入图片描述

二、JUC的常见类

Java.util.concurrent包,这个包下的所有类,都是提供多线程并发编程用的,且满足线程安全,效率也很高

1.Lock系统

Lock系统——用来加锁,还有线程通信等等的作用
Lock:JDK提供锁的对象,专门用来加锁,达到线程安全的操作
一般的使用方式

Lock lock = new ReentranLock();
try{
	lock.lock();//锁对象加锁:只能一个线程获取到锁
	...//需要保证线程安全的代码
}finally{
	lock.unlock();//不管是否出现异常,都需要释放锁
}

ReentrantLock这里的Reentrant就是可重入的意思

synchronized代码,都可以全部使用lock来进行加锁释放锁

synchronized和lock都可以加锁达到线程安全的操作,那么区别?

  1. 从语法看,synchronized是自动的加锁和释放锁,而lock是显式(手动) 来加锁和释放锁;lock相对就更灵活,但需要保证始终要释放锁(执行完不管是否出现异常)
  2. lock提供了更多的获取锁的方式
方法功能
lock()和synchronized申请锁类似,申请失败,就干等(无条件等)
lockInterruptibly()可被中断地申请锁(申请失败等待时,可以被其他线程中断)
tryLock()尝试获取锁(不阻塞)如果申请成功,就加锁(返回true),申请失败不会等待,马上返回false
tryLock(long timeout,TimeUnit unit)尝试获取锁,如果申请失败,是超时等待(等待一段时间),这段时间还没获取到锁,就返回false
  1. 从效率看,线程冲突比较严重的时候,lock性能要高很多。原因:synchronized在申请锁成功的线程,释放锁以后,所有之前因为申请锁失败而阻塞的线程都会再次竞争。lock是基于aqs来实现

aqs:是一个双端队列,专门用来进行线程状态的管理
相当于:竞争锁失败的线程就放到队列中(入队),并设置状态
释放锁以后,把队列中的的线程引用拿出来,设置状态(获取到锁)

aqs提供了很多种方法,用来方便的实现独占锁/共享锁,公平锁/非公平锁
lock就是基于aqs独占锁的方式来实现,提供了公平和非公平的设置
公平:队列出队,是按先进先出
非公平:随机出队

Lock属于哪些锁策略呢?
独占锁、悲观锁(因为是显示的加锁)

lock方法实现的时候,里边包含了很多自旋+CAS的操作

提供了公平锁和非公平锁

构造方法:ReentrantLock(boolean fair)
true就是公平锁,false就是非公平锁
无参的构造方法默认是非公平锁

可重入锁、读写锁

2.CountDownLatch

内部有一个int的属性(并发数),表示可以同时并发并行执行的线程的数量

countDown()//并发数-1
await()//让当前线程等待,直到并发数=0,才能继续向下执行
public class CountDownLatchDemo {
    public static void main(String[] args) throws InterruptedException {
        final CountDownLatch latch = new CountDownLatch(10);
        for(int i=0;i<10;i++){
            final int j=i;
            new Thread(new Runnable() {
                @Override
                public void run() {
                    //这样写会报错,i是main线程私有的变量,其他线程看不到,直接使用会报错
                    //System.out.println(i);
                    //j是一个常量,虽然在mian线程,但是因为不会变,其他线程也就可以使用
                    System.out.println(j);
                    latch.countDown();
                }
            }).start();
        }
        //希望在这里等待以上10个线程执行完以后,再做一些事
        //方式一(不推荐):while(Thread.activeCount()>1) Thread.yield();
        //方式二(太麻烦):使用join:在循环的时候,把线程引用保存在一个集合中,在这里遍历并调用每个线程.join()
        //方式三(推荐):使用CountDownLatch
        latch.await();
        System.out.println("main");
    }
}

CountDownLatch只能减,不能加
使用场景:等待多个线程全部执行完,再执行某个任务

3.信号量Semaphore

semaphore(int permits)//int:初始化时,设置的并发数(可用的资源数)
semaphore(int permits,boolean fair)

//并发数(资源数)满足的时候,才能扣除,否则就需要阻塞等待
release()//并发数+=1
release(int permits)//并发数+=permits
acquire()//并发数-=1
acquire(int permits)//并发数-=permits

使用场景:

  1. 等待一组线程执行完再执行某个任务(CountDownLatch能完成的,Semaphore也能够实现)
public class SemaphoreDemo {
    public static void main(String[] args) throws InterruptedException {
        final Semaphore s = new Semaphore(0);
        for(int i=0;i<10;i++){
            final int j=i;
            new Thread(new Runnable() {
                @Override
                public void run() {
                    System.out.println(j);
                    s.release();//一个线程执行完,释放一个资源数
                }
            }).start();
        }
        s.acquire(10);
        System.out.println("main");
    }
} 
  1. 满足同一个时间最多执行n个线程(满足有限资源的使用)
Semaphore semaphore = new Semaphore(4);
Runnable runnable = new Runnable() {
  	@Override
 	public void run() {
    	try {
      		System.out.println("申请资源");
	        semaphore.acquire();
	        System.out.println("我获取到资源了");
	        Thread.sleep(1000);
	        System.out.println("我释放资源了");
	        semaphore.release();
   		} catch (InterruptedException e) {
      		e.printStackTrace();
   		}
 	}
};
for (int i = 0; i < 20; i++) {
  	Thread t = new Thread(runnable);
  	t.start();
}

因为每个线程都要先acquire获取一个线程数,执行完,再释放
并发执行20个线程,但由于每个线程都需要先获取资源数,意味着,同一个时间,最多执行4个线程(多的线程就等待)

还比如: 想实现web项目,最多支持并发数1000,多了就等待
一个web项目,多个客户端可以同时发请求;每个http请求tomcat是用同一个线程来处理的
那么,并发数是完全可能超过1000;因为主机cpu资源有限(spu,内存)就需要保护
每个servlet:
doXXX每次http请求都是由doXXX方法执行(tomcat中一个线程来执行)
为实现这个目的:

  1. 定义一个全局的Semaphore s = new Semaphore(1000)
  2. 每个doXXX方法,都是
s.acquire()
try{
	servlet处理逻辑
}finally{
	s.release();
}

每个servlet的doXXX方法都这样写,是比较麻烦的
其实,还有一个web开发中经常使用的组件:filter(过滤器)可以针对所有请求响应进行处理,就可以在一处代码执行统一的一些操作

CountDownLatch和Semaphore,都是共享锁
都是通过aqs来实现:

aqs:抽象的队列式的同步器

4.相关面试题

线程同步的方式有哪些?

保证线程安全:

  1. volatile:读操作,或是常量赋值的操作(n++是n=n+1,依赖n本身的变量,不算常量赋值)。常量赋值本身是原子性的,使用volatile就是线程安全的(volatile保证可见性,有序性)
  2. 写操作: synchronized,或lock加锁

三、线程安全的集合类

目前学习的集合类都是线程不安全的
有三个线程安全,但不建议使用: Vector(顺序表)、HashTable(哈希表)、Stack(栈)
性能非常差: 其中所有的实例方法,都是synchronized加锁,意味着同一个集合对象中,
所有方法都没办法并发并行执行
对于List,在多线程中,不能使用ArrayList,LinkedList
使用:

  1. 同步的List:Collections.synchronizedList(new ArrayList);
  2. CopyOnWriteArrayList:两个容器,支持并发的读写操作;写的时候是把我们当前容器复制一份新容器,往新容器写数据,写完,再把引用指向新容器

使用场景: 读多写少
优势: 并发的读读,读写操作(效率高)
缺陷:
空间换时间的思想,内存占用的比较多
新写的数据,写操作没执行完,可能无法第一时间读到(某些场景可以接受)

对于队列: 使用阻塞队列
对于哈希表: 不使用HashMap

  1. Hashtable: 不推荐,性能低
  2. ConcurrentHashMap: 推荐,线程安全且性能高

Hashtable是所有方法都加上了synchronized,相当于把整个数组都锁住了
Hashtable底层数据结构:数组+链表

ConcurrentHashMap
1.8的底层数据结构:数组+链表+红黑树
ConcurrentHashMap中的属性:包括Node中的属性,都是volatile修饰的(读操作,本身就是线程安全的,可以不加锁)
写操作:put(K k,V v)

  1. 先通过k键对象的hashcode,来计算数组索引
  2. 在第一步计算的数组索引上,保存v对象

考虑线程安全:
(1)这个节点为空,就相当于数组[索引]=v(CAS+自旋)
因为这个位置没有元素,就意味着不太可能读取,满足大多数时间,没有线程冲突的CAS条件
(2)节点已经存在元素,就相当于链表/红黑树中,添加一个元素
意味着数组中的元素来加锁:synchronized(头结点)

即使加锁,也只是对一个节点加锁,多个节点还是可以并发

扩容: 当添加元素,超过负载因子,就需要扩容(HashMap是拷贝到一个新的更大的数组中)
ConcurrentHashMap,是类似CopyOnWriteArrayList的方式,只是更复杂
同时存在新老数组,扩容的时候,每次只搬一小部分
扩容完,再删除老数组

总结:(底层数据结构:数组+链表+红黑树)

  1. 读是无锁操作:读读、读写并发
  2. 写是线程安全:但加锁更细粒度化,只锁一个node节点

如果节点为空:CAS+自旋
如果节点有元素:synchronized(头节点)

以此保证多个线程对多个节点可以并发写,对一个节点的操作是互斥的

  1. 扩容:采取新老两个数组

写操作,要扩容,就每次搬一小部分到新数组(老数组还可以并发的读)
写完:删除老数组
读:需要查两个数组

四、死锁

1.什么叫死锁

死锁: 多个线程申请锁出现环路等待时,就会造成线程始终处于阻塞等待的情况——一种严重的bug

2.如何避免死锁

死锁产生的四个必要条件

  1. 互斥使用,即当资源被一个线程使用(占有)时,别的线程不能使用
  2. 不可抢占,资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放
  3. 请求和保持,即当资源请求者在请求其他的资源的同时保持对原有资源的占有
  4. 循环等待,即存在一个等待队列:P1占有p2的资源,p2占有p3的资源,p3占有p1的资源。这样就形成了一个等待环路
Object lock1 = new Object();
Object lock2 = new Object();
Thread t1 = new Thread() {
  	@Override
  	public void run() {
    	synchronized (lock1) {
      		synchronized (lock2) {
        		// do something...
     		}
   		}
 	}
};
t1.start();
Thread t2 = new Thread() {
  	@Override
  	public void run() {
    	synchronized (lock2) {
      		synchronized (lock1) {
        		// do something...
     		}
   		}
 	}
};
t2.start();

有可能产生死锁

  1. t1,申请lock1,lock2,释放lock2,lock1;t2,申请lock2,及lock1就可以申请成功(不会死锁)
  2. t2先申请lock2,lock1,释放lock1,lock2;t1,申请lock1,lock2,也可以申请加锁成功(不会死锁)
  3. t1申请lock1,t2申请lock2;t1申请lock2加锁,t2申请lock1(死锁)
    在这里插入图片描述

3.如何检测程序是否出现死锁

打开jconsole:
在这里插入图片描述
其实jconsole也是使用了JDK提供的一些指令(jstack)来获取Java进程中多个线程的信息,判断是否出现死锁

4.如何解决死锁

死锁产生的四个必要条件破坏任何一个就行但一般来说,只要不出现环路等待加锁资源,就行:多个线程,约定好,一定的顺序,按照顺序来获取锁
所有线程都按照lock1->lock2->lock3这个顺序加锁,就不会产生死锁了

五、web开发中的多线程

tomcat启动以后,就使用了一个线程池来处理http请求任务
Servlet三大生命周期方法:

  1. init():初始化方法,只执行一次
  2. service():每次http请求,都会调用一次service方法
  3. destory():销毁方法,只执行一次

这个方法的实现,就是根据请求方法(get,post…),调用doXXX()
doXXX()相当于一个http请求和响应的处理任务,这个任务就是在tomcat线程池提供的线程中执行的
Servlet是多线程环境运行的(只是我没写这个线程)
所以在servlet中使用成员变量,就会出现线程安全问题

这个线程安全问题如何解决?
ThreadLocal这个api可以隔离多个线程使用的变量
大概说一下:使用同一个static静态变量ThreadLocal,每个线程操作同一个静态变量,其实操作的都是线程自己的数据(线程间互相隔离)

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

dhdhdhdhg

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值