多线程方面


进程和线程

进程:进程是资源分配的最小单位。当一个程序运行的时候,至少会有一个进程的产生。一个进程可以包含多个线程,不同进程间数据很难共享。进程有自己的独立地址空间,每启动一个进程,系统就会为它分配地址空间,建立数据表来维护代码段、堆栈段和数据段。

线程:线程是程序执行的最小单位。线程是共享进程中的数据的,使用相同的地址空间,因此CPU切换一个线程的花费远比进程要小很多,同时创建一个线程的开销也比进程要小很多。

多个线程共享进程的堆和方法区 (JDK1.8 之后的元空间)资源,但是每个线程有自己的程序计数器、虚拟机栈 和 本地方法栈
在这里插入图片描述
简单来讲,就是一个程序运行的时候至少会有一个进程像Main函数,而这一个进程会包含很多个线程。(衣服上的口袋可以包含很多东西)
进程是资源分配的最小单位,所以计算机在产生进程的时候,会为他分配好资源,进程中的线程只能使用进程的资源。(尽管东西很多,也不会超过口袋的大小)
当上下文切换的时候,进程的切换会比线程的消耗大很多,所以我们在使用的时候都是以线程的上下文切换为目的。
像进程和线程拥有的资源,如堆、栈、指针等内容,推荐去看操作系统会有更深的理解。

并发与并行的区别?

并发: 同一时间段,多个任务都在执行 (单位时间内不一定同时执行);
并行:同一时刻内,多个任务同时执行。

使用多线程可能带来什么问题?(内存泄漏、死锁、线程不安全等等)

并发编程的目的就是为了能提高程序的执行效率提高程序运行速度,但是并发编程并不总是能提高程序运行速度的,而且并发编程可能会遇到很多问题,比如:内存泄漏、死锁、线程不安全等等。

创建线程有哪几种方式?

  • 继承 Thread 类 ,不建议使用:避免OOP单继承局限性。
  • 实现 Runnable 接口 ,避免了单继承的局限性,灵活方便同一个对象被多个线程使用。
  • 实现Callable接口 ,可以定义返回值,可以抛出异常。
  • 使用线程池

线程池的三大方法,七大参数,四种拒绝策略

三大方法

1.ExecutorService pool = Executors.newSingleThreadExecutor()
    //单个线程的线程池
2.ExecutorService pool = Executors.newFixedThreadPool(5)
    //固定的线程的大小的线程池
3.ExecutorService pool = Executors.newCachedThreadPool()
    //可伸缩的线程池,

七大参数

int corePoolSize,//核心线程池大小
int maximumPoolSize,//最大的线程池大小
long keepAliveTime,//存活的时间,超时了没有人调用就会释放,这里的生命周期有2个约束,1是针对于超过corePoolsize数量的线程,2是处于非运行状态的线程。即非核心线程数存活时间,也就是说,如果一般2个线程大小,3个备用,当超过2个的时候有使用备用的,当使用的数量变少的时候,这个等待时间起作用,例如设置3S,3S后就会停止使用,变为2个线程的大小
TimeUnit unit,//超时时间的单位
BlockingQueue<Runnable> workQueue,//阻塞队列
ThreadFactory threadFactory,//线程工厂,创建线程,一般不用动
RejectedExecutionHandler handler//拒绝策略

四种拒绝策略

ThreadPoolExecutor.AbortPolicy()//当阻塞队列和最大线程数都满了,抛出异常RejectedExecutionException来拒绝新任务的处理
ThreadPoolExecutor.CallerRunsPolicy()//哪里来的回到哪里去执行,比如main方法给线程池,要求执行任务1,但是线程池满了,便会返还给main方法,让main方法自己去执行任务1,由调用的线程处理
ThreadPoolExecutor.DiscardOldestPolicy()//队列满了,尝试去和最早的线程竞争,成功执行,失败丢弃,也不会抛出异常,喜新厌旧策略
ThreadPoolExecutor.DiscardPolicy()//队列满了,不会丢出异常,但会丢掉任务,不会竞争

示例

public static void main(String[] args)  {
    ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
        2,
        5,
        3,
        TimeUnit.SECONDS,
        new LinkedBlockingQueue<Runnable>(3),
        Executors.defaultThreadFactory(),
        new ThreadPoolExecutor.AbortPolicy()
    );
	}
}
//设置,一般开放2个柜台,候客区有3个,但有3个柜台备用可用,
//当柜台人数满了,候客区满了(阻塞队列),触发最大并发,有5个柜台可以用了,
//当都满了,也就是8个(最大+阻塞),使用AbortPolicy的拒绝策略(人满了,候客区满了,抛出异常)

线程声明周期和状态

在这里插入图片描述

什么是上下文切换?

多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。

概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换。

上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。

Linux 相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少。

产生死锁必须具备以下四个条件:

互斥条件:该资源任意一个时刻只由一个线程占用。
请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
不剥夺条件:线程已获得的资源在末使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系

如何避免死锁

既然产生死锁需要上述的四个条件,那只需要破坏产生死锁的四个条件中的其中一个就可以避免死锁:

破坏互斥条件 :这个条件我们没有办法破坏,因为我们用锁本来就是想让他们互斥的(临界资源需要互斥访问)。
破坏请求与保持条件 :一次性申请所有的资源。
破坏不剥夺条件 :占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
破坏循环等待条件 :靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。

sleep() 方法和 wait() 方法区别和共同点?

来自不同的类wait()来自object类,sleep()来自Thread

一般休眠的话用TimeUtil,来自concurrent包,juc包中
两者最主要的区别在于:sleep() 方法没有释放锁,抱着锁休眠,而 wait ()方法释放了锁 。
使用的范围不同,wait()必须在同步代码块中,sleep()可以在任何的地方使用
Wait() 通常被用于线程间交互/通信,sleep() 通常被用于暂停执行。
wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify() 或者 notifyAll() 方法。sleep() 方法执行完成后,线程会自动苏醒。或者可以使用 wait(long timeout)超时后线程会自动苏醒。

为什么我们调用 start() 方法时会执行 run() 方法,为什么我们不能直接调用 run() 方法?

new 一个 Thread,线程进入了新建状态;调用 start() 方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。
start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。 而直接执行 run() 方法,会把 run 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。

从源码上来看,start()方法调用了native本地方法start0(),是通过底层调用来执行多线程,而run方法只是一个普通的方法

总结: 调用 start 方法方可启动线程并使线程进入就绪状态,而 run 方法只是 thread 的一个普通方法调用,还是在主线程里执行

synchronized 关键字

synchronized 关键字解决的是多个线程之间访问资源的同步性,synchronized关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行

这就会引出八锁问题,八锁问题的本质就是锁对象和锁类的差别和顺序的解释。有机会会有专栏。

synchronized 关键字最主要的三种使用方式:

1.修饰实例方法: 作用于当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁

synchronized void method() {
  //业务代码
}

2.修饰静态方法: 也就是给当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得 当前 class 的锁。因为静态成员不属于任何一个实例对象,是类成员( static 表明这是该类的一个静态资源,不管 new 了多少个对象,只有一份)。所以,如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。

synchronized void staic method() {
  //业务代码
}

3.修饰代码块 :指定加锁对象,对给定对象/类加锁。synchronized(this|object) 表示进入同步代码库前要获得给定对象的锁。synchronized(类.class) 表示进入同步代码前要获得 当前 class 的锁

synchronized(this) {
  //业务代码
}

关于synchronized来实现单例模式单例模式详解

总结:

synchronized 关键字加到 static 静态方法和 synchronized(class) 代码块上都是是给 Class 类上锁。
synchronized 关键字加到实例方法上是给对象实例上锁。
尽量不要使用 synchronized(String a) 因为 JVM 中,字符串常量池具有缓存功能!

构造方法可以使用 synchronized 关键字修饰么?

构造方法不能使用 synchronized 关键字修饰。构造方法本身就属于线程安全的,不存在同步的构造方法一说

JDK1.6 之后的 synchronized 关键字底层做了哪些优

JDK1.6 对锁的实现引入了大量的优化,如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技术来减少锁操作的开销。

锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,但是也有人说锁能进行降级。这种策略是为了提高获得锁和释放锁的效率。

JMM(Java)内存模型

Java内存模型,不存在,是一个概念,内存分为,线程的工作内存和主内存

JMM的一些同步的约定

1.线程解锁前,必须把共享变量,立刻刷回主存

线程使用主存的变量时,会将主存的变量拷贝到线程的工作空间中一份

2.线程加锁前,必须读取主存中的最新值到线程的工作内存中

3.加锁和解锁是同一把锁

8种操作

lock和unlock
在这里插入图片描述
内存交互操作有8种,虚拟机实现必须保证每一个操作都是原子的,不可在分的(对于double和long类型的变量来说,load、store、read和write操作在某些平台上允许例外)
lock (锁定):作用于主内存的变量,把一个变量标识为线程独占状态
unlock (解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
read (读取):作用于主内存变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用
load (载入):作用于工作内存的变量,它把read操作从主存中变量放入工作内存中
use (使用):作用于工作内存中的变量,它把工作内存中的变量传输给执行引擎,每当虚拟机遇到一个需要使用到变量的值,就会使用到这个指令
assign (赋值):作用于工作内存中的变量,它把一个从执行引擎中接受到的值放入工作内存的变量副本中
store (存储):作用于主内存中的变量,它把一个从工作内存中一个变量的值传送到主内存中,以便后续的write使用
write  (写入):作用于主内存中的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中

JMM对这八种指令的使用,制定了如下规则:

1.不允许read和load、store和write操作之一单独出现。即使用了read必须load,使用了store必须write。
2.不允许线程丢弃他最近的assign操作,即工作变量的数据改变了之后,必须告知主存
3.不允许一个线程将没有assign的数据从工作内存同步回主内存

4.一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。就是对变量实施use、store操作之前,必须经过assign和load操作

5.一个变量同一时间只有一个线程能对其进行lock。多次lock后,必须执行相同次数的unlock才能解锁

6.如果对一个变量进行lock操作,会清空所有工作内存中此变量的值,在执行引擎使用这个变量前,必须重新load或assign操作初始化变量的值

7.如果一个变量没有被lock,就不能对其进行unlock操作。也不能unlock一个被其他线程锁住的变量

8.对一个变量进行unlock操作之前,必须把此变量同步回主内存

Volatile

java提供的轻量级同步机制,线程共享数据

1.保证可见性

2.不保证原子性

3.禁止指令重排序

因为Volatile不保证原子性,那么要实现i++操作,在多线程的情况下,就需要加锁,因为i++有3步编码,但是可以用JUC的atomic包来进行原子操作,在不使用锁的情况下,实现i++

atomic与操作系统挂钩,在内存中直接修改值

内部是unsafe类,unsafe是一个特殊的类

指令重排

CPU会对指令进行重排序来优化

源代码-----编译器优化的重排-------指令并行也可能重排------内存系统也会重排-------执行

加了volatile就会在指令上面和下面各加一个内存屏障来保证指令顺序

并发编程的三个重要特性

1.原子性 : 一个的操作或者多次操作,要么所有的操作全部都得到执行并且不会收到任何因素的干扰而中断,要么所有的操作都执行,要么都不执行。synchronized 可以保证代码片段的原子性。
2.可见性 :当一个变量对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值。volatile 关键字可以保证共享变量的可见性。
3.有序性 :代码在执行的过程中的先后顺序,Java 在编译器以及运行期间的优化,代码的执行顺序未必就是编写代码时候的顺序。volatile 关键字可以禁止指令进行重排序优化。

ThreadLocal

通常情况下,我们创建的变量是可以被任何一个线程访问并修改的。如果想实现每一个线程都有自己的专属本地变量该如何解决呢? JDK 中提供的ThreadLocal类正是为了解决这样的问题。 ThreadLocal类主要解决的就是让每个线程绑定自己的值,可以将ThreadLocal类形象的比喻成存放数据的盒子,盒子中可以存储每个线程的私有数据。

如果你创建了一个ThreadLocal变量,那么访问这个变量的每个线程都会有这个变量的本地副本,这也是ThreadLocal变量名的由来。他们可以使用 get() 和 set() 方法来获取默认值或将其值更改为当前线程所存的副本的值,从而避免了线程安全问题。

public class ThreadLocaTest {
 
    private static ThreadLocal<String> local = new ThreadLocal<String>();
    public static void main(String[] args) throws InterruptedException {
        new Thread(new Runnable() {
            public void run() {
                ThreadLocaTest .local.set("local_A");
                //打印本地变量
                System.out.println("A : " + local.get());
            }
        },"A").start();
 
        Thread.sleep(2000);
 
        new Thread(new Runnable() {
            public void run() {
                ThreadLocaTest.local.set("local_B");
                System.out.println("B : " + local.get());
            }
        },"B").start();
    }
}
 
//A :local_A
//B :local_B

ThreadLocal 内存泄露问题

ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用,而 value 是强引用。所以,如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。这样一来,ThreadLocalMap 中就会出现 key 为 null 的 Entry。假如我们不做任何措施的话,value 永远无法被 GC 回收,这个时候就可能会产生内存泄露。ThreadLocalMap 实现中已经考虑了这种情况,在调用 set()、get()、remove() 方法的时候,会清理掉 key 为 null 的记录。使用完 ThreadLocal方法后 最好手动调用remove()方法

JUC 包中的原子类是哪 4 类?

1.基本类型

使用原子的方式更新基本类型

AtomicInteger:整形原子类
AtomicLong:长整型原子类
AtomicBoolean:布尔型原子类
2.数组类型

AtomicIntegerArray:整形数组原子类
AtomicLongArray:长整形数组原子类
AtomicReferenceArray:引用类型数组原子类
3.引用类型

AtomicReference:引用类型原子类
AtomicStampedReference:原子更新引用类型里的字段原子类
AtomicMarkableReference :原子更新带有标记位的引用类型
4.对象的属性修改类型

AtomicIntegerFieldUpdater:原子更新整形字段的更新器
AtomicLongFieldUpdater:原子更新长整形字段的更新器
AtomicStampedReference:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。

进程间的六种通信方式

一共分为6种方式:管道、消息队列、共享内存、信号量、信号、Socket。

1、管道

管道的通信方式是效率低的,因此管道不适合进程间频繁地交换数据。
把一个进程连接到另外一个进程的一个数据流成为管道,通常一个进程的输出作为另外一个进程的输入。如Linux中的管道符[ | ]。
本质是内核的一块缓存
Linux的管道主要有两种:无名管道和有名管道。
1.无名管道
无名管道是管道通信的一种原始的方法。主要特点是:
(1)他只能用于具有亲缘关系的进程之间的通信(也就是父子进程或者兄弟进程之间)。
(2)它是半双工的通信模式,具有固定的读端和写端。
(3)管道可以看成是一个特殊的文件,可以使用普通的read、write函数。但是它不是普通的文件,存在于内存中并不属于其他的文件系统。

2.命名管道(FIFO
有名管道是对无名管道的一个改进。其具有的特点是:
(1)互不相关的程序之间也能实现彼此通信。
(2)管道可以通过路径指出,在文件系统中是可见的。在建立管道后,进程之间把它当成普通的文件进行读(read)写(write)操作,和使用文件一样方便。
(3)严格遵循FIFO(先进先出)规则。对管道的数据的读总是从开始处返回数据,结尾处添加数据,不知道文件定位符的操作,如lseek()。

缺点:
1.半双工通信,一条管道只能一个进程写,一个进程读。
2.一个进程写完后,另一个进程才能读,反之同理。

2、消息队列

消息队列的通信方式就可以解决管道频繁交换效率低的问题。
消息队列是保存在内核中的消息链表,在发送数据时,会分成一个一个独立的数据单元,也就是消息体 (数据块)。
消息体是用户自定义的数据类型,消息的发送方和接收方要约定好消息体的数据类型,所以每个消息体都是固定大小的存储块,不像管道是无格式的字节流数据。
如果进程从消息队列中读取了消息体,内核就会把这个消息体删除。
比如,A进程要给B进程发送消息,A进程把数据放在对应的消息队列后就可以正常返回了,B进程需要的时候再去读取数 据就可以了。同理,B进程要给A进程发送消息也是如此。
在这里插入图片描述
消息队列生命周期随内核,如果没有释放消息队列或者没有关闭操作系统,消息队列会一直存在,而前面提到的匿名管道的生命周期,是随进程的创建而建立,随进程的结束而销毁。

缺点:
消息队列通信过程中,存在用户态与内核态之间的数据拷贝开销,因为进程写入数据到内核中的消息队列时,会发生从用户态拷贝数据到内核态的过程,
同理另一进程读取内核中的消息数据时,会发生从内核态拷贝数据到用户态的过程。

3、共享内存

解决消息队列通信过程中,存在用户态与内核态之间的数据拷贝开销的问题

共享内存的机制,就是拿出一块地址空间来,映射到相同的物理内存中。这样这个进程写入的东西,另外一个进程马上就能看到了,大大提高了进程间通信的速度。

例如,小学时候应该都玩过Windows的文件共享机制来共享其他电脑上的游戏,当我们将硬盘F在局域网内进行共享后,在局域网内的其他电脑就可以看到里面的内容。

与共享内存类似,不过是在内存中进行的。这样就可以提高通信的速度。

缺点:当多个进程向同一个共享内存中写入数据时可能会产生覆盖(如在工作中,当多人同时修改一份共享文档时别人可能会将你添加的内容修改删除或覆盖),所以需要一定的保护机制,而如果只读则没有任何问题。

4、信号量

解决共享内存的当多个进程向同一个共享内存中写入数据时可能会产生覆盖的问题。
信号量其实是一个整型的计数器,主要用于实现进程间的互斥与同步,而不是用于缓存进程间通信的数据。

信号量表示资源的数量,控制信号量的方式有两种原子操作:
一个是P操作,这个操作会把信号量减去1,相减后如果信号量<0,则表明资源已被占用,进程需阻塞等待;相减后如果信号量>=0,则表明还有资源可使用,进程可正常继续执行。

另一个是V操作,这个操作会把信号量加上1,相加后如果信号量<=0,则表明当前有阻塞中的进程,于是会将该进程唤醒运行;相加后如果信号量>0,则表明当前没有阻塞中的进程。

P操作是用在进入共享资源之前,V操作是用在离开共享资源之后,这两个操作是必须成对出现的。

具体的了解可以查询通过信号量表示的生产者和消费者问题。

5、信号

上面说的进程间通信,都是常规状态下的工作模式。对于异常情况下的工作模式,就需要用「信号」的方式来通知进程。

信号是进程间通信机制中唯一的异步通信机制,因为可以在任何时候发送信号给某一进程,一旦有信号产生,我们就有下面这几种,用户进程对信号的处理方式。

例如:我们在CMD命令中,如果发现一个命令时间太长,想要中断,可以Ctrl+C来进行中断,这就是一种信号机制。
例如:在Linux中也有各种信号,可以通过kill -l 来查询所有信号。

6、Socket

socket用于实现跨网络不同主机之间的通信

socket是一个套接字,通过四元组(在tcp中为四元组,即源ip地址,目的ip地址,源端口号,目的端口号,在udp中只需要源ip地址和源端口号即可)来唯一标识接收和发送数据的两方,用于这两方之间的通信。

TCP的Socket模型
![在这里插入图片描述](https://img-blog.csdnimg.cn/f183e819c6be43f4a1bd14413d6068a6.png

  1. 服务端和客户端初始化 socket ,得到⽂件描述符;
  2. 服务端调⽤ bind ,将绑定在 IP 地址和端⼝;
  3. 服务端调⽤ listen ,进⾏监听;
  4. 服务端调⽤ accept ,等待客户端连接;
  5. 客户端调⽤ connect ,向服务器端的地址和端⼝发起连接请求;
  6. 服务端accept 返回⽤于传输的 socket 的⽂件描述符;
  7. 客户端调⽤write写⼊数据;
  8. 服务端调⽤ read 读取数据;
  9. 客户端断开连接时,会调⽤ close ,那么服务端 read 读取数据的时候,就会读取到了 EOF ,待处理完数据后,服务端调⽤ close ,表示连接关闭

UDP的Socket模型
在这里插入图片描述

UDP 是没有连接的,所以不需要三次握手,也就不需要像 TCP 调用 listen 和 connect,但是 UDP 的交互仍然需要 IP 地址和端口号,因此也需要 bind。

对于 UDP 来说,不需要要维护连接,那么也就没有所谓的发送方和接收方,甚至都不存在客户端和服务端的概念,只要有一个 socket 多台机器就可以任意通信,因此每一个 UDP 的 socket 都需要 bind。

关于JUC包

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值