线程池(四)——其他问题

线程池(四)——其他问题,第1张

目录

一、关于锁的策略问题

1.读锁(共享锁)VS写锁(独占锁)

Java中的读写锁

2.重入锁(ReentrantLock)VS不可重入锁

synchronized锁是可重入锁还是不可重入锁?

3.公平锁(fair)和不公平锁

锁的公平性

4.锁实现策略的简单对比

5.乐观锁VS悲观锁

6.锁的实现导致的锁的种类:互斥锁(mutex)VS自旋锁(spin lock)

1)默认情况下是互斥锁

2)CAS机制

3)自旋锁(spin lock)

所以从纯实现角度,锁就分为了互斥锁(mutex)VS自旋锁(spin lock)

7.synchronized的锁的实现与优化

a.锁消除策略:

b.锁的粗化优化


一、关于锁的策略问题

关于各种各样的锁:

1.读锁(共享锁)VS写锁(独占锁)

共享锁又叫Shared Lock(S锁),独占锁又叫eXclusive Lock(x锁),我们目前使用的锁都是独占锁(只有一个线程能持有锁)

当业务中读的次数>>写的次数时,共享锁优于独占锁

Java中的读写锁

1.读锁时:

package com.wy.read_write_lock_demo;

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class Main {
    public static void main(String[] args) {
        ReadWriteLock readWriteLock=new ReentrantReadWriteLock();
        Lock readLock = readWriteLock.readLock();
        Lock writeLock=readWriteLock.writeLock();
        //“写”的角色,请求写锁
        //“读”的角色,请求读锁
        readLock.lock();//读锁已经有了
        Thread t=new Thread(){
            @Override
            public void run() {
                readLock.lock();
                System.out.println("子线程也可以加读锁成功");//读和读不做互斥
            }
        };
        t.start();
    }
}
//主线程已经加了读锁了,但不影响子线程内执行

2.写锁时:

      写锁锁上时,读锁不可能加锁成功,申请不到锁,读和写之间互斥(同理,写和写之间更是互斥的)

2.重入锁(ReentrantLock)VS不可重入锁

      最大的区别就是是否允许重复加锁——是否允许持有锁的线程成功请求到同一把锁【同一个线程请求同一把锁时是否允许加锁成功】

t1线程

lock1.lock();//t1成功锁上了lock1

...

lock1.lock();//此前lock1已经处于锁上状态并且请求锁的还是t1线程

若此状态下该lock1.lock();加锁成功,就是可重入锁,不允许加锁成功,就是不可重入锁

从实现角度来讲,就是在锁的内部,是否要记录当前是谁锁了线程

public class Main {
    public static void main(String[] args) {
        //主线程
        Lock lock=new ReentrantLock();//名字ReentrantLock已经说明,这把锁是可重入锁
        lock.lock();//锁已经被锁上了
        lock.lock();
        System.out.println("这里可以打印,说明允许可重入");
    }
}
synchronized锁是可重入锁还是不可重入锁?

是可重入锁

public class Main2 {
    public static void main(String[] args) {
        Object lock=new Object();
        synchronized (lock){//main线程已经对lock加锁
            synchronized (lock){//main线程再次对lock请求锁(这个锁是已经锁上的状态)
                System.out.println("这里打印了就说明sync是可重入锁");
            }
        }
    }
}
3.公平锁(fair)和不公平锁

      当一把锁锁住时,一把锁上是有一个等待队列的,是请求锁失败的那些线程,某个时刻,锁刚好打开,刚好有一个后来的线程请求锁,这个线程就优先获取到锁了,这种锁的实现就是不公平的。当后来的线程碰到锁打开,不进去而是在队列中去排队,就是公平锁。非公平锁实现简单,公平锁实现困难,一般默认都是非公平锁【公平:严格按照请求锁的次序获取到锁】。

锁的公平性

1.sync是不公平的

2.juc下的ReentrantLock可以通过传入fair==true/false来控制是否公平,默认情况下是不公平的

public class Main {
    public static void main(String[] args) {
        //当传入的是true时是公平锁模式
        Lock lock=new ReentrantLock(true);
        //当传入的是false是不公平锁模式
        Lock lock1=new ReentrantLock(false);
        //默认情况下是不公平的
        Lock lock2=new ReentrantLock();
    }
}
4.锁实现策略的简单对比

synchronized锁:独占锁+可重入锁+不公平锁

5.乐观锁VS悲观锁

      翻译问题,严格来讲,这两个是实现并发控制的两个方案,和“锁”的概念不是一个层级的概念。

1.乐观锁:评估后,并发情况,多个线程同时修改一个共享资源的情况比较少见,可以采用轻量级(无锁lock-free)方式,进行并发控制。

2.悲观锁:多个线程会频繁的修改同一个共享资源,必须使用互斥方式(锁lock0来进行并发控制)

类比:十字路口(并发场景);红绿灯(锁)

6.锁的实现导致的锁的种类:互斥锁(mutex)VS自旋锁(spin lock) 1)默认情况下是互斥锁

      默认情况下,我们的锁的实现,是采用OS提供的锁(mutex锁,即互斥锁)。这种锁的特点是一旦请求锁失败,会导致当前线程(请求锁失败的线程)会放弃CPU,进入阻塞状态,把自己加入到锁的阻塞队列中,等待被唤醒。【放弃CPU...阻塞队列中说明必须进入到内核态,一旦放弃CPU,再获取CPU,站在CPU指令角度时间会相隔很久】。这种默认的互斥锁的成本比较大。

为了避免这个问题,就思考了一些不需要进行触发线程调度的锁的实现方式。

2)CAS机制

CPU相关概念:

      通用的CPU上都有类似的指令Compare And Swap CAS:给一个1)地址;2)预期的值;3)要替换的值 

      CAS(address,expect,newValue)  返回值是boolean类型,并且硬件保证了这整个指令的原子性

CAS内部做的事情:

if(*address==expect){//地址处是否是要替换的值

*address=newValue;//进行替换

return true;

}

return false;

//如下:

3)自旋锁(spin lock)

      整合1,2:【需要一种不触发线程调度的锁的实现】+【硬件提供了CAS机制->OS提供了CAS机制->JVM提供了CAS机制】,两者结合起来可以完成一个动作:实现一种自旋锁(spin lock)【不公平、不可重入、独占的

lock:

      boolean success=CAS(0x104,0,1);

      if(success){return;}

      //否则,加锁失败

      互斥锁基于的前提是计算机只有一个核时的,解决思路是早放弃CPU早让持有锁的线程释放锁,而自旋锁这种方案更适用于现代计算机:多核模式+一般来说,所得持有时间不会很长【线程放弃CPU到线程再拥有CPU的时间>>锁的持有时长】。在这种前提下,即使当前线程占用了一个核也没关系,很多另外的核上的线程就会释放锁

所以从纯实现角度,锁就分为了互斥锁(mutex)VS自旋锁(spin lock) 7.synchronized的锁的实现与优化

1)特点/策略:可重入的+不公平的+独占锁

2)实现上的优化策略:

a.锁消除策略:

      实际上,如果我们代码中只有主线程(单线程的代码)->所有做线程保护的 *** 作都是无用功(加锁,释放锁),编译器+JVM判断出只有一个线程时,就会消除掉所有锁的 *** 作,提升性能。

public class Main {
    private static synchronized void method(){
        System.out.println("你好");
    }

    /**
     * method理论上是被sync修饰的,需要加锁
     * 但是运行过程中只有主线程,加锁没有意义
     * 所以会使锁消除
     * @param args
     */
    public static void main(String[] args) {
        method();
    }
}
b.锁的粗化优化

前提:已经无法进行锁消除的情况下(不是单线程情况下)

锁粒度过细时,经过JVM粗化优化,锁的性能就会相对提高

c.锁升级(膨胀)【基于实现锁角度探讨】

偏向锁(bias lock)->轻量级锁(spin lock)->重量级锁(mutex lock形式实现的)

1)前言

a.关于Java对象的Hotspot版本实现——对象头的问题:

      【要关注标准(买一个板凳)不要过于关注于实现(板凳怎么造),所以其实不要过多关注于Java对象头是怎么实现的,因为我们是使用Java语言做业务开发,如果做的是JVM开发,那就应该花大量时间去研究】

b.Java对象是怎么实现的?

      语言解释:

      前提:Hotspot的实现语言是C++,写HotSpot时,就会有“对象”的概念,但这个对象不是我们Java中的对象,我们是要用C++的对象来实现Java的对象。所以,我们现在去讨论的实现的问题,是需要跳出Java中的概念逻辑的【相当于,我们用Java写应用程序,需要在JVM规定的集合中讨论,而我们要看JVM的实现,必然要跳出JVM这个框,再在外层去看实现】。Java对象的实现,在HotSpot中可以看作是一个C++级别的“对象”。

      从内存角度:

      就是内存中一块区域/空间,区域中进行数据的存储,应存储到对象中的数据(一个Java对象中逻辑上应该保存的信息):1)(关于对象的)属性的值;2)该对象的类信息(为了反射 r.getClass;3)对象的锁信息(包含wait set)

2)对象的锁信息怎么体现在HotSpot的实现中

HotSpot中实现的一个Java对象:

a.当没有对象被锁时,处于“无锁态”

      由于不需要存锁信息,所以这部分空间拿来存一些其他信息:hash、GC。

b.当最后的三个比特是101时进入偏向锁状态(第一次被锁定时进入偏向锁状态)

      前提:大部分多线程情况下,一个对象的锁,总是只被一个线程持有

      如有t1、t2、t3、t4四个线程,有lock对象,一般总是t1持有lock对象,所以,t1来申请锁的时候,就不需要走完整流程了,这就是偏向锁

      偏向锁的实现:

      偏向锁的存在时间:from第一次对象被加锁to有另外的线程也来参加锁的竞争(只有一个线程参与锁的竞争时有,如果其他线程也来参与竞争,偏向锁就没有了)

 

随着有其他线程参与了锁的竞争,偏向锁就会失效,所以需要真实的锁来工作,优先尝试使用轻量级来实现<---------CAS+spin lock

CAS+spin lock,这种情况一个锁的持有时间不会很长,所以这个线程不需要交出CPU,性能较好。这里旋锁可以分为:适应性自旋锁(自旋等待时间):一开始固定自旋10s,由于这次自旋申请到了->加大到20s,由于自旋申请失败了->5s【多次失败后就不再自旋(不再走轻量级锁了)】

随着轻量级锁不能解决问题,就会膨胀(inflation)为重量级锁:OS提供的mutex级别的锁,申请锁失败,需要放弃CPU,进入阻塞状态

{大部分对象不会被当作锁使用+对象头空间始终存在->对象头里就可以暂存一些其他信息

【从第一个线程尝试加锁到第二个线程尝加锁期间:该对象锁偏向于第一个线程,所以第一个线程过来时,走的是快速通道-》对象头记录线程信息,偏向哪个线程】

【从有多个线程参与抢锁开始:(一旦退出偏向状态,无法再回去)优先使用轻量级锁(CAS+spin lock,不进入内核态,只在JVM的用户态,解决锁的竞争问题),对象头里保存轻量级锁的地址】

【spin之后,还拿不到锁or自适应的情况下,spin之后,仍然拿不到所:走到重量级锁(放弃CPU,阻塞,需要内核态参与),对象头里保存重量级锁的地址】}

二、juc下的常见工具类(实践价值更高)

【面试价值不高】

1.atomic.*:atomic包下有一堆原子类

对于原子性加锁成本高,专本提供了原子性 *** 作

package com.wy.atomic_demo;
import java.util.concurrent.atomic.AtomicInteger;

public class Main {
    static int i = 0;
    //从Java语言角度是对象:一种特殊的“包装类”,存的原子对象
    static AtomicInteger j = new AtomicInteger(0);//原子的Integer对象
    public static void main(String[] args) {
  //      // i++,i-- *** 作不是原子的,想做到原子,就需要进行加锁 *** 作
  //      // 但加锁这个动作,其实成本是挺高的,所以可使用下面的原子对象进行 *** 作
  //      i++;
  //      i--;

        // JVM 保证了这些 *** 作是原子的,并且实现原子时,没有用到锁
        // 整体来讲,性能能好些
        // 内存其实通过 CAS + 多次尝试实现
        // j = 0   CAS(j, 0, 1),成功了,原子 *** 作成功;失败了,再次尝试  CAS(j, 1, 2)
        j.getAndIncrement();    // 先取值,后加 1,视为是 j++
        j.getAndDecrement();    // 先取值,后减 1,视为是 j--
        j.incrementAndGet();    // ++j
        j.getAndAdd(5);     // d = j; j = j + 5
    }
}
//内部是存储了这些值与 *** 作,只是把这些值包装了起来成了一个原子对象,达到不使用锁也保证了 *** 作的原子性

原子的对象很多:

 AtomicInteger (Java Platform SE 8 )

2.locks.*:锁,关于实现锁的工具

1)标准(以接口形式体现)

2)锁的条件和变量的具体实现

3)实现锁的工具

a.底层工具

LockSupport

b.提供方便我们自己实现锁的机制

AQS系列:

3.信号量等工具对象

本质都是线程之间在通信(共享数据的读取)

1)Semaphore(信号量):可以理解成可以被多个线程持有锁

      修改代码如下,子线程等用户输入东西,就往回放一个许可,所以阻塞就会继续往下走【许可只有数量的关系,并不要求一定是取的线程才能放回】:

package com.wy.semaphore_demo;

import java.util.Scanner;
import java.util.concurrent.Semaphore;

public class Main {
    // permits: 许可
    // 信号量:十字路口的红绿灯
    // permits = 5 : 允许最多 5 个拿到许可证
    // 可以一个线程取一个,可以有 5 个线程
    // 也可以一个线程就取 5 个,只有一个线程
    // 许可只有数量的关系,并不要求一定是取的线程才能放回
    static Semaphore semaphore = new Semaphore(5);

    static class MyThread extends Thread {
        @Override
        public void run() {
            Scanner scanner = new Scanner(System.in);
            scanner.nextLine();
            semaphore.release();        // 放回一个许可
        }
    }

    public static void main(String[] args) throws InterruptedException {
        MyThread t = new MyThread();
        t.start();

        semaphore.acquire();
        semaphore.acquire();
        semaphore.acquire();
        semaphore.acquire();
        semaphore.acquire();
        semaphore.acquire();    // 阻塞
        System.out.println("成功");
    }
}

信号量和信号:信号量:类比信号灯 信号:具体发出去的内容

2)

CountDown(CD):倒计时,归零的一个过程;Latch:栓,阻拦/放行的东西

CountDownLatch:倒数计时的门栓

package com.wy.count_down_latch_demo;
import java.util.concurrent.CountDownLatch;

public class Main {
    // count: 计数器为3个,只有3个全部报到了,门闩才会打开
    static CountDownLatch countDownLatch = new CountDownLatch(3);


    static class MyThread extends Thread {
        @Override
        public void run() {
            //报道了三个人时,门闩才会被打开
            countDownLatch.countDown();
            countDownLatch.countDown();
            countDownLatch.countDown(); //走完这就相当于3人报道完了主线程可以向下执行了
        }
    }

    public static void main(String[] args) throws InterruptedException {
        MyThread t = new MyThread();
        t.start();

        countDownLatch.await();//主线程在等,默认情况下执行不下去
                               //等条件来:三个人来报道,就可以往下执行

        System.out.println("门闩被打开了");
    }
}

主线程先阻塞,子线程走完三次冷却计时结束,才能继续走【相当于,一个人玩游戏机,三个币才能被打开,不断投币,投够三个币(三个new CountDown(3))门闩才能被打开。主线程由于在等待,所以三次投币的都是其他线程】

使用CountDownLatch场景

4.线程池相关

最主要的一条线:Executor接口

Callable线:发布任务,委托其他线程进行计算,计算后同步回计算的结果

2)(同步的问题)

3)(数据交换/写共享数据的问题->线程安全的问题)

package com.wy.callable_demo;

import java.util.concurrent.*;

public class Main {
    static class FibCalc implements Callable {
        private final int n;

        FibCalc(int n) {
            this.n = n;
        }

        private long fib(int n) {
            if (n == 0 || n == 1) {
                return 1;
            }

            return fib(n - 1) + fib(n - 2);
        }

        @Override
        public Long call() throws Exception {
            return fib(n);//返回fib数的值
        }
    }

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        ExecutorService service = Executors.newSingleThreadExecutor();
        FibCalc task = new FibCalc(45);//主线程发布了一个任务
        //futur:未来,期望;未来会获取到这个值,但可能因为计算获取到的慢
        Future future = service.submit(task);//提交任务快
        Long r = future.get();  // 这一步实际上是在等任务计算完成,所以,时间可能需要很久
        System.out.println(r);
    }
}

5.线程安全版本的一些数据结构【最常见的:阻塞队列】

ArrayList、LinkedList、HashMap都不是线程安全的,关于线程安全的一些数据结构:

package com.wy.copy_on_write_demo;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.TimeUnit;

public class Main {
    static CopyOnWriteArrayList cow = new CopyOnWriteArrayList<>();

    static class MyThread extends Thread {
        @Override
        public void run() {
            System.out.println(cow.size()); // 只读 *** 作
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(cow.size()); // 只读 *** 作
        }
    }

    public static void main(String[] args) throws InterruptedException {
        cow.add("1");
        cow.add("2");
        cow.add("3");

        MyThread t = new MyThread();
        t.start();

        System.out.println(cow.size()); // 只读 *** 作
        TimeUnit.SECONDS.sleep(1);
        cow.add("4");   // 由于进行了写的 *** 作,所以,会为主线程复制一份元素
                        // 导致不是同一份数据了
                        //即未休眠前主线程子线程打印的都是3,而休眠1s后,主线程由于执行写 *** 作,会针对主线程复制一份顺序表
                        //这是主线程和子线程看到的就不是一个数据了,而是两份数据,主线程添加了元素size()为4,子线程仍旧读取到的size为3
        System.out.println(cow.size()); // 只读 *** 作
    }
}
//结果:
//3
//3
//3
//4

图析:

1.⭐ 

1)两种本质上都是动态数据搜索使用的数据结构

哈希表的实现   搜索树的实现
规则复杂->要做到线程安全比较困难

2)HashMap是Java中实现的哈希表,解决冲突的方式是拉链法

3)HashMap中的put过程是怎么回事

2.HashMap在多线程环境下

1)HashMap是线程安全的吗:不是

get()是只读 *** 作,不需要太过于思考线程安全的问题

put()是写 *** 作,思考线程安全基于put *** 作思考

2)正确的使用标准上,永远不要在多线程环境下(有共享的)使用HashMap

头茬问题:在多线程环境下,头插可能会把链表变成一个环,导致遍历链表变成死循环,导致map.get()和map.put() *** 作永远不可能返回了【现象:CPU100%,程序不动了。这种错误的后果过于恶劣】,所以改成尾插,尾插数据还是可能错的,但至少不会死循环了,让结果不至于那么糟糕 

3)怎么把HashMap变成线程安全

方法1:一把锁解决:

sync put(...){...} 

sync get(...){...}

可以做到线程安全但性能较差,让本身不需要互斥的线程进行互斥了

这是Dictionary(Java早期设计的)

方法2:使用java.util.concurrent.ConcurrentHashMap

JDK1.7:分段锁

JDK1.8时:只针对某个链表做互斥,只要不是同一个链表,就不需要互斥

1.7难在扩容:一旦扩容,是涉及所有链表,只针对某个链表做互斥没有意义,1.8是这样做的:当前遭遇扩容的线程,只负责扩容+搬一个元素,期间新老数组同时存在

 

(像如果sync锁,一个线程搬,其他线程就是锁的,没办法帮忙搬)

三、多线程:并发编程总结

语法+OJ(数据结构+编程能力) 5分

数据库的使用  4分

多线程  3分{

理论部分 5分{

实践中+面试中都很重要:

(基础——本质上在说调度问题)

      1.前提:计组+OS的基本致死

      2.什么是多线程(调度单位);什么是调度

      3.多线程程序的不确定性

      4.多线程的状态

(高阶——线程安全方面)

      5.数据的共享;JVM运行时内存区域划分+哪些区域是共享的

      6.什么是线程安全

      7.两个角度理解线程安全:共享&&有写 *** 作;原子性、内存可见性、代码重排序

      8.JMM:Java内存模型(工作内存+主内存)

      9.happends-before(了解)

      10.锁的理论(sync的使用和互斥)

      11.volatile的理解

      12wait-notify

13.线程池的理论

只是为面试中准备的:(锦上添花的知识点)

1.锁的策略

2.sync的优化

3.ConcurrentHashMap(HashMap的知识点要知道清楚)

}

代码编写 2分{【难点在这】

1.单例模式

2.阻塞队列、定时器

3.juc下的其他类

【看多线程代码:视角以线程为视角,看清楚线程的职责、线程和其他线程之间如何交互,不要以类、方法为视角【类、对象、方法完全可以被多个线程调用】】

}

}

欢迎分享,转载请注明来源:内存溢出

原文地址: https://outofmemory.cn/langs/892295.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-05-14
下一篇 2022-05-14

发表评论

登录后才能评论

评论列表(0条)

保存