【JavaEE】多线程(7)
一、JUC的常见类
JUC→java.util.concurrent,放了和多线程相关的组件
1.1 Callable 接口
看以下从计算从1加到1000的代码:
public class Demo {
public static int sum;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(new Runnable() {
@Override
public void run() {
int result = 0;
for (int i = 1; i <= 1000; i++) {
result += i;
}
sum = result;
}
});
t.start();
t.join();
System.out.println(sum);
}
}
通过成员变量的方式才能得到线程中任务的最终执行结果,这样会增加代码的耦合性,当改变外部的sum,线程内的sum也要改,所以引入Callable接口
public class Demo {
public static void main(String[] args) throws InterruptedException, ExecutionException {
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int result = 0;
for (int i = 1; i <= 1000; i++) {
result += i;
}
return result;
}
};
FutureTask<Integer> futureTask = new FutureTask<>(callable);
Thread t = new Thread(futureTask);
t.start();
t.join();
System.out.println(futureTask.get());
}
}
总结:
- Callable接口也用来描述一个任务,其中的call方法可以设置返回值将任务执行结果返回
- Runnable接口可以用来描述一个任务,但run方法的返回类型为void,无法返回任务结果
- callable需要经过FutureTask封装一下才能传入Thread构造方法,否则编译报错
至此创建线程有5种方式:
- 继承Thread
- 使用Runnable
- 使用lambda表达式
- 使用线程池/ThreadFactory
- 使用Callable接口
2.2 ReentrantLock
ReentrantLock 是可重入锁,它将加锁和解锁两个操作进行了区分,提供了加锁:lock()方法 和解锁:unlock()方法
public class Demo {
public static void main(String[] args) {
ReentrantLock locker = new ReentrantLock();
locker.lock();
locker.unlock();
}
}
其实这样并不好,因为在加锁和解锁之间可能会存在很多逻辑,假如在解锁之前就通过return返回了,或者抛出了异常,这样走不到解锁,所以一般将unlock写到finally中,finally中的代码无论如何都会被执行
public class Demo {
public static void main(String[] args) {
ReentrantLock locker = new ReentrantLock();
try {
locker.lock();
} finally {
locker.unlock();
}
}
}
synchronized也是可重入锁,那ReentrantLock相比于synchronized有哪些其他价值?
- ReentrantLock 提供了公平锁,向ReentrantLock的构造方法里传入true,就是公平锁,ReentrantLock locker = new ReentrantLock(true);
- ReentrantLock提供了tryLock()操作,当尝试加锁,如果所以经被获取了,则返回操作失败,tryLock()还提供了带时间参数版本,可以等待一定时间,时间到了再返回操作失败
- synchronized搭配wait notify等待通知机制,ReentrantLock搭配Condition类完成等待通知,Condition可以指定线程唤醒
1.3 信号量 Semaphore
信号量就是一个计数器,描述了可用资源的个数,有两个核心操作:
- P操作:计数器 -1,申请资源→acquire()
- V操作:计数器 +1,释放资源→release()
public class Demo {
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore = new Semaphore(3);
semaphore.acquire();
semaphore.acquire();
semaphore.acquire(); //最多申请3次
//semaphore.acquire(); // 这里会进行阻塞, 直到有线程执行release为止
semaphore.release(); //释放一个后, 就不会阻塞了
semaphore.acquire();
}
}
将信号量设为1,其作用就等价于一个锁,当一个线程申请资源后,另外一个线程再申请,就会进入阻塞,直到第一个线程释放资源
public class Demo {
public static int count = 0;
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore = new Semaphore(1);
Thread t1 = new Thread(() -> {
try {
for (int i = 0; i < 50000; i++) {
semaphore.acquire();
count++;
semaphore.release();
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
Thread t2 = new Thread(() -> {
try {
for (int i = 0; i < 50000; i++) {
semaphore.acquire();
count++;
semaphore.release();
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = "+ count);
}
}
1.4 CountDownLatch
当我们把一个任务拆分成多个的时候,当所有的任务(线程)都执行完后将结果合并,此时就可以使用这个工具类来识别所有线程是否都执行完了
public class Demo18 {
public static void main(String[] args) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(10); // 配合下面的countDown 和 await
for (int i = 0; i < 10; i++) {
int id = i;
Thread t = new Thread(() -> {
System.out.println("线程开始 "+ id);
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("线程结束 "+ id);
latch.countDown(); //每个线程执行完都执行一次countDown
});
t.start();
}
latch.await(); // 所有线程都执行完countDown 后再往下执行,否则这里阻塞等待
System.out.println("所有线程执行结束");
}
}
二、集合类的线程安全问题
Vector、Stack、HashTable这三个集合类是线程安全的,因为它们的方法都被synchronized修饰
2.1 多线程使用ArrayList
1)自己使用线程同步机制,加上synchronized或ReentrantLock
2)使用Collections.synchronizedList (new ArrayList);synchronizedList是一个基于synchronized的List,synchronizedList的关键操作上都带有synchronized
3)使用CopyOnWriteArrayList——写时拷贝;当我们向一个容器中插入元素时,会将原容器拷贝出一份新容器,往新容器里插入元素,插入完毕后,将原容器的引用指向新容器
写时拷贝的好处:解决线程安全问题,当在修改时,许多线程也在读,通过写时拷贝的处理,让线程只能在原容器读,在新容器写,写完后,原容器的引用指向新容器
2.2 多线程使用哈希表
HashMap 本身是线程不安全的,在多线程环境下可以使用:Hashtable 或 ConcurrentHashMap
1)Hashtable
在一些关键操作上加上synchronized,这相当于直接针对Hashtable对象本身加锁
- 多线程访问同一个Hashtable就会造成锁冲突,一个线程使用Hashtable中的关键方法时都会使其他要使用Hashtable的线程进入阻塞
- Hashtable 一旦触发扩容,就让触发扩容的线程完成整个扩容,效率非常低,因为涉及到大量元素的拷贝
2)ConcurrentHashMap
ConcurrentHashMap做了一些优化
1)在加锁方式上,使用"锁桶"的方式来代替一把"全局锁",ConcurrentHashMap在哈希表中的每一个链表都加上了锁
这样当两个不同的线程操作不同的链表的时候不会产生锁冲突,这样加了锁而且链表的个数通常很多,大部分情况都不会产生锁冲突,synchronized不产生锁冲突就是个偏向锁
2)size是真个哈希表的属性,不同的线程在执行插入或删除元素时会涉及到多个线程修改同一个变量,所以采用CAS的方式修改size,避免了加锁操作
【注意】这里不要和Hashtable搞混,Hashtable是一把全局锁,一个线程像哈希表中插入元素时,其他线程都在阻塞等待,直到插入完,所以不会涉及到多个线程修改size的情况;但ConcurrentHashMap 是锁桶的方式加锁,针对每个链表加锁,所以会有多个线程同一个时间段插入的情况,那么多个线程修改size的情况就不可避免,因此这里采用CAS的方式修改size
3)ConcurrentHashMap 针对扩容操作做了特殊优化,Hashtable是在触发扩容时,直接在那个线程的put方法里完成整个扩容,而ConcurrentHashMap在扩容会搞一个新数组,这个数组的容量是扩容后的容量
接下来要把旧数组中的数据拷贝到新数组中,但并不是一次性拷贝完,而是在之后,每次有线程进行哈希表的基本操作时,都会把一部分数据从旧数组搬运到新数组
在搬运的过程中:
- 插入:在新的数组插入
- 删除:新旧数组都要删除
- 查找:新就数组都要查找
🙉到此,多线程的篇章全部结束