线程知识总结
一、线程
线程就是进程中运行的多个子任务,是操作系统调用的最小单元
线程的状态
- 初始(NEW):新创建了一个线程对象,但还没有调用start()方法。
- 可运行(RUNNABLE):线程对象创建后,调用了该对象的start()方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取cpu 的使用权
- 运行(RUNNING):可运行状态(runnable)的线程获得了cpu 时间片(timeslice) ,执行程序代码。
- 阻塞(BLOCKED):阻塞状态是指线程因为某种原因放弃了cpu 使用权,也即让出了cpu timeslice,暂时停止运行。直到线程进入可运行(runnable)状态,才有机会再次获得cpu timeslice 转到运行(running)状态。阻塞的情况分三种:
- 等待阻塞:运行(running)的线程执行o.wait()方法,JVM会把该线程放入等待队列(waitting queue)中。
- 同步阻塞:运行(running)的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则JVM会把该线程放入锁池(lock pool)中。
- 其他阻塞:运行(running)的线程执行Thread.sleep(long ms)或t.join()方法,或者发出了I/O请求时,JVM会把该线程置为阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入可运行(runnable)状态。
- 终止(TERMINATED):表示该线程已经执行完毕。
线程状态之间的切换
线程状态切换.png几个方法的比较
- Thread.sleep(long millis):进入TIMED_WAITING状态,不释放对象锁,millis后线程自动苏醒进入就绪状态。作用:给其它线程执行机会的最佳方式。
- Thread.yield():当前线程放弃获取的CPU时间片,不释放锁资源,由运行状态变为就绪状态,让OS再次选择线程。作用:让相同优先级的线程轮流执行,但并不保证一定会轮流执行。实际中无法保证yield()达到让步目的,因为让步的线程还有可能被线程调度程序再次选中。Thread.yield()不会导致阻塞。该方法与sleep()类似,只是不能由用户指定暂停多长时间。
- thread.join()/thread.join(long millis):当前线程里调用其它线程t的join方法,当前线程进入WAITING/TIMED_WAITING状态,不释放锁资源。线程t执行完毕或者millis时间到,当前线程一般情况下进入RUNNABLE状态,也有可能进入BLOCKED状态(因为join是基于wait实现的)。
- obj.wait(),当前线程调用对象的wait()方法,当前线程释放对象锁,进入等待队列。依靠notify()/notifyAll()唤醒或者wait(long timeout) timeout时间到自动唤醒。
- obj.notify()唤醒在此对象监视器上等待的单个线程,选择是任意性的。notifyAll()唤醒在此对象监视器上等待的所有线程。
二、线程池
线程池的工作原理:线程池可以减少创建和销毁线程的次数,从而减少系统资源的消耗
当一个任务提交到线程池时:
a. 首先判断核心线程池中的线程是否已经满了,如果没满,则创建一个核心线程执行任务,否则进入下一步
b. 判断工作队列是否已满,没有满则加入工作队列,否则执行下一步
c. 判断线程数是否达到了最大值,如果不是,则创建非核心线程执行任务,否则执行饱和策略,默认抛出异常
线程池的种类
- FixedThreadPool:可重用固定线程数的线程池,只有核心线程,没有非核心线程,核心线程不会被回收,有任务时,有空闲的核心线程就用核心线程执行,没有则加入队列排队
- SingleThreadExecutor:单线程线程池,只有一个核心线程,没有非核心线程,当任务到达时,如果没有运行线程,则创建一个线程执行,如果正在运行则加入队列等待,可以保证所有任务在一个线程中按照顺序执行,和FixedThreadPool的区别只有数量
- CachedThreadPool:按需创建的线程池,没有核心线程,非核心线程有Integer.MAX_VALUE个,每次提交
任务如果有空闲线程则由空闲线程执行,没有空闲线程则创建新的线程执行,适用于大量的需要立即处理的并且耗时较短的任务 - ScheduledThreadPoolExecutor:继承自ThreadPoolExecutor,用于延时执行任务或定期执行任务,核心线程数固定,线程总数为Integer.MAX_VALUE
三、线程安全
Java并发的问题要从JMM讲起,
JMM- 在Java内存模型中,分为主内存和线程工作内存,线程使用共享数据时,都是先从主内存中拷贝到工作内存,使用完成之后再写入主内存。
- 每条线程还有自己的工作内存,线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的值的传递均需要通过主内存来完成。可以理解为线程之间通讯是通过共享内存的方式实现的。
- 在多线程环境下,不同线程对同一份数据操作,就可能会产生不同线程中数据状态不一致的情况,这就是线程安全问题的定义或者原因。
线程安全需要保证数据操作的两个特性:
- 原子性:对数据的操作不会受其他线程打断,意味着一个线程操作数据过程中不会插入其他线程对数据的操作
- 可见性:当线程修改了数据的状态时,能够立即被其他线程知晓,即数据修改后会立即写入主内存,后续其他线程读取时就能得知数据的变化
以上两个特性结合起来,其实就相当于同一时刻只能有一个线程去进行数据操作并将结果写入主存,这样就保证了线程安全。
四、锁
synchronized:JVM层面实现的,系统监控锁的释放与否;在并发量比较小的情况下,使用synchronized是个不错的选择。可以与wait()、notify()、nitifyAll()一起使用,从而进一步实现线程的通信。
ReentrantLock:使用代码实现的,系统无法自动释放锁,需要在代码中的finally子句中显式释放锁lock.unlock()。并发量比较高的情况下,使用ReentrantLock。
image.png
降低锁的竞争程度
串行操作会降低可伸缩性,上下文切换会减低性能
- 减少锁的持有时间
- 降低锁的请求频率
- 使用带有协调机制的独占锁,这些机制允许更高的并发性
- 使用分段锁(ConcurrentHashMap使用的锁分段技术,首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。在ConcurrentHashMap中使用了一个包含16个锁的数组,每个锁保护所有散列桶的1/16,其中第N个散列桶由第(N mod 16)个锁来保护。假设使用合理的散列算法使关键字能够均匀的分部,那么这大约能使对锁的请求减少到越来的1/16。也正是这项技术使得ConcurrentHashMap支持多达16个并发的写入线程。)
CAS算法
CAS是英文单词Compare and Swap(比较并交换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。CAS算法涉及到三个操作数
1.需要读写的内存值 V
2.进行比较的值 A
3.拟写入的新值 B
更新一个变量的时候,只有当变量的预期值A和内存地址V当中的实际值相同时,才会将内存地址V对应的值修改为B,否则不会执行任何操作。一般情况下是一个自旋操作,即不断的重试。
自旋锁实现
当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。
本身无法保证公平性,同时也无法保证可重入性,基于自旋锁,可以实现具备公平性和可重入性质的锁(引入计数器)。
public class SpinLock {
private AtomicReference<Thread> cas = new AtomicReference<Thread>();
public void lock() {
Thread current = Thread.currentThread();
// 利用CAS
while (!cas.compareAndSet(null, current)) {
// DO nothing
}
}
public void unlock() {
Thread current = Thread.currentThread();
cas.compareAndSet(current, null);
}
}
lock()方法利用的CAS,当第一个线程A获取锁的时候,能够成功获取到,不会进入while循环,如果此时线程A没有释放锁,另一个线程B又来获取锁,此时由于不满足CAS,所以就会进入while循环,不断判断是否满足CAS,直到A线程调用unlock方法释放了该锁。
自旋锁存在的问题
- 某个线程持有锁的时间过长,就会导致其它等待获取锁的线程进入循环等待,消耗CPU。使用不当会造成CPU使用率极高。
- 上面Java实现的自旋锁不是公平的,即无法满足等待时间最长的线程优先获取锁。不公平的锁就会存在“线程饥饿”问题。
自旋锁的优点
- 自旋锁不会使线程状态发生切换,一直处于用户态,即线程一直都是active的;不会使线程进入阻塞状态,减少了不必要的上下文切换,执行速度快
- 非自旋锁在获取不到锁的时候会进入阻塞状态,从而进入内核态,当获取到锁的时候需要从内核态恢复,需要线程上下文切换。(线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能)
五、线程安全数据结构
image.png-
HashTable
HashTable实现了Map接口,为此其本身也是一个散列表,它存储的内容是基于key-value的键值对映射。
HashTable使用synchronized来修饰方法函数来保证线程安全,但是在多线程运行环境下效率表现非常低下。因为当一个线程访问HashTable的同步方法时,其他线程也访问同步方法就会粗线阻塞状态。比如当一个线程在添加数据时候,另外一个线程即使执行获取其他数据的操作也必须被阻塞,大大降低了程序的运行效率。 -
ConcurrentHashMap
ConcurrentHashMap是HashMap的线程安全版。但是与HashTable相比,ConcurrentHashMap不仅保证了多线程运行环境下的数据访问安全性,而且性能上有长足的提升。
ConcurrentHashMap允许多个修改操作并发运行,其原因在于使用了锁分段技术:首先讲Map存放的数据分成一段一段的存储方式,然后给每一段数据分配一把锁,当一个线程占用锁访问其中一个段的数据时,其他段的数据也能被其他线程访问。这样就保证了每一把锁只是用于锁住一部分数据,那么当多线程访问Map里的不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效提高并发访问效率。
上述的处理机制明显区别于HashTable是给整体数据分配了一把锁的处理方法。为此,在多线程环境下,常用ConcurrentHashMap在需要保证数据安全的场景中去替换HashMap,而不会去使用HashTable。 -
CopyOnWriteArrayList
CopyOnWriteArrayList实现了List接口,提供的数据更新操作都使用了ReentrantLock的lock()方法来加锁,unlock()方法来解锁。
当增加元素的时候,首先使用Arrays.copyOf()来拷贝形成新的副本,在副本上增加元素,然后改变原引用指向副本。读操作不需要加锁,而写操作类实现中对其进行了加锁。因此,CopyOnWriteArrayList类是一个线程安全的List接口的实现,在高并发的情况下,可以提供高性能的并发读取,并且保证读取的内容一定是正确的,这对于读操作远远多于写操作的应用非常适合 -
CopyOnWriteArraySet
CopyOnWriteArraySet是对CopyOnWriteArrayList使用了装饰模式后的具体实现。所以CopyOnWriteArrayList的实现机理适用于CopyOnWriteArraySet。 -
ConcurrentLinkedQueue
可以被看作是一个线程安全的LinkedList,使用了非阻塞算法实现的一个高效、线程安全的并发队列;其本质是一个基于链接节点的无界线程安全队列,它采用先进先出的规则对节点进行排序,当添加一个元素时会添加到队列的尾部;当获取一个元素时,会返回队列头部的元素。 -
LinkedBlockingQueue
线程安全的,实现了先进先出等特性,是作为生产者消费者的首选,内部则是基于锁,并提供了BlockingQueue的等待性方法。 -
Vector
Vector通过数组保存数据,继承了Abstract,实现了List;所以,其本质上是一个队列
但是和ArrayList不同,Vector中的操作是线程安全的,它是利用synchronized同步锁机制进行实现,其实现方式与HashTable类似。 -
StringBuffer与StringBuilder
对于频繁的字符串拼接操作,是不推荐采用效率低下的“+”操作的。一般是采用StringBuffer与StringBuilder来实现上述功能。但是,这两者也是有区别的:前者线程安全,后者不是线程安全的。
StringBuffer是通过对方法函数进行synchronized修饰实现其线程安全特性,实现方式与HashTable、Vector类似。
————————————————
参考链接:https://blog.csdn.net/qq_29229567/article/details/87799838
六、线程常见问题
1、消费者生产者模式实现
- 使用LinkedBlockingQueue
LinkedBlockingQueue实现是线程安全的,实现了先进先出等特性,是作为生产者消费者的首选,LinkedBlockingQueue 可以指定容量,也可以不指定,不指定的话,默认最大是Integer.MAX_VALUE,其中主要用到put和take方法,put方法在队列满的时候会阻塞直到有队列成员被消费,take方法在队列空的时候会阻塞,直到有队列成员被放进来。
public E take() throws InterruptedException {
E x;
int c = -1;
final AtomicInteger count = this.count;
final ReentrantLock takeLock = this.takeLock;
takeLock.lockInterruptibly();
try {
while (count.get() == 0) { //队列大小
notEmpty.await(); //释放CPU资源,进入等待,被唤醒后 继续while循环,不为空则跳出循环
}
x = dequeue();
c = count.getAndDecrement();
if (c > 1)
notEmpty.signal(); //唤醒一个等待在Condition(等待队列)上的线程,将该线程由等待队列转移到同步队列
} finally {
takeLock.unlock();
}
if (c == capacity)
signalNotFull();
return x;
}
- lock和condition的await、signalAll
- synchronized、wait和notify
参考链接: https://www.cnblogs.com/fankongkong/p/7339848.html
2、Thread为什么不能用stop方法停止线程
- 即刻抛出ThreadDeath异常,在线程的run()方法内,任何一点都有可能抛出ThreadDeath Error,包括在catch或finally语句中。
- 释放该线程所持有的所有的锁。调用thread.stop()后导致了该线程所持有的所有锁的突然释放,那么被保护数据就有可能呈现不一致性,其他线程在使用这些被破坏的数据时,有可能导致一些很奇怪的应用程序错误。
3、如何解决哲学家就餐问题
参考:https://blog.csdn.net/qq_16546235/article/details/109612177
4、为什么说String是线程安全的
String是final修饰的类,代表了String的不可继承性
image.png
final修饰的char[]代表了被存储的数据不可更改性。但仅仅是引用地址不可变,并不代表了数组本身不会变,起作用的还有private,正是因为两者保证了String的不可变性。
Java String类为什么是final的?
- 实现字符串池
可以在运行时节约很多heap空间,因为不同的字符串变量都指向池中的同一个字符串 - 线程安全
如果变量改变了它的值,那么其它指向这个值的变量的值也会一起改变数据库的用户名、密码都是以字符串的形式传入来获得数据库的连接,或者在socket编程中,主机名和端口都是以字符串的形式传入,改变字符串指向的对象的值,会造成安全漏洞。 - 创建HashCode不可变性
因为字符串是不可变的,所以在它创建的时候HashCode就被缓存了,不需要重新计算。这就使得字符串很适合作为Map中的键,字符串的处理速度要快过其它的键对象。这就是HashMap中的键往往都使用字符串。
Java final的用途?
- final可以修饰类,方法和变量,
- final修饰的类,不能被继承,即它不能拥有自己的子类,
- final修饰的方法,不能被重写,
- final修饰的变量,无论是类属性、对象属性、形参还是局部变量,都需要进行初始化操作。
5、为什么HashMap线程不安全
6、Java 中 Lock 接口比 synchronized 块的优势是什么?
Lock接口在多线程编程中最大的优势是它们分别为读和写提供了锁。
7、如何实现一个高效的缓存,它允许多个用户读,但只允许一个用户写?
使用读写锁ReentrantReadWriteLock,多个线程可以同时进行读取操作,但是同一时刻只允许一个线程进行写入操作。