队列
等待队列(Condition Queue)
我们都熟悉wait/notify,它主要是实现线程间协作的,其常用的使用模式如下:
1 | public synchronized void produce(T t) throws InterruptedException { |
当条件满足,原来等待的线程就会立即被唤醒,这就要涉及到等待队列,等待队列中的是等待某类条件发生的线程。每一个对象都可以作为锁对象,也同时被当作一个等待队列,并具有wait,notify,notifyall方法,另见图:

判断条件总是涉及到一些状态,如集合是否已满,是否为空等等,这些状态变量必须被锁监控,因为线程在等待或者唤醒另一个线程前,需要访问、操作这些与条件相关的状态变量,而加锁可以保证状态的一致性。另外,正如上例所示,wait方法必须包含在while循环中,原因有二:
1、从线程被唤醒到重新获得锁的间隙,其他线程获取了锁并且改变了状态,使得条件重新变为false。
2、如果多种条件与一个等待队列关联,必须使用notifyAll,一个线程可能在条件不满足的情况下被唤醒,这时候需要重新检查条件。
对象的内置锁只有一个内置等待队列与其关联,这样多个唤醒条件不同的线程就必须在同一个等待队列上,唤醒线程时必须使用notifyAll,导致大部分不符合条件的线程将被唤醒并且参与锁竞争,上下文切换频繁,性能下降,当然,notifyAll是一种比较安全保险的做法。上次我们提过还有另一种实现锁的形式,即Lock,与其对应的是Condition,它可以根据不同的条件提供对应的condition,可将上述使用模式改装一下:
1 | protected final Lock lock = new ReentrantLock(); |
通过wait/notify实现线程间协作,是需要一定的技巧的,初级的开发人员不一定能正确使用,我们可以使用一些并发工具类,像LinkedBlockingQueue,ConcurrentHashMap,CountDownLatch实现相应的功能
BlockingQueue
在新增的Concurrent包中,BlockingQueue很好的解决了多线程中,如何高效安全“传输”数据的问题
常用的队列主要有以下两种:(当然通过不同的实现方式,还可以延伸出很多不同类型的队列,DelayQueue就是其中的一种)
先进先出(FIFO):先插入的队列的元素也最先出队列,类似于排队的功能。从某种程度上来说这种队列也体现了一种公平性。
后进先出(LIFO):后插入队列的元素最先出队列,这种队列优先处理最近发生的事件。
当队列中没有数据的情况下,消费者端的所有线程都会被自动阻塞(挂起),直到有数据放入队列。
当队列中填满数据的情况下,生产者端的所有线程都会被自动阻塞(挂起),直到队列中有空的位置,线程被自动唤醒。
BlockingQueue的核心方法:
- offer(anObject):表示如果可能的话,将anObject加到BlockingQueue里,即如果BlockingQueue可以容纳,则返回true,否则返回false.(本方法不阻塞当前执行方法的线程)
- put(anObject):把anObject加到BlockingQueue里,如果BlockQueue没有空间,则调用此方法的线程被阻断,直到BlockingQueue里面有空间再继续. 获取数据
- poll(long timeout, TimeUnit unit):从BlockingQueue取出一个队首的对象,如果在指定时间内,队列一旦有数据可取,则立即返回队列中的数据。否则知道时间超时还没有数据可取,返回失败。
- take():取走BlockingQueue里排在首位的对象,若BlockingQueue为空,阻断进入等待状态直到BlockingQueue有新的数据被加入;
常见BlockingQueue
1. ArrayBlockingQueue 基于数组的阻塞队列实现,在ArrayBlockingQueue内部,维护了一个定长数组,以便缓存队列中的数据对象,这是一个常用的阻塞队列,除了一个定长数组外,ArrayBlockingQueue内部还保存着两个整形变量,分别标识着队列的头部和尾部在数组中的位置。 ArrayBlockingQueue在生产者放入数据和消费者获取数据,都是共用同一个锁对象,由此也意味着两者无法真正并行运行,这点尤其不同于LinkedBlockingQueue;按照实现原理来分析,ArrayBlockingQueue完全可以采用分离锁,从而实现生产者和消费者操作的完全并行运行。Doug Lea之所以没这样去做,也许是因为ArrayBlockingQueue的数据写入和获取操作已经足够轻巧,以至于引入独立的锁机制,除了给代码带来额外的复杂性外,其在性能上完全占不到任何便宜。 ArrayBlockingQueue和LinkedBlockingQueue间还有一个明显的不同之处在于,前者在插入或删除元素时不会产生或销毁任何额外的对象实例,而后者则会生成一个额外的Node对象。这在长时间内需要高效并发地处理大批量数据的系统中,其对于GC的影响还是存在一定的区别。而在创建ArrayBlockingQueue时,我们还可以控制对象的内部锁是否采用公平锁,默认采用非公平锁。
2. LinkedBlockingQueue 基于链表的阻塞队列,同ArrayListBlockingQueue类似,其内部也维持着一个数据缓冲队列(该队列由一个链表构成),当生产者往队列中放入一个数据时,队列会从生产者手中获取数据,并缓存在队列内部,而生产者立即返回;只有当队列缓冲区达到最大值缓存容量时(LinkedBlockingQueue可以通过构造函数指定该值),才会阻塞生产者队列,直到消费者从队列中消费掉一份数据,生产者线程会被唤醒,反之对于消费者这端的处理也基于同样的原理。而LinkedBlockingQueue之所以能够高效的处理并发数据,还因为其对于生产者端和消费者端分别采用了独立的锁来控制数据同步,这也意味着在高并发的情况下生产者和消费者可以并行地操作队列中的数据,以此来提高整个队列的并发性能。 作为开发者,我们需要注意的是,如果构造一个LinkedBlockingQueue对象,而没有指定其容量大小,LinkedBlockingQueue会默认一个类似无限大小的容量(Integer.MAX_VALUE),这样的话,如果生产者的速度一旦大于消费者的速度,也许还没有等到队列满阻塞产生,系统内存就有可能已被消耗殆尽了。 ArrayBlockingQueue和LinkedBlockingQueue是两个最普通也是最常用的阻塞队列,一般情况下,在处理多线程间的生产者消费者问题,使用这两个类足以。
3. DelayQueue DelayQueue中的元素只有当其指定的延迟时间到了,才能够从队列中获取到该元素。DelayQueue是一个没有大小限制的队列,因此往队列中插入数据的操作(生产者)永远不会被阻塞,而只有获取数据的操作(消费者)才会被阻塞。 使用场景: DelayQueue使用场景较少,但都相当巧妙,常见的例子比如使用一个DelayQueue来管理一个超时未响应的连接队列。
4. PriorityBlockingQueue 基于优先级的阻塞队列(优先级的判断通过构造函数传入的Compator对象来决定),但需要注意的是PriorityBlockingQueue并不会阻塞数据生产者,而只会在没有可消费的数据时,阻塞数据的消费者。因此使用的时候要特别注意,生产者生产数据的速度绝对不能快于消费者消费数据的速度,否则时间一长,会最终耗尽所有的可用堆内存空间。在实现PriorityBlockingQueue时,内部控制线程同步的锁采用的是公平锁。
5. SynchronousQueue 一种无缓冲的等待队列,类似于无中介的直接交易,有点像原始社会中的生产者和消费者,生产者拿着产品去集市销售给产品的最终消费者,而消费者必须亲自去集市找到所要商品的直接生产者,如果一方没有找到合适的目标,那么对不起,大家都在集市等待。相对于有缓冲的BlockingQueue来说,少了一个中间经销商的环节(缓冲区),如果有经销商,生产者直接把产品批发给经销商,而无需在意经销商最终会将这些产品卖给那些消费者,由于经销商可以库存一部分商品,因此相对于直接交易模式,总体来说采用中间经销商的模式会吞吐量高一些(可以批量买卖);但另一方面,又因为经销商的引入,使得产品从生产者到消费者中间增加了额外的交易环节,单个产品的及时响应性能可能会降低。 声明一个SynchronousQueue有两种不同的方式,它们之间有着不太一样的行为。公平模式和非公平模式的区别: 如果采用公平模式:SynchronousQueue会采用公平锁,并配合一个FIFO队列来阻塞多余的生产者和消费者,从而体系整体的公平策略; 但如果是非公平模式(SynchronousQueue默认):SynchronousQueue采用非公平锁,同时配合一个LIFO队列来管理多余的生产者和消费者,而后一种模式,如果生产者和消费者的处理速度有差距,则很容易出现饥渴的情况,即可能有某些生产者或者是消费者的数据永远都得不到处理。
设计模式
常见: 单例模式、工厂模式、建造模式、观察者模式、适配器模式、代理模式、装饰模式.
设计模式的六大原则及其含义:
- 单一职责原则:一个类只负责一个功能领域中的相应职责,或者可以定义为:就一个类而言,应该只有一个引起它变化的原因。主要作用实现代码高内聚,低耦合。
- 开闭原则:一个软件实体应当对扩展开放,对修改关闭。即软件实体应尽量在不修改原有代码的情况下进行扩展。
- 里氏替换原则:所有引用基类(父类)的地方必须能透明地使用其子类的对象。里氏替换原则是实现开闭原则的方式之一
- 依赖倒置原则:抽象不应该依赖于细节,细节应当依赖于抽象。换言之,要针对接口编程,而不是针对实现编程。
- 接口隔离原则:使用多个专门的接口,而不使用单一的总接口,即客户端不应该依赖那些它不需要的接口。
- 迪米特法则:一个软件实体应当尽可能少地与其他实体发生相互作用。
常见模式及优缺点:
饿汉式:
优点:不用加锁可以确保对象的唯一性,线程安全。
缺点:初始化对象会浪费不必要的资源,未实现延迟加载。
懒汉式:
优点:实现了延时加载。
缺点:线程不安全,想实现线程安全,得加锁(synchronized),这样会浪费一些不必要的资源。
双重检测锁式(
Double Check Lock –DCL):
优点:资源利用率高,效率高。
缺点:第一次加载稍慢,由于java处理器允许乱序执行,偶尔会失败。
静态内部式:
优点:第一次调用方法时才加载类,不仅保证线程安全还能保证对象的唯一,还延迟了单例的实例化
缺点:无确定
枚举实现单例模式
优点: 线程安全, 任何模式下都是单例的, 包括序列化
推荐使用静态内部式
1 | 5) public class Singleton4 { |
设计模式在实际场景的应用
单例:连接数据库,记录日志
==Spring中用到了哪些设计模式==
- 工厂模式:spring中的BeanFactory就是简单工厂模式的体现,根据传入一个唯一的标识来获得bean对象,但是否是在传入参数后创建还是传入参数前创建这个要根据具体情况来定。
- 代理模式:Spring的AOP就是代理模式的体现。
- 观察者模式:常用的地方是Listener的实现,spring中ApplicationListener就是观察者的体现。
- 策略模式:spring在实例化对象的时候使用到了。
- 工厂方法:Spring中的FactoryBean就是典型的工厂方法模式。
- 单例模式:
参考:https://www.cnblogs.com/hwaggLee/p/4510687.html
==MyBatis中用到了哪些设计模式==
- Builder模式,例如SqlSessionFactoryBuilder、XMLConfigBuilder、XMLMapperBuilder、XMLStatementBuilder、CacheBuilder;
- 工厂模式,例如SqlSessionFactory、ObjectFactory、MapperProxyFactory;
- 单例模式,例如ErrorContext和LogFactory;
- 代理模式,Mybatis实现的核心,比如MapperProxy、ConnectionLogger,用的jdk的动态代理;还有executor.loader包使用了cglib或者javassist达到延迟加载的效果;
- 组合模式,例如SqlNode和各个子类ChooseSqlNode等;
- 模板方法模式,例如BaseExecutor和SimpleExecutor,还有BaseTypeHandler和所有的子类例如IntegerTypeHandler;
- 适配器模式,例如Log的Mybatis接口和它对jdbc、log4j等各种日志框架的适配实现;
- 装饰者模式,例如Cache包中的cache.decorators子包中等各个装饰者的实现;
- 迭代器模式,例如迭代器模式PropertyTokenizer;
参考:https://www.cnblogs.com/shuchen007/p/9193179.html
代理模式(Proxy)
静态代理: AspectJ
动态代理:
- cglib(静态)
- jdk(动态)
类加载器
顾名思义,类加载器(class loader)用来加载 Java 类到 Java 虚拟机中。类加载器负责读取 Java 字节代码,并转换成 java.lang.Class类的一个实例。
有三类类加载器:
- 启动类加载器 BootStrap ClassLoader
- 扩展类加载器 Extension ClassLoader
- 应用类加载器 Application ClassLoader

ClassLoader
ClassLoader类是一个抽象类,它定义了类加载器的基本方法。
方法 | 说明 |
---|---|
getParent() | 返回该类加载器的父类加载器。 |
loadClass(String name) | 加载名称为 name的类,返回的结果是 java.lang.Class类的实例。 |
findClass(String name) | 查找名称为 name的类,返回的结果是 java.lang.Class类的实例。 |
findLoadedClass(String name) | 查找名称为 name的已经被加载过的类,返回的结果是 java.lang.Class类的实例。 |
defineClass(String name, byte[] b, int off, int len) | 把字节数组 b中的内容转换成 Java 类,返回的结果是 java.lang.Class类的实例。这个方法被声明为 final的。 |
来看看 loadClass 方法的代码:双亲委托机制
自定义类加载器一般只重写findClass方法即可
1 | protected Class<?> loadClass(String name, boolean resolve){ |
IO
Java的io,nio,bio区别
Java IO(Java数据流)主要就是Java用来读取和输出数据流
读取纯文本数据优选用字符流,其他使用字节流
Java中IO主要有两类
- |——>字节流(读写以字节(8bit)为单位,InputStream和OutputStream为主要代表
- |——>字符流(读写以字符为单位,Reader和Writer为主要代表)
BIO, NIO, AIO的区别
- BIO: 同步并阻塞, 服务器模式为一个连接一个线程, 客户端有连接请求时,就必须开启一个线程进行处理, 会造成很大的线程开销–数据的读取写入必须阻塞在一个线程内等待其完成。在活动连接数不是特别高(小于单机1000)的情况下
- NIO: 同步非阻塞,服务器模式为: 一个请求一个线程, 即客户端的请求都会注册到多路复用机,轮询到有io请求时才启动一个线程进行处理.把一些无效的连接挡在了启动线程之前,减少了这部分资源的浪费 –对于高负载、高并发的(网络)应用,应使用 NIO 的非阻塞模式来开发
- AIO: 一部非阻塞, 一个有效的请求一个线程, 客户端的io请求都是os先完成了在通知服务器应用取启动线程进行处理将一些暂时可能无效的请求挡在了启动线程之前
I/O 模型
一个输入操作通常包括两个阶段:
- 等待数据准备好(数据复制到内核缓冲区)
- 从内核向进程复制数据
对于一个套接字上的输入操作,第一步通常涉及等待数据从网络中到达。当所等待数据到达时,它被复制到内核中的某个缓冲区。第二步就是把数据从内核缓冲区复制到应用进程缓冲区。
Unix 有五种 I/O 模型:
- 阻塞式 I/O (BIO)
- 非阻塞式 I/O (NIO)
- I/O 复用(select 和 poll)
- 信号驱动式 I/O(SIGIO)
- 异步 I/O(AIO)
阻塞式 I/O
应用进程被阻塞,直到数据从内核缓冲区复制到应用进程缓冲区中才返回。
应该注意到,在阻塞的过程中,其它应用进程还可以执行,因此阻塞不意味着整个操作系统都被阻塞。因为其它应用进程还可以执行,所以不消耗 CPU 时间,这种模型的 CPU 利用率会比较高。
下图中,recvfrom() 用于接收 Socket 传来的数据,并复制到应用进程的缓冲区 buf 中。这里把 recvfrom() 当成系统调用。
1 | ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);Copy to clipboardErrorCopied |

非阻塞式 I/O
应用进程执行系统调用之后,内核返回一个错误码。应用进程可以继续执行,但是需要不断的执行系统调用来获知 I/O 是否完成,这种方式称为轮询(polling)。
由于 CPU 要处理更多的系统调用,因此这种模型的 CPU 利用率比较低-因为要去不断的轮询是否数据准备好。

==此时数据准备阶段不会阻塞, 而数据复制阶段一样会阻塞==
I/O 复用
使用 select 或者 poll 等待数据,并且可以等待多个套接字中的任何一个变为可读。这一过程会被阻塞,当某一个套接字可读时返回,之后再使用 recvfrom 把数据从内核复制到进程中。
它可以让单个进程具有处理多个 I/O 事件的能力。又被称为 Event Driven I/O,即事件驱动 I/O。
I/O复用可以避免频繁创建和销毁线程, 以及切换线程的开销
如果一个 Web 服务器没有 I/O 复用,那么每一个 Socket 连接都需要创建一个线程去处理。如果同时有几万个连接,那么就需要创建相同数量的线程。相比于多进程和多线程技术,I/O 复用不需要进程线程创建和切换的开销,系统开销更小。

==在数据准备阶段select是阻塞的==
信号驱动 I/O
应用进程使用 sigaction 系统调用,内核立即返回,应用进程可以继续执行,也就是说等待数据阶段应用进程是非阻塞的。内核在数据到达时向应用进程发送 SIGIO 信号,应用进程收到之后在信号处理程序中调用 recvfrom 将数据从内核复制到应用进程中。
相比于非阻塞式 I/O 的轮询方式,信号驱动 I/O 的 CPU 利用率更高-数据准备阶段不阻塞。

异步 I/O (AIO)
应用进程执行 aio_read 系统调用会立即返回,应用进程可以继续执行,不会被阻塞,内核会在所有操作完成之后向应用进程发送信号。
异步 I/O 与信号驱动 I/O 的区别在于,异步 I/O 的信号是通知应用进程 I/O 完成,而信号驱动 I/O 的信号是通知应用进程可以开始 I/O。

五大 I/O 模型比较
- 同步 I/O:将数据从内核缓冲区复制到应用进程缓冲区的阶段(第二阶段),应用进程会阻塞。
- 异步 I/O:第二阶段应用进程不会阻塞。
同步 I/O 包括阻塞式 I/O、非阻塞式 I/O、I/O 复用和信号驱动 I/O ,它们的主要区别在第一个阶段。
非阻塞式 I/O 、信号驱动 I/O 和异步 I/O 在第一阶段不会阻塞。

I/O 复用 详细讲解
select/poll/epoll 都是 I/O 多路复用的具体实现,select 出现的最早,之后是 poll,再是 epoll。
select
1 | int select(int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);Copy to clipboardErrorCopied |
select 允许应用程序监视一组文件描述符,等待一个或者多个描述符成为就绪状态,从而完成 I/O 操作。
- fd_set 使用数组实现,数组大小使用 FD_SETSIZE 定义,所以只能监听少于 FD_SETSIZE 数量的描述符。有三种类型的描述符类型:readset、writeset、exceptset,分别对应读、写、异常条件的描述符集合。
- timeout 为超时参数,调用 select 会一直阻塞直到有描述符的事件到达或者等待的时间超过 timeout。
- 成功调用返回结果大于 0,出错返回结果为 -1,超时返回结果为 0。
poll
1 | int poll(struct pollfd *fds, unsigned int nfds, int timeout);Copy to clipboardErrorCopied |
poll 的功能与 select 类似,也是等待一组描述符中的一个成为就绪状态。
poll 中的描述符是 pollfd 类型的数组,pollfd 的定义如下:
比较
1. 功能
select 和 poll 的功能基本相同,不过在一些实现细节上有所不同。
- select 会修改描述符,而 poll 不会;
- select 的描述符类型使用数组实现,FD_SETSIZE 大小默认为 1024,因此默认只能监听 1024 个描述符。如果要监听更多描述符的话,需要修改 FD_SETSIZE 之后重新编译;而 poll 没有描述符数量的限制;
- poll 提供了更多的事件类型,并且对描述符的重复利用上比 select 高。
- 如果一个线程对某个描述符调用了 select 或者 poll,另一个线程关闭了该描述符,会导致调用结果不确定。
2. 速度
select 和 poll 速度都比较慢,每次调用都需要将全部描述符从应用进程缓冲区复制到内核缓冲区。
3. 可移植性
几乎所有的系统都支持 select,但是只有比较新的系统支持 poll。
epoll
1 | int epoll_create(int size); |
epoll_ctl() 用于向内核注册新的描述符或者是改变某个文件描述符的状态。已注册的描述符在内核中会被维护在一棵红黑树上,通过回调函数内核会将 I/O 准备好的描述符加入到一个链表中管理,进程调用 epoll_wait() 便可以得到事件完成的描述符。
从上面的描述可以看出,epoll 只需要将描述符从进程缓冲区向内核缓冲区拷贝一次,并且进程不需要通过轮询来获得事件完成的描述符。
epoll 仅适用于 Linux OS。
epoll 比 select 和 poll 更加灵活而且没有描述符数量限制。
epoll 对多线程编程更有友好,一个线程调用了 epoll_wait() 另一个线程关闭了同一个描述符也不会产生像 select 和 poll 的不确定情况。
工作模式
epoll 的描述符事件有两种触发模式:LT(level trigger)和 ET(edge trigger)。
当 epoll_wait() 检测到描述符事件到达时,将此事件通知进程,进程可以不立即处理该事件,下次调用 epoll_wait() 会再次通知进程。是默认的一种模式,并且同时支持 Blocking 和 No-Blocking。
和 LT 模式不同的是,通知之后进程必须立即处理事件,下次再调用 epoll_wait() 时不会再得到事件到达的通知。
很大程度上减少了 epoll 事件被重复触发的次数,因此效率要比 LT 模式高。只支持 No-Blocking,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。
应用场景
很容易产生一种错觉认为只要用 epoll 就可以了,select 和 poll 都已经过时了,其实它们都有各自的使用场景。
1. select 应用场景
select 的 timeout 参数精度为 1ns,而 poll 和 epoll 为 1ms,因此 select 更加适用于实时性要求比较高的场景,比如核反应堆的控制。
select 可移植性更好,几乎被所有主流平台所支持。
2. poll 应用场景
poll 没有最大描述符数量的限制,如果平台支持并且对实时性要求不高,应该使用 poll 而不是 select。
3. epoll 应用场景
只需要运行在 Linux 平台上,有大量的描述符需要同时轮询,并且这些连接最好是长连接。
需要同时监控小于 1000 个描述符,就没有必要使用 epoll,因为这个应用场景下并不能体现 epoll 的优势。
需要监控的描述符状态变化多,而且都是非常短暂的,也没有必要使用 epoll。因为 epoll 中的所有描述符都存储在内核中,造成每次需要对描述符的状态改变都需要通过 epoll_ctl() 进行系统调用,频繁系统调用降低效率。并且 epoll 的描述符存储在内核,不容易调试。
- I/O 复用(select 和 poll)
- 信号驱动式 I/O(SIGIO)
- 异步 I/O(AIO)
多线程
进程和线程区别
- 进程是资源(CPU、内存等)分配的基本单位,它是程序执行时的一个实例
- 线程是程序执行时的最小单位,它是进程的一个执行流,是CPU调度和分派的基本单位,一个进程可以由很多个线程组成,线程间共享进程的所有资源,每个线程有自己的堆栈和局部变量,是一个独立的执行单元主要用于解决程序执行的效率
守护线程&非守护线程
GC线程属于守护线程, 和主线程同生共死
用户线程也叫非守护线程, 用户自己创建的线程, 如果主线程停止掉,不会影响用户线程. 用户线程非守护线程
线程的状态转换

线程安全
coout++
count++ 这行代码实际上需要执行三个指令:
getfield:从内存中获取变量 count 的值
iadd:将 count 加 1
putfield:将加 1 后的结果赋值给 count 变量
这也就是线程不安全的原因所在,因为 count++ 操作不具备原子性。
原子性操作指的是不可被中断的一个或一系列操作。下图描述了为什么非原子操作造成了这里的线程不安全问题

假设有两个线程去执行 add 操作,此时 count 是 0,那么存在上图中的这种可能,在线程 A 执行这三步的过程中 cpu 时间片耗尽线程 B 被调度,此时由于内存中 count 的值仍为 0(因为线程 A 的操作结果还未刷新到内存中),所以线程 B 仍是在 0 的基础上执行自增,所以导致最终内存中的 count 是 1,而不是 2.
为什么要用线程池?
线程池作用就是限制系统中执行线程的数量。根据系统的环境情况, 可以手动或者自动的设置线程数量, 避免浪费系统资源, 造成系统拥挤, 当一个新任务需要运行时,如果线程池 中有等待的工作线程,就可以开始运行了;否则进入等待队列。
减少了创建和销毁线程的次数, 每个工作线程都可以被重复利用, 可执行多个任务
可以根据系统的承受能力, 调整线程池中工作线程的数目, 防止因为消耗过多的内存, (每个线程需要大约1MB内存,线程开的越多,消耗的内存也就越大,最后死机)。
Java里边线程池的顶级接口时Executor, 但是严格意义上将Executor并不是一个线程池, 而是一个执行工具,真正的线程池接口时ExecutorService
线程中重要的几个类
ExecutorService
真正的线程池接口。
ScheduledExecutorService
能和Timer/TimerTask类似,解决那些需要任务重复执行的问题。ThreadPoolExecutor
ExecutorService的默认实现。ScheduledThreadPoolExecutor
继承ThreadPoolExecutor的ScheduledExecutorService接口实现,周期性任务调度的类实现。
new Thread的弊端
- 每次new Thread新建对象性能差。
- 线程缺乏统一管理,可能无限制新建线程,相互之间竞争,及可能占用过多系统资源导致死机或oom。
- 缺乏更多功能,如定时执行、定期执行、线程中断。
Java提供的四种线程池的好处在于:
- 重用存在的线程,减少对象创建、消亡的开销,性能佳。
- 可有效控制最大并发线程数,提高系统资源的使用率,同时避免过多资源竞争,避免堵塞。
- 提供定时执行、定期执行、单线程、并发数控制等功能。
四种线程池
Java通过Executors提供四种线程池,分别为:
newCachedThreadPool
创建一个可缓存的线程池, 如果线程池长度超过处理需要, 可以灵活回收空闲线程, 若无可回收,则新建线程
==适应场景: 创建一个可以无限扩大的线程池,适用于服务器负载较轻,执行很多短期异步任务。==
newFixedThreadPool
创建一个定长线程池,可控制线程最大并发数, 采用无界的阻塞队列,所以实际线程数量永远不会变化,超出的线程会在队列中等待
适应场景:适用于可以预测线程数量的业务中,或者服务器负载较重,对当前线程数量进行限制。
newScheduledThreadPool
创建一个定长线程池, 至此定时及周期性任务执行
==适应场景: 适用于需要多个后台线程执行周期任务的场景。==
newSingleThreadExecutor
创建一个单线程化的线程池, 它只会用唯一的工作线程来执行任务, 保证所有任务按照指定顺序(FIFO, LIFO,优先级)执行
==适应场景: 适用于需要保证顺序执行各个任务,并且在任意时间点,不会有多个线程是活动的场景。==
submit()和execute()的以及shutdown()和shutdownNow()的区别
- submit(),提交一个线程任务,可以接受回调函数的返回值吗,适用于需要处理返回着或者异常的业务场景
- execute(),执行一个任务,没有返回值
- shutdown(),表示不再接受新任务,但不会强行终止已经提交或者正在执行中的任务
- shutdownNow(),对于尚未执行的任务全部取消,正在执行的任务全部发出interrupt(),停止执行
RejectedExecutionHandler 线程池四种拒绝任务策略
线程池有一个任务队列,用于缓存所有待处理的任务,正在处理的任务将从任务队列中移除。因此在任务队列长度有限的情况下就会出现新任务的拒绝处理问题,需要有一种策略来处理应该加入任务队列却因为队列已满无法加入的情况。另外在线程池关闭的时候也需要对任务加入队列操作进行额外的协调处理。
RejectedExecutionHandler提供了四种方式来处理任务拒绝策略: ->==这四种策略是独立无关的==
- 1、直接丢弃(DiscardPolicy)
- 2、丢弃队列中最老的任务(DiscardOldestPolicy)。
- 3、抛异常(AbortPolicy)
- 4、将任务分给调用线程来执行(CallerRunsPolicy)。
Runnable, Callable, Future和 FutureTask
直接继承Thread, 和Runnable都不能返回执行结果, 就必须通过共享变量或者使用线程通信的方式来达到效果,这样使用起来就比较麻烦。
Callable与Runnable
Callable位于java.util.concurrent包下,它也是一个接口,在它里面也只声明了一个方法,只不过这个方法叫做call():一般情况下是配合ExecutorService来使用的,在ExecutorService接口中声明了若干个submit方法的重载版本
1 | <T> Future<T> submit(Callable<T> task); |
Future
Future就是对于具体的Runnable或者Callable任务的执行结果进行取消、查询是否完成、获取结果。必要时可以通过get方法获取执行结果,该方法会阻塞直到任务返回结果。Future类位于java.util.concurrent包下,它是一个接口
Future提供了三种功能:
1)判断任务是否完成;
2)能够中断任务;
3)能够获取任务执行结果。
因为Future只是一个接口,所以是无法直接用来创建对象使用的,因此就有了下面的FutureTask。
FutureTask
可以看出RunnableFuture继承了Runnable接口和Future接口,而FutureTask实现了RunnableFuture接口。所以它既可以作为Runnable被线程执行,又可以作为Future得到Callable的返回值。
1 | public class FutureTask<V> implements RunnableFuture<V> |
锁
常见的锁有synchronized、volatile、偏向锁、轻量级锁、重量级锁
死锁的原因
- 互斥性: 某一段时间内, 某一资源只能一个线程使用
- 请求保持: A申请了一部分资源不足以运行,需要额外的其他资源,但又申请不到, 而其他线程又拿不到A保持的那份资源
- 不可剥夺: 进程未使用完之前,资源不可剥夺
- 循环等待: 进程资源形成环型链, 导致任何一个线程都拿不到运行所需的全部资源
CAS
CAS(Compare and Swap): 它的作用是将指定内存地址的内容与所给的某个值相比,如果相等,则将其内容替换为指令中提供的新值,如果不相等,则更新失败。这一比较并交换的操作是原子的,不可以被中断**
==CAS是通过硬件命令保证了原子性==
(1)在多线程竞争下,加锁、释放锁会导致比较多的上下文切换和调度延时,引起性能问题。
(2)一个线程持有锁会导致其它所有需要此锁的线程挂起。
(3)如果一个优先级高的线程等待一个优先级低的线程释放锁会导致优先级倒置,引起性能风险。
volatile是不错的机制,但是volatile不能保证原子性。因此对于同步最终还是要回到锁机制上来。独占锁是一种悲观锁,synchronized就是一种独占锁,会导致其它所有需要锁的线程挂起,等待持有锁的线程释放锁。而另一个更加有效的锁就是乐观锁。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。乐观锁用到的机制就是CAS,Compare and Swap。
CAS导致的问题
ABA问题
大多数情况下乐观锁的实现都会通过引入一个版本号标记这个对象,每次修改版本号都会变话,比如使用时间戳作为版本号,这样就可以很好的解决ABA问题
在JDK中提供了AtomicStampedReference类来解决这个问题,思路是一样的。这个类也维护了一个int类型的标记stamp,每次更新数据的时候顺带更新一下stamp。
1
2
3
4// 带有时间戳的原子类,不存在ABA问题,第二个参数就是默认时间戳,这里指定为0
AtomicStampedReference<Integer> a2 = new AtomicStampedReference<Integer>(10, 0);
//可以看到使用AtomicStampedReference进行compareAndSet的时候,除了要验证数据,还要验证时间戳。
a2.compareAndSet(10, 11, stamp, stamp + 1);AtomicInteger的CAS原理
通过查看AtomicInteger的源码可知, 通过申明一个volatile (内存锁定,同一时刻只有一个线程可以修改内存值)类型的变量,再加上unsafe.compareAndSwapInt的方法,来保证实现线程同步的。
1
2
3
4private volatile int value;
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}循环时间长开销大
自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。
只能保证一个共享变量的原子操作
当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。
synchronized、volatile的区别
- 相同点:都保证了可见性
- 不同点 : ==volatile不能保证原子性==,但是==synchronized能保证原子性且会发生阻塞==(在线程状态转换中详说),开销更大。
Volatile
volatile可以看做是一种synchronized的轻量级锁,他能够保证并发时,被它修饰的共享变量的可见性,当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。
volatile可以解决指令重排,它使用的是内存屏障进行解决的,所谓的内存屏障是一个cpu命令,它有两个作用保证特定的执行顺序,保证可见性,通过在volatile 指令前后增加内存屏障从而解决指令重排问题。
实现原理:
被volatile修饰的共享变量在进行写操作的时候
- 1、将当前处理器缓存行的数据写回到系统内存。
- 2、这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。
使用场景:
- 1.访问变量不需要加锁(加锁的话使用volatile就没必要了)
- 2、对变量的写操作不依赖于当前值(因为他不能保证原子性)
- 3.该变量没有包含在具有其他变量的不变式中。
一般我们会用来修饰状态标志;读写锁(读>>写,对写加锁,读不加锁);DCL的单例模式中;volatile bean(例如放入HTTPSession中的对象)
synchronized
synchronized是并发编程中接触的最基本的同步工具,是一种重量级锁,也是 悲观锁,也是java内置的同步机制,首先我们知道synchronized提供了互斥性的语义和可见性,那么我们可以通过使用它来保证并发的安全。
synchronized三种用法
用于类上:
当使用synchronized修饰类静态方法时,那么当前加锁的级别就是类,当多个线程并发访问该类(所有实例对象)的同步方法以及同步代码块时,会进行同步
用于代码块:
当使用synchronized修饰代码块时,那么当前加锁的级别就是synchronized(X)中配置的x对象实例,当多个线程并发访问该对象的同步方法、同步代码块以及当前的代码块时,会进行同步。
==使用同步代码块时要注意的是不要使用String类型对象,因为String常量池的存在,所以很容易导致出问题。==
Synchronized和CAS区别
- CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则返回V。这是一种乐观锁的思路,它相信在它修改之前,没有其它线程去修改它;
- 而Synchronized是一种悲观锁,它认为在它修改之前,一定会有其它线程去修改它,悲观锁效率很低。下面来看一下AtomicInteger是如何利用CAS实现原子性操作的。
ReentrantLock
ReentrantLock实现了Lock接口, 支持两种获取锁的方式,一种是公平模型,一种是非公平模型。
公平锁模型:
初始化时, state=0,表示无人抢占了打水权。这时候,村民A来打水(A线程请求锁),占了打水权,把state+1,如下所示:

线程A取得了锁,把 state原子性+1,这时候state被改为1,A线程继续执行其他任务,然后来了村民B也想打水(线程B请求锁),线程B无法获取锁,生成节点进行排队,如下图所示:

初始化的时候,会生成一个空的头节点,然后才是B线程节点,这时候,如果线程A又请求锁,是否需要排队?答案当然是否定的,否则就直接死锁了。当A再次请求锁,就相当于是打水期间,同一家人也来打水了,是有特权的,这时候的状态如下图所示:

到了这里,相信大家应该明白了什么是可重入锁了吧。就是一个线程在获取了锁之后,再次去获取了同一个锁,这时候仅仅是把状态值进行累加。如果线程A释放了一次锁,就成这样了:

仅仅是把状态值减了,只有线程A把此锁全部释放了,状态值减到0了,其他线程才有机会获取锁。当A把锁完全释放后,state恢复为0,然后会通知队列唤醒B线程节点,使B可以再次竞争锁。当然,如果B线程后面还有C线程,C线程继续休眠,除非B执行完了,通知了C线程。注意,当一个线程节点被唤醒然后取得了锁,对应节点会从队列中删除。
非公平锁模型
如果你已经明白了前面讲的公平锁模型,那么非公平锁模型也就非常容易理解了。当线程A执行完之后,要唤醒线程B是需要时间的,而且线程B醒来后还要再次竞争锁,所以如果在切换过程当中,来了一个线程C,那么线程C是有可能获取到锁的,如果C获取到了锁,B就只能继续乖乖休眠了。
偏向锁(jvm内部)
- 偏向锁(顾名思义,它会偏向于第一个访问锁的线程,如果在运行过程中,同步锁只有一个线程访问,不存在多线程争用的情况,则线程是不需要触发同步的,这种情况下,就会给线程加一个偏向锁)
- 大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。
轻量级锁(jvm)
- 轻量级锁是由偏向所升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁;
- 如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量, 那偏向锁就是在无竞争的情况下把整个同步都消除掉, 连CAS操作都不做了。
自旋锁(jvm)
自旋锁原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗
jvm内部对锁的操作
如果已经存在偏向锁了,则会尝试获取轻量级锁,如果以上两种都失败,则启用自旋锁,如果自旋也没有获取到锁,则使用重量级锁,没有获取到锁的线程阻塞挂起,直到持有锁的线程执行完同步块唤醒他们;
偏向锁是在无锁争用的情况下使用的,也就是同步开在当前线程没有执行完之前,没有其它线程会执行该同步快,一旦有了第二个线程的争用,偏向锁就会升级为轻量级锁,一点有两个以上线程争用,就会升级为重量级锁;如果线程争用激烈,那么应该禁用偏向锁。
synchronized实现,lock实现,有何区别

JUC
Unsafe类是在sun.misc包下,不属于Java标准。但是很多Java的基础类库,包括一些被广泛使用的高性能开发库都是基于Unsafe类开发的,比如Netty、Cassandra、Hadoop、Kafka等。Unsafe类在提升Java运行效率,增强Java语言底层操作能力方面起了很大的作用。
Unsafe类使Java拥有了像C语言的指针一样操作内存空间的能力,同时也带来了指针的问题。过度的使用Unsafe类会使得出错的几率变大,因此Java官方并不建议使用的,官方文档也几乎没有。
通常我们最好也不要使用Unsafe类,除非有明确的目的,并且也要对它有深入的了解才行。
J.U.C - AQS
java.util.concurrent(J.U.C)大大提高了并发性能,AQS 被认为是 J.U.C 的核心。
CountDownLatch
用来控制一个或者多个线程等待多个线程。
维护了一个计数器 cnt,每次调用 countDown() 方法会让计数器的值减 1,减到 0 的时候,那些因为调用 await() 方法而在等待的线程就会被唤醒。

1 | public class CountdownLatchExample { |
CyclicBarrier
用来控制多个线程互相等待,只有当多个线程都到达时,这些线程才会继续执行。
和 CountdownLatch 相似,都是通过维护计数器来实现的。线程执行 await() 方法之后计数器会减 1,并进行等待,直到计数器为 0,所有调用 await() 方法而在等待的线程才能继续执行。
CyclicBarrier 和 CountdownLatch 的一个区别是,CyclicBarrier 的计数器通过调用 reset() 方法可以循环使用,所以它才叫做循环屏障。
CyclicBarrier 有两个构造函数,其中 parties 指示计数器的初始值,barrierAction 在所有线程都到达屏障的时候会执行一次。
1 | public CyclicBarrier(int parties, Runnable barrierAction) { |

1 | public class CyclicBarrierExample { |
Semaphore
Semaphore 类似于操作系统中的信号量,可以控制对互斥资源的访问线程数。
以下代码模拟了对某个服务的并发请求,每次只能有 3 个客户端同时访问,请求总数为 10。
1 | public class SemaphoreExample { |
基本类型原子类
concurrent 包中提供了Java基本类型的原子操作封装类—–只要类名包含了Atomic关键字都是用于迸发的类型的封装
如: AtomicInteger, AtomicLong, AtomicIntegerArray, AtomicBoolean, AtomicReference, … 等等
以AtomicInteger为例
1 | /* |
incrementAndGet
是将自增后的值返回,还有一个方法getAndIncrement
是将自增前的值返回,分别对应++i
和i++
操作。同样的decrementAndGe
t和getAndDecrement
则对--i
和i--
操作。
并发容器
哈希表非常高效,复杂度为O(1)的数据结构,在Java开发中,我们最常见到最频繁使用的就是HashMap和HashTable,但是在线程竞争激烈的并发场景中使用都不够合理,会导致线程安全问题
HashMap
关于位运算
关于位运算,左移一位就是×2倍的值, 位运算之高效,如下文在本来可以求模运算的时候,也换用位运算提高运算速度
为何要用2的幂次作为其容量
为了追求速度与效率,计算key的bucket进行hash计算的时候把取模运算转换为位运算,而当容量一定是2^n时:
h & (length - 1) 等价与 h % length,但他们是等价(效果)不等效(效率)的,其效果是计算h与length的模
负载因子默认常量:
static final floatDEFAULT_LOAD_FACTOR=0.75f;
hash运算 (为什么长度要是2的原因)
左边两组是数组长度为16(2的4次方),右边两组是数组长度为15。两组的hashcode均为8和9,但是很明显,当它们和1110“与”的时候,产生了相同的结果,也就是说它们会定位到数组中的同一个位置上去,这就产生了碰撞,8和9会被放到同一个链表上,那么查询的时候就需要遍历这个链表,得到8或者9,这样就降低了查询的效率。同时,我们也可以发现,当数组长度为15的时候,hashcode的值会与14(1110)进行“与”,那么最后一位永远是0,而0001,0011,0101,1001,1011,0111,1101这几个位置永远都不能存放元素了,空间浪费相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率!
hashmap什么时候进行扩容呢?
当hashmap中的元素个数超过数组大小loadFactor时,就会进行数组扩容,loadFactor的默认值为0.75,也就是说,默认情况下,数组大小为16,那么当hashmap中元素个数超过160.75=12的时候,就把数组的大小扩展为2*16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知hashmap中元素的个数,那么预设元素的个数能够有效的提高hashmap的性能.
HashMap是线程不安全
- 扩容时可能造成(因为采用了倒插法导致了死循环链表,所以线程不安全-> 1.8采用了顺插法解决了这个问题)
- 结果覆盖问题(多线程环境下,在同一个桶内插入数据,会拿到头节点,若A对头节点修改值后,b不知道又修改则覆盖了原值)
HashTable
HashTable和HashMap的实现原理几乎一样,差别无非是1.HashTable不允许key和value为null;2.HashTable是线程安全的。但是HashTable线程安全的策略实现代价却太大了,简单粗暴,get/put所有相关操作都是synchronized的,这相当于给整个哈希表加了一把大锁,多线程访问时候,只要有一个线程访问或操作该对象,那其他线程只能阻塞,相当于将所有的操作串行化,在==竞争激烈的并发场景中性能就会非常差==。

HashTable性能差主要是由于所有操作需要竞争同一把锁,而如果容器中有多把锁,每一把锁锁一段数据,这样在多线程访问时不同段的数据时,就不会存在锁竞争了,这样便可以有效地提高并发效率。这就是ConcurrentHashMap所采用的”分段锁“思想。

put的主要逻辑也就两步:
put方法是要加锁的, 若c超出阈值threshold,需要扩容并rehash。扩容后的容量是当前容量的2倍。这样可以最大程度避免之前散列好的entry重新散列,具体在另一篇文章中有详细分析,不赘述。扩容并rehash的这个过程是比较消耗资源的。
- *1.定位segment并确保定位的Segment已初始化 *
- 2.调用Segment的put方法。
get方法 先定位Segment,再定位HashEntry
无需加锁,由于其中涉及到的共享变量都使用volatile修饰,volatile可以保证内存可见性,所以不会读取到过期数据。
Vector
[1. 同步](https://cyc2018.github.io/CS-Notes/#/notes/Java 容器?id=_1-同步)
它的实现与 ArrayList 类似,但是使用了 synchronized 进行同步。
1 | public synchronized boolean add(E e) { |
[2. 扩容](https://cyc2018.github.io/CS-Notes/#/notes/Java 容器?id=_2-扩容-1)
Vector 的构造函数可以传入 capacityIncrement 参数,它的作用是在扩容时使容量 capacity 增长 capacityIncrement。如果这个参数的值小于等于 0,扩容时每次都令 capacity 为原来的两倍。
1 | public Vector(int initialCapacity, int capacityIncrement) { |
调用没有 capacityIncrement 的构造函数时,capacityIncrement 值被设置为 0,也就是说默认情况下 Vector 每次扩容时容量都会翻倍。
1 | public Vector(int initialCapacity) { |
[3. 与 ArrayList 的比较](https://cyc2018.github.io/CS-Notes/#/notes/Java 容器?id=_3-与-arraylist-的比较)
- Vector 是同步的,因此开销就比 ArrayList 要大,访问速度更慢。最好使用 ArrayList 而不是 Vector,因为同步操作完全可以由程序员自己来控制;
- Vector 每次扩容请求其大小的 2 倍(也可以通过构造函数设置增长的容量),而 ArrayList 是 1.5 倍。
[4. 替代方案](https://cyc2018.github.io/CS-Notes/#/notes/Java 容器?id=_4-替代方案)
可以使用 Collections.synchronizedList();
得到一个线程安全的 ArrayList。
1 | List<String> list = new ArrayList<>(); |
也可以使用 concurrent 并发包下的 CopyOnWriteArrayList 类。
1 | List<String> list = new CopyOnWriteArrayList<>(); |
CopyOnWriteList
读写分离
写操作在一个复制的数组上进行,读操作还是在原始数组中进行,读写分离,互不影响。
写操作需要加锁,防止并发写入时导致写入数据丢失。
写操作结束之后需要把原始数组指向新的复制数组。
1 | public boolean add(E e) { |
[适用场景](https://cyc2018.github.io/CS-Notes/#/notes/Java 容器?id=适用场景)
CopyOnWriteArrayList 在写操作的同时允许读操作,大大提高了读操作的性能,因此很适合读多写少的应用场景。
但是 CopyOnWriteArrayList 有其缺陷:
- 内存占用:在写操作时需要复制一个新的数组,使得内存占用为原来的两倍左右;
- 数据不一致:读操作不能读取实时性的数据,因为部分写操作的数据还未同步到读数组中。
所以 CopyOnWriteArrayList 不适合内存敏感以及对实时性要求很高的场景。
JVM
Java内存模型
Java 内存模型试图屏蔽各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果。
主内存与工作内存
处理器上的寄存器的读写的速度比内存快几个数量级,为了解决这种速度矛盾,在它们之间加入了高速缓存。
加入高速缓存带来了一个新的问题:缓存一致性。如果多个缓存共享同一块主内存区域,那么多个缓存的数据可能会不一致,需要一些协议来解决这个问题。

所有的变量都存储在主内存中,每个线程还有自己的工作内存,工作内存存储在高速缓存或者寄存器中,保存了该线程使用的变量的主内存副本拷贝。 线程只能直接操作工作内存中的变量,不同线程之间的变量值传递需要通过主内存来完成 (共享内存)
内存间交互操作
Java 内存模型定义了 8 个操作来完成主内存和工作内存的交互操作。

- read:把一个变量的值从主内存传输到工作内存中
- load:在 read 之后执行,把 read 得到的值放入工作内存的变量副本中
- use:把工作内存中一个变量的值传递给执行引擎
- assign:把一个从执行引擎接收到的值赋给工作内存的变量
- store:把工作内存的一个变量的值传送到主内存中
- write:在 store 之后执行,把 store 得到的值放入主内存的变量中
- lock:作用于主内存的变量
- unlock: 作用于主内存的变量
内存模型三大特性
1. 原子性
Java 内存模型保证了 read、load、use、assign、store、write、lock 和 unlock 操作具有原子性,例如对一个 int 类型的变量执行 assign 赋值操作,这个操作就是原子性的。但是 Java 内存模型允许虚拟机将没有被 volatile(可见性,立即同步) 修饰的 64 位数据(long,double)的读写操作划分为两次 32 位的操作来进行,即 load、store、read 和 write 操作可以不具备原子性。
有一个错误认识就是,int 等原子性的类型在多线程环境中不会出现线程安全问题。前面的线程不安全示例代码中,cnt 属于 int 类型变量,1000 个线程对它进行自增操作之后,得到的值为 997 而不是 1000。
为了方便讨论,将内存间的交互操作简化为 3 个:load、assign、store。
下图演示了两个线程同时对 cnt 进行操作,load、assign、store 这一系列操作整体上看不具备原子性,那么在 T1 修改 cnt 并且还没有将修改后的值写入主内存,T2 依然可以读入旧值。可以看出,这两个线程虽然执行了两次自增运算,但是主内存中 cnt 的值最后为 1 而不是 2。因此对 int 类型读写操作满足原子性只是说明 load、assign、store 这些单个操作具备原子性。

AtomicInteger 能保证多个线程修改的原子性。

使用 AtomicInteger 重写之前线程不安全的代码之后得到以下线程安全实现:
1 | public class AtomicExample { |
除了使用原子类之外,也可以使用 synchronized 互斥锁来保证操作的原子性。它对应的内存间交互操作为:lock 和 unlock,在虚拟机实现上对应的字节码指令为 monitorenter 和 monitorexit。
1 | public class AtomicSynchronizedExample { |
2. 可见性
可见性指当一个线程修改了共享变量的值,其它线程能够立即得知这个修改。Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值来实现可见性的。
主要有三种实现可见性的方式:
- volatile
- synchronized,对一个变量执行 unlock 操作之前,必须把变量值同步回主内存。
- final,被 final 关键字修饰的字段在构造器中一旦初始化完成,并且没有发生 this 逃逸(其它线程通过 this 引用访问到初始化了一半的对象),那么其它线程就能看见 final 字段的值。
对前面的线程不安全示例中的 cnt 变量使用 volatile 修饰,不能解决线程不安全问题,因为 volatile 并不能保证操作的原子性。
3. 有序性
有序性是指:在本线程内观察,所有操作都是有序的。在一个线程观察另一个线程,所有操作都是无序的,无序是因为发生了指令重排序。在 Java 内存模型中,允许编译器和处理器对指令进行重排序,重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。
volatile 关键字通过添加内存屏障的方式来禁止指令重排,即重排序时不能把后面的指令放到内存屏障之前。
也可以通过 synchronized 来保证有序性,它保证每个时刻只有一个线程执行同步代码,相当于是让线程顺序执行同步代码。
先行发生原则
上面提到了可以用 volatile 和 synchronized 来保证有序性。除此之外,JVM 还规定了先行发生原则,让一个操作无需控制就能先于另一个操作完成。
1. 单一线程原则
Single Thread rule
在一个线程内,在程序前面的操作先行发生于后面的操作。

管程锁定规则
Monitor Lock Rule
一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。

3. volatile 变量规则
Volatile Variable Rule
对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作。

4. 线程启动规则
Thread Start Rule
Thread 对象的 start() 方法调用先行发生于此线程的每一个动作。

5. 线程加入规则
Thread Join Rule
Thread 对象的结束先行发生于 join() 方法返回。

6. 线程中断规则
Thread Interruption Rule
对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 interrupted() 方法检测到是否有中断发生。
[7. 对象终结规则
Finalizer Rule
一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。
8. 传递性
Transitivity
如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作 A 先行发生于操作 C。
内存分区
内存分区: (总共分为: 程序计数器, 堆, 栈, 方法区, 本地方法区)
线程私有区域:程序计数器、Java虚拟机栈、本地方法栈
如果当前线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行 的是一个Native方法,这个计数器值为空。
线程共享区域:Java堆(存储静态文件, 变量) , 方法区、运行时常量池
当线程终止时,三者(虚拟机栈,本地方法栈和程序计数器)所占用的内存空间也会被释放掉, 因为它们是非共享区

内存溢出
原因:系统不能再分配你所需的内存空间
- 一次性加载了过多的数据
- 对象不能被回收
- 代码bug导致死循环
解决方案:
- 使用内存分析工具jstat分析原因
- 调式是否是三方软件的漏洞
- 设置启动内存的大小
内存泄漏
原因:内存中有一块空间无法在被使用,又不能被清理掉,累积量大了之后就会导致内存泄漏
- 代码漏洞导致常发性或者偶发性泄漏
- 对象不能被回收是根本原因
内存溢出和内存泄露的联系
内存泄露会最终会导致内存溢出。
相同点:都会导致应用程序运行出现问题,性能下降或挂起。
不同点:
- 1) 内存泄露是导致内存溢出的原因之一,内存泄露积累起来将导致内存溢出。
- 2) 内存泄露可以通过完善代码来避免,内
GC
GC算法和机制
- 机制: 标记清除, 引用计数, 停止-复制(程序暂停执行)
- 算法: 引用计数法, 根搜索算法, 标记-清除算法, 复制算法 Copying, 标记整理算法
GC算法
java语言规范没有明确的说明JVM 使用哪种垃圾回收算法,但是任何一种垃圾回收算法一般要做两件基本事情:
(1)发现无用的信息对象;
(2)回收将无用对象占用的内存空间。使该空间可被程序再次使用。
1. 引用计数法
堆中每个对象实例都有一个引用计数。任何引用计数器为0的对象实例可以被当作垃圾收集。当一个对象实例被垃圾收集时,它引用的任何对象实例的引用计数器减1。
优点:
引用计数器可以很快的额执行, 交织在程序运行中,对程序需要不被长时间打断的实时环境比较有利
缺点:
无法检测出循环引用。如父对象有一个对子对象的引用,子对象反过来引用父对象。这样,他们的引用计数永远不可能为0.
1 | public class Main { |
2. tracing算法(Tracing Collector) 或 标记-清除算法(mark and sweep)

从一个节点GC ROOT开始,寻找对应的引用节点,找到这个节点以后,继续寻找这个节点的引用节点,当所有的引用节点寻找完毕之后,剩余的节点则被认为是没有被引用到的节点,即无用的节点。
java中可作为GC Root的对象有
1.虚拟机栈中引用的对象(本地变量表)
2.方法区中静态属性引用的对象
3.方法区中常量引用的对象
4.本地方法栈中引用的对象(Native对象)
2.2.1 标记-清除算法
标记-清除算法采用从根集合进行扫描,对存活的对象对象标记,标记完毕后,再扫描整个空间中未被标记的对象,进行回收,如图所示

2.2.2 compacting算法 或 标记-整理算法
标记-整理算法采用标记-清除算法一样的方式进行对象的标记,但在清除时不同,在回收不存活的对象占用的空间后,会将所有的存活对象往左端空闲空间移动,并更新对应的指针,解决了内存碎片的问题.
2.2.3copying算法(Compacting Collector)
基于copying算法的垃圾 收集就从根集中扫描活动对象,并将每个 活动对象复制到空闲面(使得活动对象所占的内存之间没有空闲洞),这样空闲面变成了对象面,原来的对象面变成了空闲面,程序会在新的对象面中分配内存。一种典型的基于coping算法的垃圾回收是stop-and-copy算法,它将堆分成对象面和空闲区域面,在对象面与空闲区域面的切换过程中,==程序暂停执行==。
2.3.4 generation算法(Generational Collector)分代回收
分代的垃圾回收策略,是基于这样一个事实:不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的回收算法,以便提高回收效率。

Java中的对是jvm所管理的最大的一块内存空间, 主要用于粗放各种类的实例对象.
在Java中堆被分成两个不同的区域:
1. 新生代(young)
所有新生成的对象首先都是放在年轻代的。年轻代的目标就是尽可能快速的收集掉那些生命周期短的对象。
存在区域:Eden, From Survivor, To Survivor
回收时先将eden区存活对象复制到一个survivor0区,然后清空eden区,当这个survivor0区也存放满了时,则将eden区和survivor0区存活对象复制到另一个survivor1区,然后清空eden和这个survivor0区,此时survivor0区是空的,然后将survivor0区和survivor1区交换,即保持survivor1区为空, 如此往复。
当survivor1区不足以存放 eden和survivor0的存活对象时,就将存活对象直接存放到老年代。若是老年代也满了就会触发一次Full GC,也就是==新生代、老年代都进行回收==
2. 老年代(old)
在年轻代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。
当老年代内存满时触发Major GC即Full GC,Full GC发生频率比较低,老年代对象存活时间比较长,存活率标记高。

堆大小=新生代+老年代, 堆的大小可以通过参数 –Xms、-Xmx 来指定。
JVM 每次只会使用 Eden 和其中的一块 Survivor 区域来为对象服务,所以无论什么时候,总是有一块 Survivor 区域是空闲着的。
因此,新生代实际可用的内存空间为 9/10 ( 即90% )的新生代空间。
持久代(Permanent Generation)
用于存放静态文件,如Java类、方法等。持久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些class,例如Hibernate 等,在这种时候需要设置一个比较大的持久代空间来存放这些运行过程中新增的类。
GC(垃圾收集器)

Serial收集器(复制算法)
新生代单线程收集器,标记和清理都是单线程,优点是简单高效。
Serial Old收集器(标记-整理算法)
老年代单线程收集器,Serial收集器的老年代版本。
ParNew收集器(停止-复制算法)
新生代收集器,可以认为是Serial收集器的多线程版本,在多核CPU环境下有着比Serial更好的表现。
Parallel Scavenge收集器(停止-复制算法)
并行收集器,追求高吞吐量,高效利用CPU。吞吐量一般为99%, 吞吐量= 用户线程时间/(用户线程时间+GC线程时间)。适合后台应用等对交互相应要求不高的场景。
Parallel Old收集器(停止-复制算法)
Parallel Scavenge收集器的老年代版本,并行收集器,吞吐量优先
CMS(Concurrent Mark Sweep)收集器(标记-清理算法)
高并发、低停顿,追求最短GC回收停顿时间,cpu占用比较高,响应时间快,停顿时间短,多核cpu 追求高响应时间的选择
GC的执行机制
由于对象进行了分代处理,因此垃圾回收区域、时间也不一样。GC有两种类型:Scavenge GC和Full GC。
Scavenge GC
一般情况下,当新对象生成,并且在Eden申请空间失败时,就会触发Scavenge GC,对Eden区域进行GC,清除非存活对象,并且把尚且存活的对象移动到Survivor区。然后整理Survivor的两个区。这种方式的GC是对年轻代的Eden区进行,不会影响到年老代。因为大部分对象都是从Eden区开始的,同时Eden区不会分配的很大,所以Eden区的GC会频繁进行。因而,一般在这里需要使用速度快、效率高的算法,使Eden去能尽快空闲出来。
Full GC
对整个堆进行整理,包括Young、Tenured和Perm。Full GC因为需要对整个堆进行回收,所以比Scavenge GC要慢,因此应该尽可能减少Full GC的次数。在对JVM调优的过程中,很大一部分工作就是对于FullGC的调节。有如下原因可能导致Full GC:
- 1.年老代(Tenured)被写满
- 2.持久代(Perm)被写满
- 3.System.gc()被显示调用
- 4.上一次GC之后Heap的各域分配策略动态变化
Java有了GC同样会出现内存泄露问题
- 1.静态集合类像HashMap、Vector等的使用最容易出现内存泄露,这些静态变量的生命周期和应用程序一致,所有的对象Object也不能被释放,因为他们也将一直被Vector等应用着。
- 2.各种连接,数据库连接,网络连接,IO连接等没有显示调用close关闭,不被GC回收导致内存泄露。
- 3.监听器的使用,在释放对象的同时没有相应删除监听器的时候也可能导致内存泄露。
JVM中的安全点
在 JVM 中如何判断对象可以被回收 一文中,我们知道 HotSpot 虚拟机采取的是可达性分析算法。即通过 GC Roots 枚举判定待回收的对象。
那么,首先要找到哪些是 GC Roots。
有两种查找 GC Roots 的方法:
- 一种是遍历方法区和栈区查找(保守式 GC)。
- 一种是通过 OopMap 数据结构来记录 GC Roots 的位置(准确式 GC)。
很明显,保守式 GC 的成本太高。准确式 GC 的优点就是能够让虚拟机快速定位到 GC Roots。
对应 OopMap 的位置即可作为一个安全点(Safe Point)。
在执行 GC 操作时,所有的工作线程必须停顿,这就是所谓的”Stop-The-World”。
为什么呢?
因为可达性分析算法必须是在一个确保一致性的内存快照中进行。如果在分析的过程中对象引用关系还在不断变化,分析结果的准确性就不能保证。
安全点意味着在这个点时,所有工作线程的状态是确定的,JVM 就可以安全地执行 GC 。
如何选定安全点
安全点太多,GC 过于频繁,增大运行时负荷;安全点太少,GC 等待时间太长。
一般会在如下几个位置选择安全点:
- 循环的末尾
- 方法临返回前
- 调用方法之后
- 抛异常的位置
为什么选定这些位置作为安全点:
主要的目的就是避免程序长时间无法进入 Safe Point。比如 JVM 在做 GC 之前要等所有的应用线程进入安全点,如果有一个线程一直没有进入安全点,就会导致 GC 时 JVM 停顿时间延长。比如这里,超大的循环导致执行 GC 等待时间过长。
如何在 GC 发生时,所有线程都跑到最近的 Safe Point 上再停下来?
主要有两种方式:
- 抢断式中断:在 GC 发生时,首先中断所有线程,如果发现线程未执行到 Safe Point,就恢复线程让其运行到 Safe Point 上。
- 主动式中断:在 GC 发生时,不直接操作线程中断,而是简单地设置一个标志,让各个线程执行时主动轮询这个标志,发现中断标志为真时就自己中断挂起。
JVM 采取的就是主动式中断。轮询标志的地方和安全点是重合的。
安全区域又是什么?
Safe Point 是对正在执行的线程设定的, 如果一个线程处于 Sleep 或中断状态,它就不能响应 JVM 的中断请求,再运行到 Safe Point 上。因此 JVM 引入了 Safe Region。
Safe Region 是指在一段代码片段中,引用关系不会发生变化。在这个区域内的任意地方开始 GC 都是安全的。线程在进入 Safe Region 的时候先标记自己已进入了 Safe Region,等到被唤醒时准备离开 Safe Region 时,先检查能否离开,如果 GC 完成了,那么线程可以离开,否则它必须等待直到收到安全离开的信号为止。