Java多线程

本文详细介绍了Java中的多线程概念及其三种实现方式:继承Thread类、实现Runnable接口和实现Callable接口。讨论了线程同步、死锁、阻塞队列以及线程安全问题,特别强调了Volatile关键字的作用。此外,还分析了HashMap、Hashtable和ConcurrentHashMap的区别,并介绍了CountDownLatch和Semaphore在并发控制中的应用。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

为什么要用多线程:

一个线程运行过程中因为各种情况导致堵塞,堵塞之后CPU就闲着,为了不让CPU闲着,提高CPU利用率,就创建多个线程,一个线程堵塞的时候可以运行另一个线程。

面试题:进程和线程的区别:

Java程序运行时至少2条线程:main线程、垃圾回收线程。

 

Java多线程的3种实现方式

1.继承Thread类

 

注意:通过线程实例调用start()方法,只是让线程进入“就绪”状态,“就绪”态的线程具备了抢CPU的能力,抢到CPU才能执行。

 

2.实现Runnable接口,创建Thread对象

 

3.实现Callable接口,创建FutureTask对象,创建Thread对象

 

 

Callable的call()和run()一样被JVM调用,且call()有返回值,返回值是什么类型,Callable泛型就是什么类型。

 

Thread常用方法

1.获取当前线程名字(两种方式)

     

2.设置线程名字(两种方式)

setName():

创建带参构造,参数是名字,调用父类带参构造:

    

3.sleep(毫秒数)

    

4.手动交出CPU使用权

5.获取id、获取优先级

 

多线程和run()方法的工作方式:

1.定义MyThread继承Thread,new两个mt1、mt2

        

mt1和mt2有各自的run()方法,mt1和mt2两线程抢夺CPU使用权,抢到后执行自己的run()方法;

二者在两条线上执行各自的方法,虽然有时mt1执行有时mt2执行,但是二者互不干扰,即没有产生线程安全问题。

2.定义MyRunnable实现Runnable,new两个mr1、mr2参与Thread()构造

        

同上,th1、th2两线程执行各自的run()方法,不会产生线程同步问题。

3.定义MyRunnable实现Runnable,new一个mr参与Thread()构造

        

这样th1和th2执行的是同一个run()方法,二者谁抢到CPU谁执行,会产生线程安全问题:

th1先--99再打印99再--98没打印98

th2先--97在打印97再--96没打印96

th1再打印98再--95在打印95……再--0再打印0,th1结束

th2打印96,发现num已经==0,结束

 

线程同步机制解决线程安全问题

1.同步代码块

创建一个同步代码块synchronized(锁对象){ 可能产生线程安全问题的代码 }:

锁对象要唯一,即不同线程运行run()方法都对应的同一个锁对象,同一个锁才能实现同步。

线程抢到执行权后,有锁就能执行,没锁就阻塞,让出执行权。

同步代码块要把判断num > 0包含在内,否则仍有可能出现-1。

2.同步方法

创建一个synchronized修饰的方法,把可能出现线程安全问题的代码放进去。不需要自己指定锁(底层肯定加锁)。

 

死锁

线程死锁是指由于两个或多个线程互相持有对方所需要的资源(比如锁),导致这些线程处于等待状态无法往前执行。

线程1要先获取obj1,再获取obj2,才能执行;线程2要先获取obj2,在获取obj1,才能执行;

线程1执行了几次之后,突然有一次,线程1获取obj1之后,CPU被线程2抢走了,

线程2先获取了obj2,但是obj1在线程1那里,因此线程2等待,

而线程1因为obj2被线程2获取,也在等待。

这样程序处于运行状态,但是没有往前推进。

 

生产者消费者(等待唤醒机制)(线程间通信)

生产者 生产 ---- 产品 ---- 消费者 消费

1.理想状态

先生产者生产产品,再消费者消费产品,按照先后顺序来。

2.消费者等待

如果消费者消费产品,发现没有产品,则消费者等待;生产者生产产品,叫醒消费者;消费者消费产品。

3.生产者等待

如果生产者生产产品,发现还有产品,则生产者等待;消费者消费产品,叫醒生产者;生产者生产产品。

总结:

生产者得到执行权时,如果没有产品就生产,并叫醒消费者,如果有产品就等待;

消费者得到执行权时,如果有产品就消费,并叫醒生产者,如果没有产品就等待。

以上方法由锁对象调用,因此也要求锁对象唯一,才能保证唤醒/等待的是同一个共享资源上的线程。

 

阻塞队列实现等待唤醒机制

生产者生产出产品,如果队列没满就放进队列,如果队列满了就等待,如果放的时候队列为空就唤醒消费者;

消费者要消费产品,如果队列里有产品就消费,如果队列里没有产品就等待,如果消费的时候队列是满的就唤醒生产者。

可以实现阻塞等待效果:

但是也有顺序错的:

这是为什么呢?以take()方法为例,查看源码:

内部在执行取操作是加锁的,因此不需要我们自己加锁实现阻塞等待,而我们的代码:

take()内部是加锁的,而println()在锁外边,因此输出可能会出现随机情况。

 

线程状态

线程运行之后是和CPU产生的关系,JVM没有定义运行状态

 

线程池

过一会才运行结束。

为什么都是thread-1?因为提交第一个任务时,线程池里没有线程,就创建thread-1执行任务,在提交第二个任务前,thread-1已经把第一个任务执行完了,thread-1被线程池回收,第二个任务提交时,线程池里有thread-1,就不创建线程,直接让thread-1执行。

(上面这句话应该不对,因为接下来创建线程池并指定最大线程数为2的话,执行同样的任务就是会创建2条线程,应该是线程池有自己的算法。)

让thread-1睡100ms,看看这时是否创建新线程:

这说明在第二个任务提交时thread-1还没睡醒,于是线程池又创建了thread-2执行第二个任务。

线程池不用了还可以关闭:

注意:这里的“任务”指的是Runnable。

创建线程池时指定最大线程数:

 X 4

说明创建了4条线程。现在把最大线程数改为2:

 X 4

说明创建了2条线程。

创建线程池时指定各种参数:

Ctrl B查看定义,发现前面用的两个创建线程池的方法,都是调用了另一个方法并指定了一些参数:

于是我们自己调用这个方法:

补充1:

查看定义:

查看DefaultThreadFactory类的定义,发现有一个方法:

就是用new Thread()的方式创建线程,只不过指定了几个参数。

即Executors.defaultThreadFactory()返回一个DefaultThreadFactory类对象,该对象通过new Thread()的方式创建新线程。

补充2:

第5个参数,任务队列:线程池内的线程数量已满且没有空闲线程时,当又有新任务提交,就会放在任务队列等待。

第7个参数,任务拒绝策略:

什么时候执行拒绝策略:当线程池中没有空闲线程,任务队列也满了,这时又有新任务提交,就会执行拒绝策略。

有哪些拒绝策略:

测试一下最后一种:

    

说明是让主线程执行。

 

Volatile关键字

问题:

JMM(Java内存模型)解释:

堆内存中创建共享数据,创建线程,线程会把共享数据拷贝一份到线程栈的临时存储空间,这块空间叫变量副本。线程使用数据是从变量副本获取的。

如果一个线程修改数据,会先修改变量副本中的数据,再把数据副本拷贝一份到共享数据。

修改后,另一个线程可能会访问共享数据,也可能不会,这个是不可控的。

如果不访问的话,另一个线程的变量副本还是老数据,这就会出问题。

Volatile关键字:

修饰共享数据变量,则强制线程每次使用变量副本都会看一下共享区域最新的值。

另一种解决方法:同步代码块:

同步代码块会强制每次获得锁后都会更新数据:

 

原子性

每一步都可能被抢走CPU,导致数据错误。

比如线程1和线程2都想把共享数据100+1,结果理应是102,

线程1读取共享数据100到本线程栈,+1,

这时CPU被线程2抢走,线程2读取共享数据100到本线程栈,+1,

这时CPU又被线程1抢走,线程1把变量副本101赋给共享数据,

这时CPU又被线程2抢走,线程2把变量副本101赋给共享数据,

最终,共享数据的值是101。

注意:上面的volatile关键字只能保证线程每次使用变量副本时都是最新的共享数据,但不能保证原子性

同步锁就可以保证原子性。

AtomicInteger对象保证原子性:

Atomic原理:CAS + 自旋 = 自旋锁:

CAS:Compare And  Swap 比较并交换。

涉及三个操作数:内存值,旧值,新值。

新值 == 内存值,修改成功:

A线程抢到CPU,把内存值100拷贝到变量副本,并拷贝给旧值,然后变量副本++变成101,此时变量副本101就是新值;

此时比较旧值100和内存值100相等,说明在线程A获取内存值之后没有别的线程修改内存值;

这时就执行修改,把新值101赋值给内存值。

新值 != 内存值,修改失败,自旋:

A线程抢到CPU,把内存值100拷贝到变量副本,并拷贝给旧值,然后变量副本++变成101,此时变量副本101就是新值;

此时CPU被B线程抢到,B线程把内存值100拷贝到变量副本,并拷贝给旧值,然后变量副本++变成101,此时变量副本101就是新值;

此时B线程比较旧值100和内存值100相等,说明在线程B获取内存值之后没有别的线程修改内存值;

这时B线程执行修改,把新值101赋值给内存值。

此时CPU被A线程抢到,A线程比较旧值100和内存值101不相等,说明在此之前已经被修改了,就不执行修改,把新的内存值再次拷贝给变量副本,再来一轮。

这个把新内存值再次拷贝给变量副本的过程就叫自旋。

小结:

synchronized和CAS的对比(悲观锁和乐观锁)

 

HashMap、Hashtable和ConcurrentHashMap

Hashtable是同步的,线程安全的,只要有线程访问Hashtable,就对整个表加悲观锁(意思是所有方法都是同步方法),不让其他线程访问,因此效率也比较低。

HashMap不是同步的,因此多个线程同时访问可能会出现数据错误,但是效率高。

而ConcurrentHashMap既能保证同步,又比Hashtable效率高:

ConcurrentHashMap JDK1.7版本(HashMap由数组+链表实现)

CHM在new一个对象的时候在底层创建一个默认长度为16,默认加载因子为0.75的数组,数组名Segment;再创建一个长度为2的小数组,把地址值赋给Segment[0],其他元素都是null;

当有键值对要存入时,根据键的哈希值计算出Segment数组的索引,如果该索引处为null,就创建一个和Segment[0]一样长的小数组,再根据哈希值计算出小数组的索引,这叫做二次哈希,如果该索引处为null,则直接添加,如果不是null,就把老元素挂在新元素上,新元素添加。当小数组存到第二个(1/2=0.5<0.75,2/2=1>0.75)就扩容两倍(和HashMap一样),大数组无法扩容。

这样就相当于CHM是把16个哈希表存在一个数组里形成一个大哈希表。当有一个线程访问CHM时,比如该键的哈希值对应的大数组索引是5,那么只会把索引5上的小哈希表锁起来,其他小哈希表不会上锁,这样提高了一部分效率。

小结

ConcurrentHashMap初始化16个元素后每个元素都发展成一个小哈希表,有线程访问某元素,只把该元素对应的小哈希表上锁,其他元素仍可以访问。

ConcurrentHashMap JDK1.8版本(HashMap由数组+链表+红黑树实现)

Ctrl B查看定义,Alt 7查看类的结构大纲,Alt Ctrl Shift U查看类的继承实现关系。

ConcurrentHashMap空参构造啥都没做,是空的,父类的空参构造也是空的,啥都没做。

put()方法中会判断类中的table是否为null,如果是就初始化。即添加第一个元素的时候才初始化。

添加元素时,用键的哈希值计算应该存的索引;如果该索引元素为null,以CAS算法往里添加。

如果不为null,已有元素,由于ConcurrentHashMap元素是volatile修饰的,因此可以在多线程环境中获取该索引下的最新地址,把他挂在新节点下面,新节点添加。

链表长度大于等于8,自动转为红黑树。

当有线程访问ConcurrentHashMap时,只把访问的索引下的链表或红黑树用synchronized锁起来。怎么锁:以链表或红黑树的头节点作为锁对象,配合悲观锁。

 

CountDownLatch(线程间通信)

场景:三个孩子吃完饺子后,妈妈收拾碗筷:

 

Semaphore控制公共资源的最大访问线程个数

场景:一个方法,多个线程执行,最多允许2个线程同时执行。

可知没有出现同时存在三张通行证的时候。

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值