多线程(六)

发布于:2024-04-29 ⋅ 阅读:(29) ⋅ 点赞:(0)

目录

一、synchronized工作原理

基本特点

1、加锁工作工程

2、其它优化操作

锁消除 

锁粗化

 二、Collable接口

三、JUC(java.util.concurrent)的常见类

ReentrantLock

原子类

线程池

信号量 Semaphor

CountDownLatch

四、线程安全的集合类

1、多线程环境使用ArrayList

1)自己使用同步机制(synchronized或者ReentrantLock)

2)Collections.synchronizedList(new ArrayList)

3)使用CopyOnWriteArrayList 

 2、多线程使用队列

3、多线程环境使用哈希表

1)Hashtable

2)ConcurrentHashMap


一、synchronized工作原理

基本特点

1、开始是乐观锁,若锁冲突频繁,就转换为悲观锁(自适应)

2、 开始是轻量级锁,若锁被持有的时间较长,就转换为重量级锁(自适应)

3、实现轻量级锁时大概率用到的是自旋锁策略(自适应)

4、不公平锁

5、可重入锁

6、读写锁

参考文章:多线程-锁策略(四)

1、加锁工作工程

JVM将synchronized锁分为无锁、偏向锁、轻量级锁、重量级锁状态,会根据情况依次升级

2、其它优化操作

锁消除 

编译器+JVM会判断锁是否可消除,若可以就直接消除了

比如我们知道的StringBuffer中的方法都是加了锁的,这里只是在单线程下使用,但append()方法每次调用都要加锁解锁,这没必要,浪费了大量的资源开销,这下就要给锁消除了...

StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");
锁粗化

一段逻辑中如果出现多次加锁解锁,编译器+JVM会自动进行锁的粗化 

锁的粒度:synchronized中代码越多,粒度越粗,反之越细

使用细粒度锁,是期望释放锁时其它线程可以使用,这样能够并发执行的逻辑更多,更有利于充分利用多核cpu资源,但是也有可能实际上并没有线程要来抢占这个锁。这时JVM自动把锁粗化,避免频繁申请释放锁(反复的锁竞争)

        打个比方,善良的小明使用公用电话,他有3件事情要给对方交代。但是他怕有别的急着打电话的人,于是他每说一件事就挂掉出去看看有没有人要用电话,没有就再回来说下一件事,有的话就先让给别人...那这还不如你直接3件事赶紧讲完然后走开...

 二、Collable接口

Callable接口也是一种创建线程的方式,相当于把线程封装了一个”返回值“

相比Runnable,描述的是一段带有返回值的任务

Callable需要搭配FutureTask使用,用于保存Callable的返回结果,因为往往Callable往往是在另一个线程中执行的,啥时候执行完并不知道

(Callable相当于麻辣烫,FutureTask相当于小票,凭小票取餐)

 例子如下,求1~1000之和并返回结果

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.FutureTask;

public class demo13 {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        Callable<Integer> callable=new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                int sum=0;
                for (int i = 0; i <= 1000; i++) {
                    sum+=i;
                }
                return sum;
            }
        };
        FutureTask<Integer> futureTask=new FutureTask<>(callable);
        Thread t1=new Thread(futureTask);
        t1.start();
        System.out.println(futureTask.get());//t1未执行完,则执行到get()就阻塞等待
    }
}

 

 若不使用Callable这样带有返回值的创建方式要从主线程获得执行结果:

static class Result {
    public int sum = 0;//保存最终结果
    public Object lock = new Object();
}

public static void main(String[] args) throws InterruptedException {
    Result result = new Result();
    Thread t = new Thread() {
        @Override
        public void run() {
            int sum = 0;
            for (int i = 1; i <= 1000; i++) {
                sum += i;
           }
            synchronized (result.lock) {
                result.sum = sum;
                result.lock.notify();
           }
       }
   };
    t.start();

    synchronized (result.lock) {
        while (result.sum == 0) {
            result.lock.wait();
       }
        System.out.println(result.sum);
   }
}

可以看出,上述代码需要一个辅助类Result,还需要一系列的加锁和wait、notify操作,代码复杂,容易出错;而使用Callable和FutureTask后,代码简化了很多,也不必手写线程同步代码了

小结:线程的创建方式

  1. 继承Thread,重写run()(创建单独的类/匿名内部类)
  2. 实现Runnable,重写run()(创建单独的类/匿名内部类)
  3. 实现Callable,重写call()(创建单独的类/匿名内部类)
  4. 使用lambda表达式
  5. 线程工厂 ThreadFactory
  6. 线程池 ThreadPoolEcecutor

三、JUC(java.util.concurrent)的常见类

ReentrantLock

可重入互斥锁,和synchronized定位相似,都是用来实现互斥效果,保证线程安全

用法

  • lock():加锁,若获取不到就死等
  • trylock(超时时间):加锁,若获取不到锁,等待一段时间后就放弃加锁
  • unlock():解锁
ReentrantLock lock = new ReentrantLock(); 
-----------------------------------------
lock.lock();   
try {    
 // working    
} finally {    
 lock.unlock()    
}  

二者 区别:

  1. synchronized是一个关键字,是JVM内部实现的(大概率是基于C++);ReentrantLock是标准库中的一个类,在JVM外实现(基于Java)

  2. synchronized使用时不需要手动释放锁,ReentrantLock使用时需要手动释放,使用起来更灵活(但是容易遗漏unlock)
  3. synchronized在申请锁失败时,会死等;ReentrantLock使用时可以通过trylock()的方式等待一段时间就放弃 
  4. synchronized是非公公平锁,ReentrantLock默认是非公平锁,可以通过构造方法传入一个true开启公平锁模式
  5. 提供了更强大的唤醒机制,synchronized是通过Object的wait/notify实现等待-唤醒机制,每次唤醒的是一个随机等待的线程;ReentrantLock搭配Condition类实现等待-唤醒,可以更精准唤醒某个指定的线程
// ReentrantLock 的构造方法
public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

 如何选择使用哪个锁?

  1. 锁竞争不激烈时,使用synchronized效率更高,自动释放更方便
  2. 锁竞争激烈时,使用ReentrantLock搭配tryLock更灵活控制加锁行为,而不是死等
  3. 如果需要使用公平锁,使用ReentrantLock 
  4. 还是首选synchronized,ReentrantLock使用更加复杂,尤其是容易忘记加锁;另外synchronized背后还有一些优化手段

原子类

        在标准库中提供的 java.util.concurrent.atomic 包里面有原子类,原子类内部用的是CAS,所以性能要比加锁实现i++高很多,原子类有以下几个:

  • AtomicBoolean
  • AtomicInteger
  • AtomicInntegerArry
  • AtomicLong
  • AtomicReference
  • AtomicStampedReference

线程池

ExecutorService 和 Executors

ThreadPoolExecutor

具体详见 多线程基础-案例(三)

信号量 Semaphor

信号量,用来表示”可用资源个数“,本质上是一个计数器 

理解信号量

可以把信号量想象成停车场的展示牌:当前有车位100位,表示有100个可用资源

  • 当有车开进(申请一个可用资源),可用车位-1(信号量的P操作)
  • 当有车开出 (释放一个可用资源),可用车位+1(信号量的V操作)
  • 若计数器的值已为0还尝试申请资源,就会阻塞等待,直到有其他线程释放资源 

Semaphore的PV操作的加减计数器都是原子的,可以在多线程环境下直接使用  

代码示例:

import java.util.concurrent.Semaphore;

public class demo14 {
    public static void main(String[] args) throws InterruptedException {
        Semaphore semaphore=new Semaphore(4);
        semaphore.acquire();
        System.out.println("P操作");
        semaphore.acquire();
        System.out.println("P操作");
        semaphore.acquire();
        System.out.println("P操作");
        semaphore.acquire();
        System.out.println("P操作");
        semaphore.acquire();
        System.out.println("P操作");
    }
}

使用信号量可以实现”共享锁“:

        比如某个资源允许3个线程同时使用,那么就可以使用P操作作为加锁;V操作作为解锁

        前三个线程的P操作都能顺利返回,后续线程再进行P操作就会阻塞等待,直到前面的线程执行了V操作 

CountDownLatch

同时等待N个任务执行结束

就像跑步比赛,10个选手依次就位,哨声响才同时出发;所有选手都通过终点才能公布成绩 

主要适用于多个线程来完成一系列任务 时,用来衡量任务的进度是否完成

比如需要把一个大的任务拆分成多个小的任务并发执行;CountDownLatch就可以判断这些任务是否都完成了

下载文件就可以使用多线程下载(IDM多线程下载成倍的提升下载速度)

代码示例:

  1. 构造CountDownLatch实例,初始化10个任务需要完成
  2. 每个任务执行完毕,都调用countDown(),在CountDownLatch内部的计数器同时自减
  3. 主线程使用await(),阻塞等待所有任务执行完毕(相当于计数器为0了)
import java.util.concurrent.CountDownLatch;

public class demo15 {
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch countDownLatch=new CountDownLatch(10);
        for (int i = 0; i < 10; i++) {
            int id=i;
            Thread t=new Thread(()->{
                System.out.println("thread"+id);
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                //通知说当前任务执行完毕
                countDownLatch.countDown();
            });
            t.start();
        }
        countDownLatch.await();
        System.out.println("所有任务都执行完毕");
    }
}

synchronized、ReentrantLock、Semaphore等都可以用于线程同步 

四、线程安全的集合类

 数据结构中大部分的集合类都是线程不安全的

 Vector、Stack、Hashtable线程安全 =>sunchronized

 上古时期Java引入的集合类。Stack继承自Vector所以也线程安全了

 针对线程不安全的结合类在多线程下就要考虑线程安全问题了

1、多线程环境使用ArrayList

1)自己使用同步机制(synchronized或者ReentrantLock)
2)Collections.synchronizedList(new ArrayList)

synchronizedList是标准库提供的一个基于synchronized进行线程同步的List,关键操作上都带有synchronized

3)使用CopyOnWriteArrayList 

CopyOnWrite容器 即 写时拷贝的容器

  • 当往容器添加元素,并不是直接往当前容器添加 ,而是先将当前容器进行拷贝,复制出一个新的容器,往这个新容器里添加元素
  • 新元素添加完毕,再将原容器的引用指向新的容器

这样就可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素

  • 优点:在读多写少的场景下,性能很高,不需要加锁竞争
  • 缺点:占用内存较多、新写的数据不能第一时间读取到

 2、多线程使用队列

  • ArrayBlockingQueue  基于数组实现的阻塞队列
  • LinkedBlockingQueue  基于链表实现的 阻塞队列
  • PriorityBlockingQueue  基于堆实现的带优先级的阻塞队列
  • TransferQueue  最多只包含一个元素的阻塞队列

3、多线程环境使用哈希表

HashMap本身不是线程安全的

在多线程环境下使用哈希表可以使用:Hashtable、ConcurrentHashMap

1)Hashtable

只是简单的把 关键方法加上了synchronized关键字,这相当于直接针对Hashtable对象本身加锁

  • 如果多线程访问同一个Hashtable(任意数据)都会直接造成锁冲突 
  • size属性也是通过synchronized来控制同步,也是比较慢的 
  • 一旦触发扩容,就由该线程完成整个扩容过程,此过程会涉及到大量的元素拷贝,效率非常低
2)ConcurrentHashMap

相比于HashMap做出了一系列的改进和优化(以Java1.8为例)

  1. 读操作没有加锁(但是使用了volatile保证从内存读取结果),只对写操作进行加锁
  2. 加锁的方式仍然是用synchronized,但不是锁整个对象,而是”锁桶“(用每个链表的头结点作为锁对象),大大降低了锁冲突的概率 
  3. 充分利用CAS特性(size属性通过CAS来更新,避免出现重量级锁的情况)
  4. 优化了扩容方式:化整为零                                                                                                        > 需要扩容的线程,只需要创建一个新的数组,同时搬几个元素过去                                      > 后续每个来操作map的线程,都会参与搬家过程,每个操作负责搬运一小部分元素              > 搬完最后一个元素再把老数组删掉                                                                                          > 扩容期间,新老数组同时存在;插入只往新数组插入;查找需要同时查新数组和老数组 

读操作不加锁:为了进一步降低锁冲突的概率、保证读到刚修改的数据,搭配了volatile关键字

 ConcurrentHashMap“锁桶”技术

Java1.7中采用的是锁分段技术:把若干个哈希桶分成一个”段“(Segment),针对每个段分别加锁

目的是为了降低锁竞争的概率,当2个线程访问的数据恰好在同一个段上时才触发锁竞争;

但其实这也没必要,范围搞大了,修改不是一个段的数据怎么会冲突呢...

Java1.8就取消了分段锁,直接给每个哈希桶(每个链表)分配了一个锁(以每个链表的头节点对象作为锁对象)

Java1.8还在Java1.7上做了别的优化:将原来数组+链表的实现方式改进成了数组+链表/红黑树的方式。当链表较长时(大于等于7个元素)就转换成红黑树

Hashtable和HashMap、ConcurrentHashMap之间的区别: 

  • HashMap:线程不安全,key允许为null
  • Hashtable:线程安全,使用synchronized锁Hashtable对象,效率较低 ,key不允许为null
  • ConcurrentHashMap:线程安全,使用synchronized锁每个链表头结点,锁冲突概率低,充 分利用CAS机制,优化了扩容方式,key不允许为null