线程安全只在多线程环境下出会出现,单线程串行执行并不存在此问题。
解决线程安全问题可以从几方面来考虑,比如:
保证数据在单线程内可见:举个例子,SimpleDateFormat
在格式化时间时要设置时间,多线程访问会导致设置的时间被其它线程修改,这种情况下只要保证每个线程内SimpleDateFormat
不使用同一个就可以了,ThreadLocal
就可以用于这种场景。
使用线程安全类:有些线程安全类的内部有明确的线程安全机制,比如DateTimeFormatter
它在格式化的时候通过StringBuffer
来操作的,在多线程环境下它可以用来代替SimpleDateFormat
。
使用同步或锁机制:
synchronized
或者Lock
给资源操作加锁。ConcurrentHashMap
、CopyOnWriteArrayList
。(在了解锁之前要知道对象头是什么)。
每个对象都有一个对象头(Object Header),它主要用来保存与对象本身数据无关的额外数据,它由Mark word【标记字段】、klass Pointer【类指针,找到类元数据】组成,如果对象是一个数组对象头中还包含Array
Length
【数组长度】,【不同机器对象头大小不同32位机器为32位】
其中Mark word中又包括
hashCode:
threadId(偏向锁线程标识):偏向锁线程ID
epoch(偏向锁有效性时间戳)
age(分代年龄):对象被GC的次数,如果次数达到设定的阈值后会被移送直老年代,(这个值是4个字节存储的,所以最大值只能是15)。
biased_lock(偏向锁标志):
lock(锁标志):
pointer_to_lock_record(轻量锁lock record指针):
pointer_to_heavyweight_monitor(重量锁monitor指针):
synchronized
synchronized使用monitorenter及monitorexit两个字节码指令来获取和释放monitor。如果使用monitorenter进入monitor时monitor为0,表示该线程可以
执行monitor,并将monitor加1,如果当前线程已经持有了monitor,那么monitor继续加1,如果monitor非0,其他线程就会进入阻塞状态。
Java中的锁最初是使用synchronized
来实现,在jdk1.6之前synchronized
通过向操作系统资源,借助操作系统的互斥锁来实现线程同步的。
从jdk1.6之开始synchronized
做了很多优化,
在大多数情况下,锁总是被同一个线程获取,为了让获取锁的代价更低引入了偏向锁。
当一个线程获取锁后,会在这个对象的对象头中记录这个线程的ID,之后这个线程进入锁时不需要CAS,只需要根据线程ID判断是不是同一个线程,
当一个对象被多个线程访问并且访问时间是错开的时候,会使用轻量级锁。会把线程栈帧中的Lock Record和对象Mark word中的Lock Record通过CAS替换。
当出现锁竞争时,也就是轻量级锁CAS替换失败,锁会膨胀为重量级锁,为Object申请Monitor锁,Mark Word将记录Monitor地址,失败线程阻塞。
重量级锁竞争的时候,线程并不是立即进入阻塞状态,线程还会通过自旋来进行优化,如果自旋期间其它线程释放了锁就可能会获取到锁,避免阻塞及线程上下文切换。
自旋多久有系统状态决定,如CPU空闲率高的话自旋时间会越长。
Lock
Lock
接口可以用来代替synchronized
实现并发访问,Lock
的接口的实现逻辑并不依赖synchronized
,它主要是利用了volatile
的可见性,Lock
接口下主
要实现类为ReentrantLock
,ReentrantLock
中定义了Sync
类,它继承自AbstractQueuedSynchronizer
,也就是我们常说的AQS,在AQS中定义了一个volatile int state
变量作为共享资源,如果线程获取资源失败,则进入同步FIFO队列中等待。
当获取锁的线程执行完并释放锁资源时,会通知同步队列中的等待线程来获取资源后并出队执行。
ConditionObject 继承自 Condition 类,这个类里面保存了 firstWaiter 在解锁时通过 firstWaiter 获取下一个线程并唤醒。
volatile
在说volatile
之前先说下指令重排,就是在线程内程序执行顺序和代码顺序不一样,但是执行结果不变,那么就说程序发生了指令重排。【指令重排发生在JNI动态编译和CPU执行期间】、
计算机并不会按照代码顺序按部就班的执行,CPU在处理信息时会进行指令优化,分析哪些取数据动作可以合并【取指令】、哪些存数据可以合并【写操作数】,然后对指令重排序,并以冲排序结果执行,以些来提高执行效率。
指令重排还应该准许Happen before规则,对于哪个有先后关系、有依赖的代码顺序,并不会发生指令重排,比如定义一个变量a,然后定义一个变量b,变量b的值是根据a计算出来的,这种情况下就不会发生指令重排。
指令重排因为有happen before规则约束,所以在单线程环境下始终能得到正确的结果,但是多线程环境下就得到与预期不符的值。
以双重检查的单例模式为例,首先我们知道创建对象并不是一个原子操作,它经历了分配内存空间、对象初始化、为变量赋值分配的内存空间地址3个操作,这三个操作是可能发生指令重排的,比如先分配内存,再给变量赋值为分配的内存空间地址、最后为对象初始化。
此时其它线程在做第一次检查时就会发现单例实例不为null
,然后返回这个实例,但是这个实例被没有并初始化。
public class Singleton {
private static Singleton instance;
private Singleton() {
}
public static synchronized SingletonTest getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
这种情况呢就可以使用volatile
来修饰目标属性,限制编译器对它进行指令重排序操作,确保对象实例化之后才返回。
volatile
不仅可以禁止指令重排,也可以确保变量的可见性。被volatile修饰的变量,任何对该变量的操作都会在内存中进行,以此保证内存可见性。
要注意的是volatile
只是保证了内存的可见性,并不保证原子性,多线程下对volatile
修饰的变量做i++
操作并不一定能得到预期结果(因为i++不是原子的)。
比如两个线程操作volatile
修饰人int
变量,一个线程++一个线程–。
(volatile
的所有操作都需要同步内存变量,所以volatile
一定会使用线程执行速度变慢)。
volatile
不是轻量级的线程同步方式,它只是轻量级的线程操作可见方式,并发多写场景一定会引发线程安全问题,一写多读的并发场景,使用volatile
则非常合适,
比如CopyOnWriteArrayList
。
transient
transient
只能修饰变量, 修饰的字段不会被序列化。反序列化的时候这个值被设定为对应类型的默认值。
static
修饰的变量,也就是类变量不会被序列化,因为类变量在类加载的时候就已经初始化了,反序列化时也能获取到这个变量值。
CopyOnWriteArrayList
CyclicBarrier
public class CyclicBarrierTest {
public static void main(String[] args) {
CyclicBarrier cyclicBarrier = new CyclicBarrier(3);
for (int i = 0; i < 3; i++) {
int finalI = i;
new Thread(() -> {
System.out.printf("线程%d开始执行\n", finalI);
try {
if (finalI == 2)
TimeUnit.SECONDS.sleep(finalI);
// await次数到达 构造器设定的3时,全部被唤醒继续执行
cyclicBarrier.await();
} catch (Exception e) {
e.printStackTrace();
}
System.out.printf("线程%d结束执行\n", finalI);
}).start();
}
}
}
线程的创建需要开辟虚拟机栈、本地方法栈、程序计数器等线程私有的内存空间,在线程销毁时需要回收这些资源,频繁的创建和销毁会浪费大量的系统资源。
通过ThreadPoolExecutor
来构造自定义的线程池。参数为:
RejectExecutionException
。有好的拒绝策略:
1. 保存到数据库进行削峰填谷,等空闲时再取出来执行。
2. 转向某个提示页面
3. 打印日志
常见的线程池创建方式有ThreadPoolExecutor
、ScheduledThreadPoolExecutor
和ForkJoinPool
。
Integer.MAX_VALUE
,是高度可伸缩的线程池,默认线程为60秒空闲时间,大量任务容易导致OOM。在newSingleThreadExecutor
和newFixedThreadPool
中使用的是LinkedBlockingQueue
,并且没有指定队列的长度,这种无界队列如果瞬间请求非常大,会有OOM风险。所以在4种模式都有OOM风险。
每个Thread
对象中均包含一个ThreadLocalMap
变量threadLocals,它存储了本线程中所有ThreadLocal
对象及其对应的值。
ThradLocalMap
里面是一个Entry
数组,它的key就是ThreadLocal
对象,value就是设置的值,并且它的key是一个弱引用对象,如果没有指向这个的强引用,
key就会被垃圾回收器回收,如果线程一直没有结束,就会导致value无法被回收,造成内存泄露。
在执行set
或者get
方法时,首先获取的是当前Thread
对象,然后通过当前Thread
对象的threadLocals
来设置或获取值。
因为第个Thread
对象都有一个ThreadLocalMap
变量,所以不会存在线程安全问题。
使用场景:
ThreadLocal
来避免多次传递。SimpleDateFormat
对时间进行格式化时。不再会被使用的对象或者变量占用的内存不能被回收,就是内存泄露,当堆积大量的未被回收的对象时,可能会导致内存溢出。
ThreadLocalMap
中的Entry
的key是弱引用的ThreadLocal
,当这个key没有被强引用时,就会被回收,早成Entry
中关联关系为null
关联一个value。
由于线程中的ThreadLocalMap
是跟随线程的生命周期的,导致线程未结束时value
无法被回收,产生内存泄露。 尤其在项目中使用线程池时,任务结束后,线程可能并不会被销毁,就会导致value一直无法被回收。
解决办法:
ThreadLocal
的remove
方法清除数据。ThreadLocal
设置为static final
,保证ThreadLocal
为强引用,保证任何时候都能通过ThreadLocal
获取到设置的值。set
、get
时会清除key为null的值。new
方式创建的对象一般都为强引用,只要对象有强引用指向,并且GC Roots可达,那么Java内存回收时,即使是内存耗尽,也不会回收该对象。一般来说,一程序在不同的硬件平台需要多套代码编译成对应的机器码来执行。字节码就是代码编译后的指令集,它运行在可以执行字节码的软件平台上,从而屏蔽对操作系统的依赖,实现跨平台。
如JVM就可以解释执行java源文件编译后的字节码,如果是热点代码,JVM也可以通过JIT动态地编译为机器码执行,提高执行效率。
类加载过程主要分为加载、链接和初始化三个阶段。
链接阶段主要是读取类文件产生的二进制流,并转化为特定的数据结构,初步校验魔法树、常量池、文件长度、是否有父类等,然后创建对应的java.lang.Class实例。
链接阶段又分为验证、准备和解析三个阶段。验证是做更详细的校验,比如final是否合规、类型是否正确等,准备阶段就是为静态变量分配内存并设定默认值,解析类和方法保证保证类与类之间的相互引用正确性,完成内存布局。
初始化阶段执行类构造器方法,如果赋值运算是通过其它类的静态方法完成的,那么马上解析另外一个类,在虚拟机栈中执行完毕后通过返回值进行赋值。
JMM,java memory model,Java内存模型,它可以屏蔽不同硬件和操作系统的内存访问差异,让Java程序在不同平台达到一致的访问效果。
JVM内存布局局,主要分为本地方法栈、程序计数器、虚拟机栈、堆区和元数据区。
堆区是用来存储对象实例,堆区由垃圾回收器自动回收。堆的内存空间可以在运行时动态调整也可以固定大小,通过-Xms256M来设置堆最小容量(默认系统内存1/64),-Xmx1024(默认系统内存1/4)来设置堆最大容量,在服务器运行过程中,堆空间不断地扩容与回缩,会形成不必要的系统压力,所以在线上生产环境Xms和Xmx设置成一样,避免GC后调整堆大小带来的额外压力。
堆区分成两大块:新生代和老年代,新生代包含一个Eden区、和两个Survivor区.
Eden区:绝大部分对象在Eden区生成生成(对象过大新生代无法容纳时直接进入老年代【如很长的数组】),当Eden区装填满的时候会触发YGC(Yong Garbage
Collection),没有被引用的对象直接回收,依然存活的对象被移送到Survivor区。
Survivor区:Survivor区分为s0和s1两块内存空间,每次YGC的时候会把存活的对象复制到未使用的那块空间,然后清除当前正在使用的空间,并交换两块空间的使用状态,如果移送的对象大于Survivor区容量的上限,直接移送到老年代。每个对象都像都有一个计数器,每次YGC时都会加1,可以通过-XX:
MaxTenuringThreshold为配置当计数器达到某个阈值时,对象从新生代移送直老年代。如果该参数配置为1,会从新生代Eden区直接移送至老年代,默认值为15,在Survivor交换14次后也就是第15次交换时直接晋升到老年代。
如果一个超大对象在移到老年代时,老年代也无法放下,就会触发FGC(Full Garbage Collection),如果依然无法放下,就抛出OOM。可以设置虚拟机参数-XX:+HeapDumpOnOutOfMemoryError和-XX:
HeapDumpPath 使异常信息输出到指定文件。
主要用于保存类元信息、字段、静态属性、方法、常量等。(字符串常量池移至堆内存中)
栈是一个先进后出的数据结构,JVM中的虚拟机栈是描述方法执行的内存区域,它是线程私有的。栈桢是方法执行的基本单位,方法从开始调用到结束执行就是栈桢从入栈到出栈的过程。栈中又包含局布变量表、操作栈、动态连接和方法返回地址。
局部变量表是存放方法参数和局部变量的区域。字节码指令中的STORE指令是将操作栈中计算完成的局部变量写回局部变量表的存储空间内。
操作栈在方法执行过程中,会有各种指令往栈中写入和提取信息,(JVM是基于栈的执行引擎,其中的栈就是指操作栈),以a=i++为例,首先把i的值从局部变量表提取出来压入操作栈中,然后在局部变量表中将值加1,然后再把操作栈顶的值赋值给a。a=++i是先在局部变量表中把i的值加1,然后把+1后的结果压入操作栈中,最后取出栈顶的值赋值给a。
动态连接,每个栈帧中包含一个在常量池中对所属方法的引用,目的是支持方法调用过程中的动态连接。编译时的变量和方法都是作为符号引用存储在常量池的,动态连接就是把符号引用转换为直接引用。符号引用在方法调用时只知道调用了哪个方法,不能直接知道调用方法的地址。
StackOverflowError就是栈溢出,导致内存耗尽。
本地方法栈主要是为Native方法服务的,本地方法可以通过JNI(Java native interface)来访问JVM运行时的数据区,System.currentTimeMillis()
就是一个本地方法,JNI可以使用Java使用操作系统的特性功能,复用非Java代码。
CPU是通过时间片轮询来执行程序的,一个确定的时间点只会执行某一个线程,所以就会导致线程经常中断和恢复。每个线程创建后都会产生自己的程序计数器,程序计数器中存放着执行指令的偏移量和信号指示器等,线程执行或恢复都依赖程序计数器。
从线程角度来说,堆和元空间是线程共享的,而虚拟机栈、本地方法栈、程序计数器是线程私有的。
JVM是和语言解耦的,只要一门语言能编译生成符合JVM规范的字节码,JVM就能执行该程序。比如scala、groovy、kotlin。
JVM创建对象时,线程安全问题主要发生多个线程为对象分配内存指向了同一个内存区域。JVM中主要采用TLAB(Thread Local Allocation Buffer)来做的。
JVM会为每个线程在新生代的Eden区开辟一块私有的内存区域,不被其它线程共享,创建的对象会优先分配在这块内存区域内。【大对象会创建在老年代】
有没有其它办法?可以考虑使用CAS + 重试机制,这样做的缺点是考虑并发问题,效率不高。
一个空的Object对象没有实例数据占用内存,只有对象头,对象头中的mark word和klass pointer分别占用8个字节,一共占用16个字节。
public class Test {
private Integer id;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Test test = (Test) o;
return Objects.equals(id, test.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
}
public class Test2 {
private Integer id;
private Test test;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Test2 test2 = (Test2) o;
return Objects.equals(id, test2.id) && Objects.equals(test, test2.test);
}
@Override
public int hashCode() {
return Objects.hash(id, test);
}
}
红黑树是一种特殊的AVL树(平衡二叉查找树),它的主要特征是在每个节点上增加一个属性来表示节点的颜色,可以是红色,也可以是黑色。它与AVL树类似,都是在
进行插入和删除元素时,通过特定旋转来保持自身平衡,从而获得较高的查找性能。
Spring中自定义注解的使用:
元注解:
@Retention:指定其所修饰的注解的保留策略,Retention.SOURCE、Retention.CLASS、Retention.RUNTIME
@Document:该注解是一个标记注解,用于指示一个注解将被文档化
ElementType.Type 可以修饰类、接口、注解或枚举类型
ElementType.FIELD 可以修饰属性(成员变量),包括枚举常量
ElementType.METHOD 可以修饰方法
ElementType.PAPAMETER 可以修饰参数
ElementType.CONSTRUCTOR 可以修饰构造方法
ElementType.LOCAL_VARIABLE 可以修饰局部变量
ElementType.ANNOTATION_TYPE 可以修饰注解类
ElementType.PACKAGE
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface MyAnnotation {
String value() default "";
}
public class Demo {
@MyAnnotation("这是日志内容")
@RequestMapping("user/{id}")
public User findUser(@PathVariable("id") Integer id) {
return userService.findUserById(id);
}
}
@Component
@Aspect
public class KthLogAspect {
@Pointcut("@annotation(com.example.demo.annotation.KthLog)")
private void pointcut() {
}
@Before("pointcut() && @annotation(MyAnnotation)")
public void advice(MyAnnotation logger) {
System.out.println("--- 日志的内容为[" + logger.value() + "] ---");
}
}
在程序的运行状态中,可以构造任意一个类的实例,可以设置和获取任意一个类的属性,调用获取和调用任意一个类的方法,这种动态获取程序信息以及动态调用对象方法的功能就叫反射。
通过new
方式创建对象的效率高,使用反射要去加载或查找类,然后获取类的构造器,然后才能创建对象实例,过程比较繁琐。
在加载MySQL驱动的时候有用到反射,JDK动态代理中有用到反射,Spring 创建Bean用到反射,各种框架中都有用到反射。
后台将数据下载为excel文件时用到了反射。
自定义一个注解,这个注解用来指定实体类属性映射到excel文件列的名字,从数据库取出数据后,获取第一个元素,通过反射拿到属性上的注解
并获取列名,然后再生成excel文件。