文章目录
一、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使用方式:
- 定义一个Callable(泛型)对象,重写带返回值的call方法
- 创建一个FutureTask未来的任务对象
- new Thread(futureTask)
- 返回值 = 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都是任务的描述
- 继承Thread
- 实现Runnable接口
- 实现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都可以加锁达到线程安全的操作,那么区别?
- 从语法看,synchronized是自动的加锁和释放锁,而lock是显式(手动) 来加锁和释放锁;lock相对就更灵活,但需要保证始终要释放锁(执行完不管是否出现异常)
- lock提供了更多的获取锁的方式
方法 | 功能 |
---|---|
lock() | 和synchronized申请锁类似,申请失败,就干等(无条件等) |
lockInterruptibly() | 可被中断地申请锁(申请失败等待时,可以被其他线程中断) |
tryLock() | 尝试获取锁(不阻塞)如果申请成功,就加锁(返回true),申请失败不会等待,马上返回false |
tryLock(long timeout,TimeUnit unit) | 尝试获取锁,如果申请失败,是超时等待(等待一段时间),这段时间还没获取到锁,就返回false |
- 从效率看,线程冲突比较严重的时候,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
使用场景:
- 等待一组线程执行完再执行某个任务(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");
}
}
- 满足同一个时间最多执行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中一个线程来执行)
为实现这个目的:
- 定义一个全局的Semaphore s = new Semaphore(1000)
- 每个doXXX方法,都是
s.acquire()
try{
servlet处理逻辑
}finally{
s.release();
}
每个servlet的doXXX方法都这样写,是比较麻烦的
其实,还有一个web开发中经常使用的组件:filter(过滤器)可以针对所有请求响应进行处理,就可以在一处代码执行统一的一些操作
CountDownLatch和Semaphore,都是共享锁
都是通过aqs来实现:
aqs:抽象的队列式的同步器
4.相关面试题
线程同步的方式有哪些?
保证线程安全:
- volatile:读操作,或是常量赋值的操作(n++是n=n+1,依赖n本身的变量,不算常量赋值)。常量赋值本身是原子性的,使用volatile就是线程安全的(volatile保证可见性,有序性)
- 写操作: synchronized,或lock加锁
三、线程安全的集合类
目前学习的集合类都是线程不安全的
有三个线程安全,但不建议使用: Vector(顺序表)、HashTable(哈希表)、Stack(栈)
性能非常差: 其中所有的实例方法,都是synchronized加锁,意味着同一个集合对象中,
所有方法都没办法并发并行执行
对于List,在多线程中,不能使用ArrayList,LinkedList
使用:
- 同步的List:Collections.synchronizedList(new ArrayList);
- CopyOnWriteArrayList:两个容器,支持并发的读写操作;写的时候是把我们当前容器复制一份新容器,往新容器写数据,写完,再把引用指向新容器
使用场景: 读多写少
优势: 并发的读读,读写操作(效率高)
缺陷:
空间换时间的思想,内存占用的比较多
新写的数据,写操作没执行完,可能无法第一时间读到(某些场景可以接受)
对于队列: 使用阻塞队列
对于哈希表: 不使用HashMap
- Hashtable: 不推荐,性能低
- ConcurrentHashMap: 推荐,线程安全且性能高
Hashtable是所有方法都加上了synchronized,相当于把整个数组都锁住了
Hashtable底层数据结构:数组+链表
ConcurrentHashMap
1.8的底层数据结构:数组+链表+红黑树
ConcurrentHashMap中的属性:包括Node中的属性,都是volatile修饰的(读操作,本身就是线程安全的,可以不加锁)
写操作:put(K k,V v)
- 先通过k键对象的hashcode,来计算数组索引
- 在第一步计算的数组索引上,保存v对象
考虑线程安全:
(1)这个节点为空,就相当于数组[索引]=v(CAS+自旋)
因为这个位置没有元素,就意味着不太可能读取,满足大多数时间,没有线程冲突的CAS条件
(2)节点已经存在元素,就相当于链表/红黑树中,添加一个元素
意味着数组中的元素来加锁:synchronized(头结点)
即使加锁,也只是对一个节点加锁,多个节点还是可以并发
扩容: 当添加元素,超过负载因子,就需要扩容(HashMap是拷贝到一个新的更大的数组中)
ConcurrentHashMap,是类似CopyOnWriteArrayList的方式,只是更复杂
同时存在新老数组,扩容的时候,每次只搬一小部分
扩容完,再删除老数组
总结:(底层数据结构:数组+链表+红黑树)
- 读是无锁操作:读读、读写并发
- 写是线程安全:但加锁更细粒度化,只锁一个node节点
如果节点为空:CAS+自旋
如果节点有元素:synchronized(头节点)
以此保证多个线程对多个节点可以并发写,对一个节点的操作是互斥的
- 扩容:采取新老两个数组
写操作,要扩容,就每次搬一小部分到新数组(老数组还可以并发的读)
写完:删除老数组
读:需要查两个数组
四、死锁
1.什么叫死锁
死锁: 多个线程申请锁出现环路等待时,就会造成线程始终处于阻塞等待的情况——一种严重的bug
2.如何避免死锁
死锁产生的四个必要条件
- 互斥使用,即当资源被一个线程使用(占有)时,别的线程不能使用
- 不可抢占,资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放
- 请求和保持,即当资源请求者在请求其他的资源的同时保持对原有资源的占有
- 循环等待,即存在一个等待队列: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();
有可能产生死锁
- t1,申请lock1,lock2,释放lock2,lock1;t2,申请lock2,及lock1就可以申请成功(不会死锁)
- t2先申请lock2,lock1,释放lock1,lock2;t1,申请lock1,lock2,也可以申请加锁成功(不会死锁)
- t1申请lock1,t2申请lock2;t1申请lock2加锁,t2申请lock1(死锁)
3.如何检测程序是否出现死锁
打开jconsole:
其实jconsole也是使用了JDK提供的一些指令(jstack)来获取Java进程中多个线程的信息,判断是否出现死锁
4.如何解决死锁
死锁产生的四个必要条件破坏任何一个就行但一般来说,只要不出现环路等待加锁资源,就行:多个线程,约定好,一定的顺序,按照顺序来获取锁
所有线程都按照lock1->lock2->lock3这个顺序加锁,就不会产生死锁了
五、web开发中的多线程
tomcat启动以后,就使用了一个线程池来处理http请求任务
Servlet三大生命周期方法:
- init():初始化方法,只执行一次
- service():每次http请求,都会调用一次service方法
- destory():销毁方法,只执行一次
这个方法的实现,就是根据请求方法(get,post…),调用doXXX()
doXXX()相当于一个http请求和响应的处理任务,这个任务就是在tomcat线程池提供的线程中执行的
Servlet是多线程环境运行的(只是我没写这个线程)
所以在servlet中使用成员变量,就会出现线程安全问题
这个线程安全问题如何解决?
ThreadLocal这个api可以隔离多个线程使用的变量
大概说一下:使用同一个static静态变量ThreadLocal,每个线程操作同一个静态变量,其实操作的都是线程自己的数据(线程间互相隔离)