Java并发编程:Callable与ReentrantLock实战解析
JUC
Callable 接口
1. 继承 Thread(包含匿名内部类)
2.实现 Runnable(包含匿名内部类)
3.基于 lambda
4.基于 Callable
Runnable 关注的是这个过程,不关注执行结果
Callable 要关注执行结果Callable 提供的 call 方法,返回值就是线程执行任务得到的结果
当我们要想获得结果,就得专门创建一个成员变量,保存计算结果。
如果使用Callable 就可以避免引入额外的成员变量了。
因为 Thread 没有提供构造函数来传入 callable 所以 引入一个 FutureTask 类,作为 Thread 和 callable 之间的联系桥梁
futureTask,get()带有阻塞功能,如果任务没执行完是不会获取到结果
Callable 能干的事情,使用 Runnable 也能干。
对于这种带有返回值的任务,使用 Callable 会更好一些,代码更直观
这里的 Future Task 起到的作用 十分重要
ReentrantLock 可重入锁
在之前sychronized 没有现在这样的强大,ReentrantLock 用来实现可重入锁的选择
传统锁的风格
这个对象提供了两个方法
lock
unlock
这个方法可能引起加了锁之后就忘了去解锁了
在 unlock 之前触发了 return 或者 异常,就可能引起unlock 执行不到了
正确使用 ReetrantLock 就需要把 unlock 操作放到 finally中
既然有了 synchronized 为啥还要有 ReentrantLock?
1.ReentrantLock 提供了 tryLock 操作
lock 直接进行加锁,加锁不成就要阻塞
tryLock,尝试进行加锁,如果加锁不成,不阻塞,直接返回false
2.ReentrantLock 提供了公平锁的实现(通过队列记录加锁线程的先后顺序)
synchronized 是非公平锁
ReentrantLock 构造方法中填写参数,就可以设置成公平锁
3.搭配的等待通知机制不同的
对于 sychronized 搭配 wait/notify
对于 ReentrantLock 搭配 Condition 类,功能比wait notify 略强一点
停车场
停车场一般会显示当前剩余的车位
开进去一个车, 车位就减一
开出来一个车,车位就加一
如果为0就不能开进来了
剩余的车位就是信号量,表示“可用资源的个数”
申请一个可用资源,就会使数字减一,这个操作称为 P 操作
释放一个可用资源,就会使数字加一,这个操作称为 V 操作
如果数值为 0 了,继续 P 操作,P操作就会阻塞
public class ThreadDemo37 { public static void main(String[] args) throws InterruptedException { Semaphore semaphore = new Semaphore(1); semaphore.acquire(); System.out.println("P操作"); semaphore.acquire(); System.out.println("P操作"); semaphore.acquire(); System.out.println("P操作"); semaphore.release(); } }public class ThreadDemo38 { private static int count = 0; public static void main(String[] args) throws InterruptedException { Semaphore semaphore = new Semaphore(1); Thread t1 = new Thread(() -> { for (int i = 0; i < 50000; i++) { try { semaphore.acquire(); } catch (InterruptedException e) { throw new RuntimeException(e); } count++; semaphore.release(); } }); Thread t2 = new Thread(() -> { for (int i = 0; i < 50000; i++) { try { semaphore.acquire(); } catch (InterruptedException e) { throw new RuntimeException(e); } count++; semaphore.release(); } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(count); } }如何确保线程安全?
1.sychronized
2.ReentrantLock
3.CAS(原子类)
4.Semaphore
semaphore 也可以用来实现生产者消费者模型
定义两个信号量
一个用来表示,队列中有多少个可以被消费的元素 sem1
一个用来表示,队列中有多少个可以放置新元素的空间 sem2
生产一个元素,sem1.V(),sem2.P()
消费一个元素,sem1.P(),sem2.V()
下载一个文件,肯呢个很大,但是可以拆成多个部分,每个线程负责下载一部分,下载完成之后,最终把下载的结果凭借到一起
像多线程下载这样的场景,最终执行完成之后,要把所有内容拼到一起,这个拼必然要等到所有线程执行完成
使用 CountDownLatch 就可以很方便感知到这个事情
如果使用 join 方式,就只能使用每个线程执行一个任务
借助 countDownLatch 就可以让一个线程执行多个任务
public static void main(String[] args) throws InterruptedException { //1.此处构造方法中写 10,意思是有 10 个线程/任务 CountDownLatch latch = new CountDownLatch(10); //创建 10 个线程负责下载 for (int i = 0; i < 10; i++) { int id = i; Thread t = new Thread(() -> { Random random = new Random(); int time = (random.nextInt(5)+1) * 1000; System.out.println("线程" + id + "开始下载"); try { Thread.sleep(time); } catch (InterruptedException e) { throw new RuntimeException(e); } System.out.println("线程" + id + "结束下载"); //告知 countDownLatch 执行结束了 latch.countDown(); }); t.start(); } latch.await(); System.out.println("所有任务已经完成了!"); }多环境下使用ArrayList
Vector Stack Hashtable 把关键方法都加上了 sychronized
因此这几个无论如何都得加锁,是不科学的
Collections.synchronizedList(new ArrayList)
给 ArrayList 套了个壳
ArrayList 各种操作本身不带锁的
通过上述套壳之后,得到了新的对象,新的对象里面的关键方法都是带锁的
使用 CopyOnWriteArrayList
CopyOnWrite容器即写时复制的容器。
写时拷贝
线程安全问题,多个修改同一个线程
如果多线程读这个顺序表,没有任何线程安全问题
一旦有线程要修改这里的值,把顺序表复制一份,修改新的顺序表内容并且修改引用的指向,这个操作是原子的必须要加锁
这种操作也有很大的局限性
1.修改不能太频繁
2.顺序表也不应该太大
多线程环境使用队列
1.自己加锁
2.BlockingQueue (线程安全的)
多线程环境使用 哈希表
HashMap 肯定不行 属于线程不安全的
更靠谱的,Hashtable们就在关键方法上添加了 sychronized
后来标准库又引入了一个更好的解决方案
ConcurrHashMap
ConcurrHashMap 的改进
1.缩小了锁的粒度
直接在方法上使用 sychronized,就相当于是对this 加锁
此时,尝试修改两个不同链表上的元素,都会出发锁冲突
ConcurrHashMap 就是把锁变小了,给每个链表都发了一个锁
上述设定,不会产生更多的空间代价,因为 java 中任何一个对象都可以直接作为锁对象,本身哈希表中,就得有数组,数组的元素都是已经存在的(每个链表的头结点),此时只要使用数组元素(链表头结点)作为加锁的对象即可
锁桶(Hash桶)
构成一个类似于“桶”,每个链表就是构成桶的一个木板,所谓“锁桶”就是针对每个木板(每个链表)分别加锁的
在 java1.7及其之前 ConcurrentHashMap 是通过“分段锁” 来实现的,给若干个链表分配一把锁,这种设定,不太合适,实现更复杂,效率也不够,引入额外的空间开销
Java 8 开始,这里的设定就成了每个链表一把锁了
2.充分使用了CAS原子操作,减少一些加锁
比如 针对哈希表元素个数的维护
sychronized 里头刚开始是偏向锁或者轻量级锁,速度很快,但 sychronized 也有可能成为重量级锁,是够升级了,但咱们不可控
3.针对扩容操作优化
扩容是一个重量操作
负载因子--描述了每个桶上平均有多少个元素
此时桶上的链表元素个数不应该太长
如果太长
1.变成树
2.扩容
创建一个更大的数组把旧的Hash表的元素搬运到(删除/插入)新的数组上
如果hash表本身元素非常多,这里的扩容操作就会消耗很长时间
hash表表现不稳定,平时很快突然某个操作就满了过一会又快了
无法控制合适出发扩容,一旦扩容触发了就会导致这次操作非常耗时
HashMap 的扩容操作是一把锁,在某一次插入元素操作中,整体完成扩容了
ConcurrentHashMap 则是每次操作都只搬运一部分元素