为什么String被设计为是不可变的?
把String类设计为不可变类,主要是出于安全和性能的考虑,可归纳为以下4点:
- 由于字符串无论在任何Java系统中都广泛使用,会用来存储敏感信息,如账号、密码、网络路径、文件处理等场景里,保证字符串String类的安全性就尤为重要,如果字符串是可变的,容易被篡改,那我们就无法保证使用字符串进行操作时它是安全的,很可能出现SQL注入、访问危险文件等操作。
- 在多线程中,只有不变的对象和值是线程安全的,可以在多个线程中共享数据。由于String天然的不可变,当一个线程“修改”了字符串的值,只会产生一个新的字符串对象,不会对其他线程的访问产生副作用,访问的都是同样的字符串数据,不需要任何同步操作。
- 字符串作为基础的数据结构,大量地应用在一些集合容器中,尤其是一些散列集合,在散列集合中,存放元素都要根据对象的hashCode()方法来确定元素存放的位置。由于字符串hashcode属性不会变更,保证了唯一性,使得类似HashMap、HashSet等容器才能实现相应的缓存功能。由于String的不可变,避免重复计算hashcode,只要使用缓存的hashcode即可,这样一来大大提高了在散列集合中使用String对象的性能。
- 当字符串不可变时,字符串常量池才有意义。字符串常量池的出现可以减少创建字面量相同的字符串,让不同的引用指向池中同一个字符串,为运行时节约很多堆内存。若字符串可变,字符串常量池失去意义,基于常量池的String.intern()方法也失效,每次创建新的字符串都将在堆内开辟出新的空间,占据更多内存。
为什么不能通过Executors去创建线程池?
Executors返回的线程池对象的弊端如下:
FixedThreadPool和SingleThreadPool:允许的请求队列长度为Integer.MAX_VALUE,可能会堆积大量的请求,从而导致OOM。CachedThreadPool:允许的创建线程数量为Integer.MAX_VALUE,可能会创建大量的线程,从而导致OOM。ScheduledThreadPool:采用的存放队列是DelayedWorkQueue,也是一个无界队列,可能会堆积大量请求,从而导致OOM。
通过ThreadPoolExecutor的方式创建线程池,可以更加明确线程池的运行规则,避免资源耗尽的风险。
如何根据实际需要,定制自己的线程池?
参数信息:
- int corePoolSize 核心线程大小
- int maximumPoolSize 线程池最大容量大小
- long keepAliveTime 线程空闲时,线程存活的时间
- TimeUnit unit 时间单位
- ArrayBlockingQueue<Runnable> 任务队列
- threadFactory 线程工厂
- handler 执行拒绝策略的对象
CAS有什么缺点?
- ABA问题: 通过添加版本号解决
- 自旋时间过长: 单次的CAS不一定能够成功,所以用CAS去配合一个循环,有的时候可能是死循环,直到线程竞争不激烈的时候才能够修改成功。如果本身就是一种高并发的场景,那么就有可能导致CAS一直不成功。在此期间CAS不会停止,CPU资源也一直被消耗。高并发的场景下CAS的效率是不高的。
- 范围不能灵活控制: 通常在执行CAS的时候是针对某一个共享变量,而不是多个共享变量,因为多个共享变量之间是一个相互独立的状态,如果说简单地把原子操作组合到一起,实际上并不具备原子性,无法保证线程安全。在Java中可以利用一个新的类,然后整合一组共享变量,这样就能够保证线程安全了。
ActiveMQ、RabbitMQ、RocketMQ、Kafka有什么优缺点?
| 特性 | ActiveMQ | RabbitMQ | RocketMQ | Kafka |
|---|---|---|---|---|
| 单机吞吐量 | 万级,比RocketMQ、Kafka低一个数量级 | 同ActiveMQ | 10万级,支撑高吞吐 | 10万级,高吞吐一般配合大数据类的系统来进行实时数据计算、日志采集等场景 |
| topic数量对吞吐量的影响 | topic可以达到几百/几千级别,吞吐量会有小幅度的下降,这是RocketMQ的一大优势,在同等机器下,可以支撑大量的topic | topic从几十到几百个的时候,吞吐量会大幅度下降,在同等机器下,Kafka尽量保证topic数量不要过多,如果要支撑大规模的topic,需要增加更多的机器资源 | ||
| 时效性 | ms级 | 微秒级,这是RabbitMQ的一大特点,延迟最低 | ms级 | 延迟在ms级以内 |
| 可用性 | 高,基于主从架构实现高可用 | 同ActiveMQ | 非常高,分布式架构 | 非常高,分布式,一个数据多个副本,少数机器宕机,不会丢失数据,不会导致不可用 |
| 消息可靠性 | 有较低的概率丢失 | 基本不丢失 | 经过参数优化配置,可以做到0丢失 | 同RocketMQ |
| 功能支持 | MQ领域的功能极其完善 | 基于erlang开发,并发能力很强,性能极好,延时很低 | MQ功能较为完善,还是分布式的,扩展性好 | 功能较为简单,主要支持简单的MQ功能,在大数据领域的实时计算以及日志采集被大规模使用 |
消息队列核心使用场景:解耦、异步、削峰。
一般的业务系统要引入MQ,最早大家都用ActiveMQ,但是现在确实大家用的不多了,没经过大规模吞吐量场景的验证,社区也不是很活跃。
后来大家开始用RabbitMQ,但是erlang语言阻止了大量的Java工程师去深入研究和掌握他,对公司而言,几乎处于不可控的状态,但是人家确实是开源的,有比较稳定的支持,社区活跃度也高。
不过现在越来越多的公司会去使用RocketMQ,阿里出品的确实很不错,但社区有突然黄掉的风险,目前RocketMQ已经捐给Apache,但GitHub上的社区活跃度不高,对自己公司技术实力有绝对自信的,推荐用RocketMQ。
所以中小型公司技术实力较为一般,技术挑战不是特别高,用RabbitMQ是不错的选择;大型公司,基础架构研发实力较强,用RocketMQ是很好的选择。
如果是大数据领域的实时计算、日志采集等场景,用Kafka是业内标准,绝对没问题,社区活跃度很高,几乎是全世界这个领域的事实性规范。
说一说自动装箱和自动拆箱
自动装箱、自动拆箱是JDK1.5提供的功能。
自动装箱:可以把一个基本类型的数据直接赋值给对应的包装类型;
自动拆箱:可以把一个包装类型的对象直接赋值给对应的基本类型;
通过自动装箱、自动拆箱功能,可以大大简化基本类型变量和包装类对象之间的转换过程。比如某个方法的参数类型为包装类型,调用时我们所持有的数据却是基本类型的值,则可以不做任何特殊的处理,直接将这个基本类型的值传入方法即可。
int和Integer有什么区别,二者在做==运算时会得到什么结果?
int是基本数据类型,Integer是int的包装类。二者在做==运算时,Integer会自动拆箱为int类型,然后再进行比较。如果两个int值相等则返回true,否则就返回false。
面向对象的三大特征是什么?
面向对象的程序设计有三个基本特征:封装、继承、多态。
- 封装:将对象的实现细节隐藏起来,然后通过一些公用方法来暴露该对象的功能;
- 继承:面向对象实现软件复用的重要手段,当子类继承父类后,子类作为一种特殊的父类,将直接获得父类的属性和方法;
- 多态:指的是子类对象可以直接赋给父类变量,但运行时依然表现出子类的特征行为,这意味着同一个类型的对象在执行同一个方法时,可能表现出多种行为特征。
此外还有一个重要思想是“抽象”,即忽略一个主题中与当前目标无关的那些方面,以便更充分地注意与当前目标有关的方面。抽象并不打算了解全部问题,而是只考虑部分问题。例如,需要考察Person对象时,不可能在程序中把Person所有细节都定义出来,通常只能定义Person的部分数据、部分行为特征,而这些数据、行为特征是软件系统所关心的部分。
封装的目的是什么?从什么方面考虑封装?
封装是面向对象编程语言对客观世界的模拟。在客观世界里,对象的状态信息都被隐藏在对象内部,外界无法直接操作和修改。对一个类或者对象实现良好的封装,可以实现以下目的:
- 隐藏类的实现细节;
- 让使用者只能通过事先预定的方法来访问数据,可以在该方法内加入控制逻辑,从而限制对成员变量的不合理访问;
- 可进行数据检查,从而有利于保护对象信息的完整性;
- 便于修改,提高代码的可维护性。
为了实现良好的封装,应当从两个方面考虑:
- 将对象的成员变量和实现细节隐藏起来,不允许外部直接访问;
- 把方法暴露出来,让方法来控制对这些成员变量进行安全的访问和操作。
封装就是将该隐藏的隐藏起来,将该暴露的暴露出来。这两个方面都需要通过Java提供的访问控制符来实现。
说一说你对多态的理解
子类是一种特殊的父类,因此Java允许把一个子类对象直接赋给一个父类引用变量,无需任何类型转换,也称为向上转型,由系统自动完成。
当把一个子类对象直接赋给父类引用变量时,例如BaseClass obj = new SubClass();,这个obj引用变量的编译时类型是BaseClass,而运行时类型是SubClass,当运行时调用该引用变量的方法时,其方法行为总是表现出子类方法的行为特征,而非父类方法的行为特征,这就可能出现:相同类型的变量,调用同一个方法时呈现出多种不同的行为特征,这就是多态。
多态可以提高程序的可扩展性,在设计程序时让代码更简洁优雅。
例如设计一个司机类,司机可以开轿车、巴士、卡车等,如下:
class Driver{
void drive (Car car){...}
void drive (Bus bus){...}
void drive (Truck truck){...}
}
在设计上述代码时,采用了重载机制,将方法名进行了统一。这样在调用时无论开什么交通工具,都是通过Driver.drive(obj)来调用,对调用者足够友好。
但对于程序开发者来说,这样的代码就不友好了,因为实际上司机可以驾驶更多类的交通工具。当系统需要为司机增加车型时,开发者就需要相应地增加drive方法,类似的代码会越堆积越多,显得臃肿。
如果采用多态的方式来设计上述程序,就会变得简洁很多。我们可以为所有的交通工具定义一个父类Vehicle,然后按照如下方法设计drive方法:
class Driver{
void drive (Vehicle vehicle){...}
}
调用时,我们可以传入Vehicle类型的实例,也可以传入任意的Vehicle子类型的实例,对于调用者来说一样的方便,但对开发者来说,代码就十分简洁了。
说一说重载和重写的区别
重载发生在同一个类中,若多个方法之间方法名相同、参数列表不同,则它们构成重载的关系。重载与方法的返回值以及访问修饰符无关,即重载的方法不能根据返回类型进行区分。
重写发生在父类子类中,若子类方法想要和父类方法构成重写关系,则它的方法名、参数列表必须与父类方法相同。另外,返回值要小于等于父类方法,抛出的异常要小于等于父类方法,访问修饰符要大于等于父类方法。此外,若父类的访问修饰符为private,则子类不能对其重写。
说一说hashCode()和equals()的关系
hashCode()用于获取哈希码(散列码),equals()用于比较两个对象是否相等,它们应遵守如下约定:
- 如果两个对象相等,则它们必须有相同的哈希码。
- 如果两个对象有相同的哈希码,它们也未必相等。
为什么要重写hashCode()和equals()?
Object类提供的equals()方法默认是用==来比较的,也就是说只有两个对象是同一个对象时,才能返回相等的结果。而实际的业务中,我们通常的需求是,若两个不同的对象它们的内容是相同的,就认为它们相同。鉴于这种情况,Object类中equals()方法的默认实现是没有实用价值的,所以通常都要重写。
由于hashCode()与equals()具有很强的联动关系,所以equals()方法重写时,通常也要将hashCode()进行重写,使得这两个方法始终满足相关的约定。
==和equals()有什么区别?
==运算符:
- 作用于基本数据类型时,是比较两个数值是否相等;
- 作用于引用数据类型时,是比较两个对象的内存地址是否相同,即判断它们是否为同一个对象。
equals()方法:
- 没有重写时,Object默认以==来实现,即比较两个对象的内存地址是否相同;
- 进行重写后,一般会按照对象的内容来进行比较,若两个对象内容相同则认为对象相同,否则认为对象不同。
遇到过异常吗,如何处理?
在Java中,可以按照如下三个步骤处理异常:
- 捕获异常:将业务代码包裹在try块内部,当业务代码中发生任何异常时,系统都会为此异常创建一个异常对象。创建异常对象之后,JVM会在try块之后寻找可以处理它的catch块,并将异常对象交给这个catch块处理。
- 处理异常:在catch块中处理异常时,应该先记录日志,便于以后追溯这个异常。然后根据异常的类型,结合当前的业务情况,进行相应的处理。比如,给变量赋予一个默认值、直接返回空值、向外抛出一个新的业务异常交给调用者处理等等。
- 回收资源:如果业务代码打开了某个资源,比如数据库连接、网络连接、磁盘文件等,则需要在需要在这段业务代码执行完毕后关闭这项资源。并且,无论是否发生异常,都要尝试关闭这项资源,将关闭资源的代码写finally块内,可以满足这种需求,即无论是否发生了异常,finally块内的代码总会被执行。
HashMap为什么用红黑树而不用B树?
B/B+树多用于外存上,B/B+树也被称为是磁盘友好的数据结构。 HashMap本来是数组+链表的形式,链表由于其查找慢的特点,所以需要被查找效率更高的树结构来代替。如果用B/B+树的话,在数据量不是很多的情况下,数据都会“挤在”一个结点里面,这时候遍历效率就退化成了链表。
说一说HashSet和TreeSet的区别
HashSet和TreeSet中的元素都是不能重复的,并且它们都是线程不安全的。二者的区别是:
- HashSet的元素可以是null,但TreeSet的元素不能是null;
- HashSet不能保证元素的排列顺序,而TreeSet支持自然排序、定制排序这两种排序的方式;
- HashSet的底层是采用哈希表实现的,而TreeSet的底层是采用红黑树实现的。
创建线程有哪几种方式?
三种方式,分别是继承Thread类、实现Runnable接口、实现Callable接口。
通过继承Thread类来创建并启动线程的步骤如下:
- 定义Thread类的子类,并重写该类的
run()方法,该run()方法将作为线程执行体。 - 创建Thread类子类的实例,即创建了线程对象。
- 调用线程对象的
start()方法来启动该线程。
通过实现Runnable接口来创建并启动线程的步骤如下:
- 定义Runnable接口的实现类,并实现该接口的
run()方法,该run()方法将作为线程执行体。 - 创建Runnable实现类的实例,并将其作为Thread的target来创建Thread对象,Thread对象为线程对象。
- 调用线程对象的
start()方法来启动该线程。
通过实现Callable接口来创建并启动线程的步骤如下:
- 创建Callable接口的实现类,并实现
call()方法,该call()方法将作为线程执行体,且该call()方法有返回值。然后再创建Callable实现类的实例。 - 使用FutureTask类来包装Callable对象,该FutureTask对象封装了该Callable对象的
call()方法的返回值。 - 使用FutureTask对象作为Thread对象的target创建并启动新线程。
- 调用FutureTask对象的
get()方法来获得子线程执行结束后的返回值。
run()和start()有什么区别?
run()被称为线程执行体,它的方法体代表了线程需要完成的任务,而start()方法用来启动线程。
调用start()方法启动线程时,系统会把该run()方法当成线程执行体来处理。但是如果直接调用线程对象的run()方法,则run()方法会立即被执行,而且在run()方法返回之前其他线程无法并发执行。也就是说,如果直接调用线程对象的run()方法,系统把线程对象当成一个普通对象,而run()方法也是一个普通方法,而不是线程执行体。
说一说线程的生命周期
线程的生命周期中,有新建(New)、就绪(Ready)、运行(Running)、阻塞(Block)、和死亡(Dead) 这五种状态。当线程启动后,一个线程不可能一直“霸占”着CPU独自运行,所以CPU需要在多条线程之间切换,于是线程状态也会多次在运行和就绪之间切换。
当程序使用new关键字创建了一个线程之后,该线程就处于新建状态,此时它和其他的Java对象一样,仅仅由Java虚拟机为其分配内存,并初始化其成员变量的值。此时的线程对象没有表现出任何线程的动态特征,程序也不会执行线程的线程执行体。
当线程对象调用了start()方法后,该线程就处于就绪状态,Java虚拟机会为其创建方法调用栈和程序计数器,处于这个状态中的线程并没有开始运行,只是表示该线程可以运行了。至于该线程何时开始运行,取决于JVM里线程调度器的调度。
如果处于就绪状态的线程获得了CPU,开始执行run()方法的线程执行体,则该线程处于运行状态,如果计算机只有一个CPU,那么在任何时刻只有一个线程处于运行状态。当然,在一个多处理器的机器上,将会有多个线程并行执行,当线程数大于处理器数时,依然会存在多个线程在同一个CPU上轮换的现象。
当一个线程开始运行后,他不可能一直处于运行状态,线程在运行的过程中需要被中断,目的是使其他线程获得被执行的机会,线程调度的细节取决于底层平台所采用的策略。对于采用抢占式策略的系统而言,系统会给每个可执行的线程一个小时间段来处理任务。当该时间段用完后,系统就会剥夺该线程所占用的资源,让其他线程获得执行的机会。当发生如下情况时,线程将会进入阻塞状态:
- 线程调用
sleep()方法主动放弃所占用的处理器资源。 - 线程调用了一个阻塞式IO方法,在该方法返回之前,该线程被阻塞。
- 线程试图获得一个同步监视器,但该同步监视器正在被其他线程所持有。
- 线程在等待某个通知(notify)。
- 程序调用了线程的
suspend()方法将该线程挂起。但这个方法容易导致死锁,所以应该尽量避免使用该方法。
针对上述几种情况,当发生如下特定情况时可以解除上面的阻塞,让该线程重新进入就绪状态:
- 调用
sleep()方法的线程经过了指定时间。 - 线程调用的阻塞式IO方法以及返回。
- 线程成功地获得了试图取得的同步监视器。
- 线程正在等待某个通知时,其他线程发出了一个通知。
- 处于挂起状态的线程被调用了
resume()恢复方法。
线程会以如下三种方式结束,结束后就处于死亡状态:
run()或call()方法执行完成,线程正常结束。- 线程抛出一个未捕获的Exception或Error。
- 直接调用该线程的
stop()方法来结束该线程,该方法容易导致死锁,通过不推荐使用。
线程五种状态的转换关系如下图:
如何实现线程同步?
- 同步方法:即有synchronized关键字修饰的方法,由于Java的每个对象都有一个内置锁,当用此关键字修饰方法时,内置锁会保护整个方法。在调用该方法前,需要获得内置锁,否则就处于阻塞状态。需要注意,synchronized关键字也可以修饰静态方法,此时如果调用该静态方法,将会锁住整个类。
- 同步代码块:即有synchronized关键字修饰的语句块,被该关键字修饰的语句块会自动被加上内置锁,从而实现同步。值得注意的是,同步是一种高开销的操作,因此应该尽量减少同步的内容。通常没有必要同步整个方法,使用synchronize代码块同步关键代码即可。
- ReentrantLock:Java 5新增了一个java.util.concurrent包来支持同步,其中ReentrantLock类是可重入、互斥、实现了Lock接口的锁,它与使用synchronized方法的块具有相同的基本行为和语义,并且拓展了其能力。需要注意的是,ReentrantLock还有一个可以创建公平锁的构造方法,但由于能大幅度降低程序运行效率,因此不推荐使用。
- volatile:volatile关键字为域变量的访问提供了一种免锁机制,使用volatile修饰域相当于告诉虚拟机该域可能会被其他线程更新,因此每次使用该域就要重新计算,而不是使用寄存器中的值。需要注意的是,volatile不会提供任何原子操作,它也不能用来修饰final类型的变量。
- 原子变量:在java的util.concurrent.atomic包中提供了创建了原子类型变量的工具类,使用该类可以简化线程同步
AtomicInteger表可以用原子方式更新int的值,可用在应用程序中(如以原子方式增加的计数器),但不能用于替换Integer。可扩展Number,允许那些处理机遇数字类的工具和实用工具进行统一访问。
说一说Java多线程之间的通信方式
wait()、notify()、notifyAll()
如果线程之间采用synchronized来保证线程安全,则可以利用wait()、notify()、notifyAll()来实现线程通信。这三个方法都不是Thread类中所声明的方法,而是Object类中声明的方法。原因是每个对象都拥有锁,所以让当前线程等待某个对象的锁,当然应该通过这个对象来操作。并且因为当前线程可能会等待多个线程的锁,如果通过线程来操作,就非常复杂了。另外,这三个方法都是本地方法,并且被final修饰,无法被重写。
wait()方法可以让当前线程释放对象锁并进入阻塞状态。notify()方法用于唤醒一个正在等待相应对象锁的线程,使其进入就绪队列,以便在当前线程释放锁后竞争锁,进而得到CPU的执行。notifyAll()用于唤醒所有正在等待相应对象锁的线程,使它们进入就绪队列,以便在当前线程释放锁后竞争锁,进而得到CPU的执行。
每个锁对象都有两个队列,一个是就绪队列,一个是阻塞队列。就绪队列存储了已就绪(将要竞争锁)的线程,阻塞队列存储了被阻塞的线程。当一个阻塞线程被唤醒后,才会进入就绪队列,进而等待CPU的调度。反之,当一个线程被wait后,就会进入阻塞队列,等待被唤醒。
await()、signal()、signalAll()
如果线程之间采用Lock来保证线程安全,则可以利用await()、signal()、signalAll()来实现线程通信。这三个方法都是Condition接口中的方法,该接口是在Java 1.5中出现的,它用来替代传统的wait+notify实现线程间的协作,它的使用依赖于 Lock。相比使用wait+notify,使用Condition的await+signal这种方式能够更加安全和高效地实现线程间协作。
Condition依赖于Lock接口,生成一个Condition的基本代码是lock.newCondition() 。 必须要注意的是,Condition 的 await() / signal() / signalAll() 使用都必须在lock保护之内,也就是说,必须在lock.lock()和lock.unlock之间才可以使用。事实上,await() / signal() / signalAll() 与 wait() / notify() / notifyAll()有着天然的对应关系。即:Conditon中的await()对应Object的wait(),Condition中的signal()对应Object的notify(),Condition中的signalAll()对应Object的notifyAll()。
BlockingQueue
Java 5提供了一个BlockingQueue接口,虽然BlockingQueue也是Queue的子接口,但它的主要用途并不是作为容器,而是作为线程通信的工具。BlockingQueue具有一个特征:当生产者线程试图向BlockingQueue中放入元素时,如果该队列已满,则该线程被阻塞;当消费者线程试图从BlockingQueue中取出元素时,如果该队列已空,则该线程被阻塞。
程序的两个线程通过交替向BlockingQueue中放入元素、取出元素,即可很好地控制线程的通信。线程之间需要通信,最经典的场景就是生产者与消费者模型,而BlockingQueue就是针对该模型提供的解决方案。
说一说synchronized和Lock的区别
- synchronized是Java关键字,在JVM层面实现加锁和解锁;Lock是一个接口,在代码层面实现加锁和解锁。
- synchronized可以用在代码块上、方法上;Lock只能写在代码里。
- synchronized在代码执行完或出现异常时自动释放锁;Lock不会自动释放锁,需要在finally中显示释放锁。
- synchronized会导致线程拿不到锁一直等待;Lock可以设置获取锁失败的超时时间。
- synchronized无法得知是否获取锁成功;Lock则可以通过tryLock得知加锁是否成功。
- synchronized锁可重入、不可中断、非公平;Lock锁可重入、可中断、可公平/不公平,并可以细分读写锁以提高效率。
说一说你对AQS的理解
抽象队列同步器AbstractQueuedSynchronizer(AQS),是用来构建锁或者其他同步组件的骨架类,减少了各功能组件实现的代码量,也解决了在实现同步器时涉及的大量细节问题,例如等待线程采用FIFO队列操作的顺序。在不同的同步器中还可以定义一些灵活的标准来判断某个线程是应该通过还是等待。
AQS采用模板方法模式,在内部维护了许多的模版的方法的基础上,子类只需要实现特定的几个方法(不是抽象方法),就可以实现子类自己的需求。
基于AQS实现的组件,诸如:
- ReentrantLock 可重入锁(支持公平和非公平的方式获取锁)
- Semaphore 计数信号量
- ReentrantReadWriteLock 读写锁
说下ThreadLocal和它的应用场景
ThreadLocal是Java中的一个线程私有的局部变量存储容器,它提供了一种将状态与线程关联的机制。通常情况下,线程共享同一个变量,而ThreadLocal可以让每个线程都拥有自己独立的变量,互相之间不会产生冲突。可以理解成每个线程都有自己专属的存储容器,它用来存储线程私有变量。
ThreadLocal内部真正存取是一个Map,每个线程可以通过set()和get()存取变量,多线程间无法访问各自的局部变量,相当于在每个线程间建立了一个隔板。只要线程处于活动状态,它所对应的ThreadLocal实例就是可以可访问的,线程被终止后,它的所有实例将被垃圾收集。ThreadLocal存储的变量属于当前线程。
使用ThreadLocal可以避免多线程环境下状态共享导致的线程安全问题,因为每个线程都拥有自己独立的变量,互相之间不会产生冲突。比如,在Web应用中,可以使用ThreadLocal来存储每个请求的用户信息,这样在请求处理过程中就不需要使用同步机制来保证线程安全。
ThreadLoacl还有一个经典的使用场景是为每个线程分配一个JDBC连接Connection,这样就可以保证每个线程都在各自的Connection上进行数据库的操作,不会出现A线程关闭了B线程正在使用的Connection。另外ThreadLocal还经常用于管理Session会话,将Session保存在ThreadLoacl中,使线程处理多次处理会话时始终是同一个Session。
需要注意的是,使用ThreadLocal需要注意内存泄漏的问题。由于ThreadLocal的生命周期和线程一样长,如果不及时清理ThreadLocal变量,就会导致ThreadLocal持有的对象无法被回收,从而引发内存泄漏。为了避免这个问题,通常需要在使用完ThreadLocal之后调用remove()方法来清理ThreadLocal变量。
说一说线程池
系统启动一个新线程的成本是比较高的,因为涉及和操作系统的交互。在这种情形下,使用线程池可以很好地提高性能,尤其是当程序中需要创建大量生存期很短暂的线程时,更应该考虑使用线程池。
与数据库链接池类似的是,线程池在系统启动时即创建大量空闲的线程,程序将一个Runnable对象或一个Callable对象传给线程池,线程池就会启动一个空闲的线程来执行它们的run()或call()方法,当run()或call()方法执行结束后,该线程不会死亡,而是再次返回线程池中成为空闲状态,等待执行下一个对象的run()或call()方法。
线程池的工作流程按顺序如下:
- 提交任务进入线程池。
- 判断核心线程池是否已满,没满则创建一个新的工作线程来执行任务。
- 若核心线程池已满,判断任务队列是否已满,没满则将新提交的任务添加在工作队列。
- 若任务队列已满,判断整个线程池是否已满,没满则创建一个新的工作线程来执行任务。
- 若整个线程池已满,则执行饱和(拒绝)策略。
线程池的队列大小你通常怎么设置?
- CPU密集型任务:尽量使用较小的线程池,一般为CPU核心数+1。因为CPU密集型任务使得CPU使用率很高,若开过多的线程数,会造成CPU过度切换。
- IO密集型任务:可以使用稍大的线程池,一般为CPU核心数*2。IO密集型任务CPU使用率并不高,因此可以让CPU在等待IO的时候有其他线程去处理别的任务,充分利用CPU时间。
- 混合性任务:可以将任务分成IO密集型和CPU密集型任务,然后分别用不同的线程池去处理。只要分完之后两个任务的执行时间相差不大,那么就会比串行执行来的高效。因为如果划分之后两个任务执行时间有数据级的差距,那么拆分没有意义。因为先执行完的任务就要等后执行完的任务,最终的时间仍然取决于后执行完的任务,而且还要加上任务拆分与合并的开销,得不偿失。
Java程序是怎么运行的?
概括地说,写好的Java源代码文件经过Java编译器编译成字节码文件后,通过类加载器加载到内存中,才能被实例化,然后到Java虚拟机中解释执行,最后通过操作系统操作CPU执行取得结果。如下图:
介绍一下类加载的过程
一个类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期会经历加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备和解析三个部分统称为连接(Linking)。
类加载的过程即是生命周期的前五个过程。
- 加载:加载阶段是整个类加载过程中的第一个阶段,此阶段Java虚拟机需要完成以下三件事情:
- 通过一个类的全限定名来获取定义此类的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的java.lang.Class类的对象,作为方法区这个类的各种数据的访问入口。
加载阶段结束后,Java虚拟机外部的二进制字节流就按照虚拟机所设定的格式存储在方法区之中了,方法区中的数据存储格式完全由虚拟机实现自行定义,《Java虚拟机规范》未规定此区域的具体数据结构。类型数据妥善安置在方法区后,会在Java堆内存中实例化一个java.lang.Class类的对象,这个对象将作为程序访问方法区中的类型数据的外部接口。
-
验证:验证是连接阶段的第一步,这一步的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。验证阶段大致上会完成下面四个检验动作:
- 文件格式验证:验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。
- 元数据验证:对字节码描述的信息进行语义分析,以保证其描述的信息符合《Java语言规范》的要求。
- 字节码验证:通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。
- 符号引用验证:符号引用验证可以看作是对类自身以外(常量池的各种符号引用)的各类信息进行匹配性校验,通俗来说就是,该类是否缺少或被禁止访问它依赖的某些外部类、方法、字段等资源。
-
准备:准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段。从概念上来讲,这些变量所使用的内存都应当在方法区中进行分配,但必须注意到方法区本身是一个逻辑上的区域,在JDK7及之前,HotSpot使用永久代来实现方法区时,实现是完全符合这种逻辑概念的。而在JDK8及之后,类变量会随着Class对象一起存放在Java堆中,这时候“类变量在方法区”就完全是一种对逻辑概念的表述了。
-
解析:解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程,符号引用在Class文件中以CONSTANT_Class_info, CONSTANT_Fieldref_info, CONSTANT_Methodref_info等类型的常量出现,那解析阶段中所说的直接引用与符号引用又有什么关联呢?
-
符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定是已经加载到虚拟机内存当中的内容。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须是一致的,因为符号引用的字面量形式明确定义在《Java虚拟机规范》的Class文件格式中。
-
直接引用(Direct References):直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定是已经在虚拟机的内存中存在。
- 初始化 :类的初始化阶段是类加载过程中的最后一个步骤,之前介绍的几个类加载的动作里,除了在加载阶段用户应用程序可以通过自定义类加载器的方式局部参与外,其余动作都完全由Java虚拟机来主导控制。直到初始化阶段,Java虚拟机才真正开始执行类中编写的Java程序代码,将主导权移交给应用程序。进行准备阶段时,变量已经赋过一次系统要求的初始零值,而在初始化阶段,则会根据程序员通过程序编码指定的主观计划去初始化类变量和其他资源。我们也可以从另外一种更直接的形式来表达:初始化阶段就是执行类构造器
<clinit>()方法的过程。<clinit>()并不是程序员在代码中直接编写的方法,它是Javac编译器的自动生成物。
说一说双亲委派机制
双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。
使用双亲委派模型来组织类加载器之间的关系,一个显而易见的好处就是Java中的类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都能够保证是同一个类。反之,如果没有使用双亲委派模型,都由各个类加载器自行去加载的话,如果用户自己也编写了一个名为java.lang.Object的类,并放在程序的ClassPath中,那系统中就会出现多个不同的Object类,Java类型体系中最基础的行为也就无从保证,应用程序将会变得一片混乱。
介绍一下G1垃圾收集器
G1(Garbage First)是一款主要面向服务端应用的垃圾收集器,JDK 9发布之日,G1宣告取代ParallelScavenge加Parallel Old组合,成为服务端模式下的默认垃圾收集器,而CMS则沦落至被声明为不推荐使用(Deprecate)的收集器。G1收集器是垃圾收集器技术发展历史上的里程碑式的成果,它开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。
虽然G1也仍是遵循分代收集理论设计的,但其堆内存的布局与其他收集器有非常明显的差异:G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。
Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。每个Region的大小可以通过参数-XX:G1HeapRegionSize设定,取值范围为1MB~32MB,且应为2的N次幂。而对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中,G1的大多数行为都把Humongous Region作为老年代的一部分来进行看待。
虽然G1仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它们都是一系列区域(不需要连续)的动态集合。G1收集器之所以能建立可预测的停顿时间模型,是因为它将Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集。更具体的处理思路是让G1收集器去跟踪各个Region里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis指定,默认值是200毫秒),优先处理回收价值收益最大的那些Region,这也就是“Garbage First”名字的由来。这种使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1收集器在有限的时间内获取尽可能高的收集效率。
什么是内存泄漏,怎么解决?
内存泄漏(Memory Leak)的根本原因是长生命周期的对象持有短生命周期对象的引用,尽管短生命周期的对象已经不再需要,但由于长生命周期对象持有它的引用而导致不能被回收。以发生的方式来分类,内存泄漏可以分为4类:
- 常发性内存泄漏。发生内存泄漏的代码会被多次执行到,每次被执行到的时候都会导致一块内存泄漏。
- 偶发性内存泄漏。发生内存泄漏的代码只有在某些特定环境或操作过程下才会发生。常发性和偶发性是相对的。对于特定的环境,偶发性的也许就变成了常发性的。所以测试环境和测试方法对检验内存泄漏至关重要。
- 一次性内存泄漏。发生内存泄漏的代码只会被执行一次,或者是由于算法上的缺陷,导致总会有一块且仅有一块内存发生泄漏。
- 隐式内存泄漏。程序在运行过程中不停地分配内存,但是直到结束的时候才释放内存。严格地说这里并没有发生内存泄漏,因为最终程序释放了所有申请的内存,但是对于一个服务器程序,需要运行几天、几周甚至几个月,不及时释放内存也有可能最终耗尽所有内存。所以,我们称这类内存泄漏为隐式内存泄漏。
避免内存泄漏的几点建议:
- 尽早释放无用对象的引用。
- 避免在循环中创建对象。
- 使用字符串处理时避免使用String,应使用StringBuffer。
- 尽量少使用静态变量,因为静态变量存放在永久代,基本不参与垃圾回收。
什么是内存溢出,怎么解决?
内存溢出(Out of Memory)简单地说就是指程序运行过程中申请的内存大于系统能够提供的内存,导致无法申请到足够的内存,于是就发生了内存溢出。
引起内存溢出的原因有很多种,常见的有如下几种:
- 内存中加载的数据量过于庞大,如一次从数据库取出过多数据。
- 集合类中有对对象的引用,使用完后未清空,使得JVM不能回收。
- 代码中存在死循环或循环产生过多重复的对象实体。
- 使用的第三方软件中的bug。
- 启动参数内存值设定得过小。
内存溢出的解决方案:
- 第一步,修改JVM启动参数,直接增加内存。
- 第二步,检查错误日志,查看"OutOfMemory"错误前是否有其他异常或错误。
- 第三步,对代码进行走查和分析,找出可能发生内存溢出的位置。
- 第四步,使用内存查看工具动态查看内存使用情况。
说一说你对MySQL索引的理解
索引是一个单独的、存储在磁盘上的数据库结构,包含着对数据表里所有记录的引用指针。使用索引可以快速找出某个或多个列中有一特定值的行,所有MySQL列类型都可以被索引,对相关列使用索引是提高查询操作速度的最佳途径。索引是在存储引擎中实现的,因此,每种存储引擎的索引都不一定完全相同,并且每种存储引擎也不一定支持所有的索引类型。MySQL中索引的存储类型有两种,BTREE和HASH,具体和表的存储引擎相关。MyISAM和InnoDB存储引擎只支持BTREE索引;MEMORY和HEAP存储引擎可以支持HASH和BTREE索引。
索引的优点主要有以下几条:
- 通过创建唯一引擎,可以保证数据库表中每一行数据的唯一性。
- 可以大大加快数据的查询速度,这也是创建索引的主要原因。
- 在实现数据的参考完整性方面,可以加速表和表之间的连接。
- 在使用分组和排序子句进行数据查询时,也可以显著减少查询中分组和排序的时间。
增加索引也有许多不利的方面,主要表现在以下几点:
- 创建索引和维护索引要耗费时间,并且随着数据量的增加所耗费的时间也会增加。
- 索引需要占据磁盘空间,除了数据表占据数据空间之外,每一个索引还要占一定的物理空间,如果有大量的索引,索引文件可能比数据文件更快达到最大文件尺寸。
- 当对表中的数据进行增删改的时候,索引也要动态地维护,这样就降低了数据的维护速度。
MySQL中,索引可以分为以下几种:
- 普通索引和唯一索引:普通索引是MySQL中基本的索引类型,允许在定义索引的列中插入重复值和空值。唯一索引要求索引列的值必须唯一,但允许有空值。如果是索引组合,则列值的组合必须唯一。主键索引是一种特殊的唯一索引,不允许有空值。
- 单列索引和组合索引:单列索引即一个索引只包含单个列,一个表可以有多个单列索引。组合索引是指在表的多个字段组合上创建的索引,只有在查询条件中使用了这些字段的左边字段时,索引才会被使用。使用组合索引时遵循最左前缀集合。
- 全文索引:全文索引类型为FULLTEXT,在定义索引的列上支持值的全文查找,允许在这些索引列中插入重复值和空值。全文索引可以在CHAR、VARCHAR或者TEXT类型的列上创建。
- 空间索引:空间索引是空间数据类型的字段建立的索引,MySQL中的空间数据类型有4种,分别是GEOMETRY、POINT、LINESTRING和POLYGON。MySQL使用SPATIAL关键字进行扩展,使得能够使用创建正规索引类似的语法创建空间索引。创建空间索引的列,必须将其声明为NOT NULL,空间索引只能在存储引擎为MyISAM的表中创建。
如何避免索引失效?
可以采用以下几种方式,来避免索引失效:
- 使用组合索引时,需要遵循“最左前缀”原则;
- 不在索引列上做任何操作,例如计算、函数、类型转换,会导致索引失效而转向全表扫描;
- 尽量使用覆盖索引(之访问索引列的查询),减少 select * 覆盖索引能减少回表次数;
- MySQL在使用不等于(!=或者<>)的时候无法使用索引会导致全表扫描;
- LIKE以通配符开头(%abc)MySQL索引会失效变成全表扫描的操作;
- 字符串不加单引号会导致索引失效(可能发生了索引列的隐式转换);
- 少用or,用它来连接时会索引失效。
说说MySQL的事务隔离级别
并发情况下,读操作可能存在的三类问题:
- 脏读:当前事务(A)中可以读到其他事务(B)未提交的数据(脏数据)。
- 不可重复读:在事务A中先后两次读取同一个数据,两次读取的结果不一样。脏读和不可重复的区别在于:脏读读到的是其他事务未提交的数据,不可重复读读到的是其他事务已经提交的数据。
- 幻读;在事务A中按照某个不变的条件先后两次查询数据库,两次查询结果的条数不同。不可重复读与幻读的区别在于:不可重复读是读到的数据变了,幻读是读到的数据的行数变了。
SQL标准定义了四种隔离级别,这四种级别分别对脏读、不可重复读、幻读问题的解决程度如下表:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| 读未提交(READ UNCOMMITTED) | 可能 | 可能 | 可能 |
| 读提交(READ COMMITTED) | 不可能 | 可能 | 可能 |
| 可重复读(REPEATABLE READ) | 不可能 | 不可能 | 可能 |
| 串行化(SERIALIZABLE) | 不可能 | 不可能 | 不可能 |
上述四种隔离级别MySQL都支持,InnoDB存储引擎默认的隔离级别是REPEATABLE READ。与标准SQL不同的是,InnoDB在REPEATABLE READ事务隔离级别下,使用Next-Key Lock的锁算法,因此避免了幻读的产生。所以InnoDB在默认的事务隔离级别下已经完全能保证食物的隔离性要求,即达到SQL标准的SERIALIZABLE隔离级别。
说一说死锁以及解决办法
死锁是指两个或两个以上的事务在执行过程中,因争夺锁资源而造成的一种互相等待的现象。若无外力作用,事务都将无法推进下去。
解决死锁最简单的办法是超时机制,即当两个事务互相等待时,当其中一个事务的等待时间超过设置的阈值时,将该事务进行回滚,另一等待事务就能继续进行。
除了超时机制,当前数据库还都普遍采用wait-for graph(等待图)的方式来检测死锁,这是一种更为主动的死锁检测方式,InnoDB也采用的这种方式。wait-for graph要求数据库保存两种信息:
- 锁的信息链表
- 事务等待链表 通过上述链表可以构造出一张图,而在这个图中若存在回路,就代表存在死锁。在每个事务请求锁并发生等待时都会判断是否存在回路,若存在则有死锁,通常来说,InnoDB选择回滚undo量最小的事务。
数据库表中包含几千万条数据该怎么优化?
建议按照以下顺序进行优化:
- 优化SQL语句和索引;
- 增加缓存,例如Redis;
- 读写分离,可以采用主从复制,也可以采用主主复制;
- 使用MySQL自带的分区表,这对应用是透明的,无需改代码,但SQL语句是要针对分区表做优化的;
- 做垂直拆分,即根据模块的耦合度,将一个大的系统分为多个小的系统;
- 做水平拆分,要选择一个合理的sharding key,为了有更好的查询效率,表结构也要改动,做一定的冗余,应用也要改,sql中尽量带sharding key,将数据定位到限定的表上去查,而不是扫描全部的表。
说一下三大范式
第一范式(1NF):是指在关系模型中,对于添加的一个规范要求,所有的域都应该是原子性的,即数据库表的每一列都是不可分割的原子数据项,而不能是集合、数组、记录等非原子数据项。1NF即要求实体中的某个属性有多个值时,必须拆分为不同的属性。在符合第一范式的表中的每个域值只能是实体的一个属性或一个属性的一部分。简而言之,第一范式就是无重复的域。
第二范式(2NF):在1NF的基础上,非码属性必须完全依赖于候选码,即在1NF的基础上消除非主属性对主码的部分函数依赖。第二范式要求数据库表中的每个实例或记录必须被唯一地区分,选取一个能区分每个实体的属性或属性组,作为实体的唯一标识。
例如在员工表中的身份证号码即可实现每一个员工的区分,该身份证号码即为候选键,任何一个候选键都可以被选为主键。在找不到候选键时,可以额外增加属性以实现区分,如果在员工关系中,没有对其身份证号进行存储,而姓名可能会在数据库运行的某个时间重复,无法区分出实体时,设计如ID等不重复的编号以实现区分,将添加的ID选作主键。
第三范式(3NF):在2NF的基础上,任何非主属性不依赖于其他非主属性,即在2NF的基础上消除传递依赖。简而言之,第三范式要求一个关系中不包含已在其他关系中已包含的非主关键字信息。
例如有一个部门信息表,其中每个部门有部门编号作为唯一标识,有部门名称、部门简介等信息。那么在员工信息表中列出部门编号后,就不能再将部门名称、部门简介等信息加入员工信息表中。如果不存在部门信息表,则根据第三范式也应该构建它,否则就会有大量的数据冗余。
谈谈对MVCC的了解
InnoDB默认的隔离级别是RR(REPEATABLE READ),RR解决脏读、不可重复读、幻读等问题,使用的是MVCC。MVCC全称Multi-Version Concurrency Control,即多版本的并发控制协议。它最大的优点是读不加锁,因此读写不冲突,并发性能好。InnoDB实现MVCC,多个版本的数据可以共存,主要基于以下技术及数据结构:
- 隐藏列:InnoDB中每行数据都有隐藏列,隐藏列中包含了本行数据的事务id、指向undo log的指针等。
- 基于undo log的版本链:每行数据的隐藏列中包含了指向undo log的指针,而每条undo log也会指向更早版本的undo log,从而形成一条版本链。
- ReadView:通过隐藏列和版本链,MySQL可以将数据恢复到指定版本。但是具体要恢复到哪个版本,则需要根据ReadView来确定。所谓ReadView,是指事务(记做事务A)在某一时刻给整个事务系统(trx_sys)打快照,之后再进行读操作时,会将读取到的数据中的事务id与trx_sys快照比较,从而判断数据对该ReadView是否可见,即对事务A是否可见。
MySQL是如何实现主从同步的?
复制(replication)是MySQL数据库提供的一种高可用高性能的解决方案,一般用来建立大型的应用。总体来说,replication的工作原理分为以下3个步骤:
- 主服务器(master)把数据更改记录到二进制日志(binlog)中。
- 从服务器(slave)把主服务器的二进制日志复制到自己的中继日志(relay log)中。
- 从服务器重做中继日志中的日志,把更改应用到自己的数据库上,以达到数据的最终一致性。
复制的工作原理并不复杂,其实就是一个完全备份加上二进制日志备份的还原。不同的是这个二进制日志的还原操作基本上实时在进行中。这里特别需要注意的是,复制不是完全实时地进行同步,而是异步实时。这中间存在主从服务器之间的执行延时,如果主服务器的压力很大,则可能导致主从服务器延时较大。复制的工作原理如下图所示,其中从服务器有2个线程,一个是I/O线程,负责读取主服务器的二进制日志,并将其保存为中继日志;另一个是SQL线程,复制执行中继日志。
介绍Spring Boot的启动流程
首先,Spring Boot项目创建完成会默认生成一个名为*Application的入口类,我们是通过该类的main方法启动Spring Boot项目的。在main方法中,通过SpringApplication的静态方法,即run方法进行SpringApplication类的实例化操作,然后再针对实例化对象调用另一个run方法来完成整个项目的初始化和启动。
SpringApplication调用run方法的大致流程如下图:
其中,SpringApplication在run方法中重点做了以下操作:
- 获取监听器和配置参数
- 打印Bean信息
- 创建并初始化容器
- 监听器发送通知
除了上述核心操作,run方法运行过程中还涉及启动时长统计、异常报告、启动日志、异常处理等辅助操作。比较完整的流程可以参考如下源代码:
public ConfigurableApplicationContext run(String... args) { // 创建StopWatch对象,用于统计run方法启动时长。
StopWatch stopWatch = new StopWatch(); // 启动统计
stopWatch.start();
ConfigurableApplicationContext context = null;
Collection<SpringBootExceptionReporter> exceptionReporters = new ArrayList<>(); // 配置Headless属性
configureHeadlessProperty(); // 获得SpringApplicationRunListener数组,该数组封装于SpringApplicationRunListeners对象的listeners中。
SpringApplicationRunListeners listeners = getRunListeners(args); // 启动监听,遍历SpringApplicationRunListener数组每个元素,并执行。
listeners.starting();
try { // 创建ApplicationArguments对象
ApplicationArguments applicationArguments = new DefaultApplicationArguments(args); // 加载属性配置,包括所有的配置属性。
ConfigurableEnvironment environment = prepareEnvironment(listeners, applicationArguments);
configureIgnoreBeanInfo(environment); // 打印Banner
Banner printedBanner = printBanner(environment); // 创建容器
context = createApplicationContext(); // 异常报告器
exceptionReporters = getSpringFactoriesInstances(SpringBootExceptionReporter.class, new Class[] { ConfigurableApplicationContext.class}, context); // 准备容器,组件对象之间进行关联。
prepareContext(context, environment, listeners, applicationArguments, printedBanner); // 初始化容器
refreshContext(context); // 初始化操作之后执行,默认实现为空。
afterRefresh(context, applicationArguments); // 停止时长统
stopWatch.stop(); // 打印启动日志
if (this.logStartupInfo) {
new StartupInfoLogger(this.mainApplicationClass).logStarted(getApplicationLog(), stopWatch);
} // 通知监听器:容器完成启动。
listeners.started(context); // 调用ApplicationRunner和CommandLineRunner的运行方法。
callRunners(context, applicationArguments);
} catch (Throwable ex) { // 异常处理
handleRunFailure(context, ex, exceptionReporters, listeners);
throw new IllegalStateException(ex);
}
try { // 通知监听器:容器正在运行。
listeners.running(context);
} catch (Throwable ex) { // 异常处理
handleRunFailure(context, ex, exceptionReporters, null);
throw new IllegalStateException(ex);
}
return context;
}
说一说对Spring IoC的理解
IoC(Inversion of Control)是控制反转的意思,这是一种面向对象编程的设计思想。在不采用这种思想的情况下。我们需要自己维护对象与对象之间的依赖关系,很容易造成对象之间的耦合度过高,在一个大型的项目中这十分不利于代码的维护。IoC则可以解决这种问题,它可以帮助我们维护对象与对象之间的依赖关系,降低对象之间的耦合度。
说到IoC就不得不提DI(Dependency Injection),DI是依赖注入的意思,它是IoC实现的实现方式,就是说IoC是通过DI来实现的。由于IoC这个词比较抽象而DI比较直观,所以很多时候我们用DI来代替IoC,这是一种习惯。实现依赖注入的关键是IoC容器,它的本质是一个工厂。
具体实现中,主要有三种注入方式:
- 构造方法注入:就是被注入对象可以在它的构造方法中声明依赖对象的参数列表,让外部知道它需要哪些依赖对象。然后,IoC Service Provider会检查被注入的对象的构造方法,取得它所需要的依赖对象的列表,进而为其注入相应的对象。构造方法注入方式比较直观,对象被构造完成后,即进入就绪状态,马上可以使用。
- 通过setter方法注入:通过setter方法,可以更改相应的对象属性。所以当前对象只要为其依赖对象所对应的属性添加setter方法,就可以通过setter方法将相应的依赖对象设置到被注入对象中。setter方法注入虽然不像构造方法注入那样,让对象构造完成后即可使用,但相对来说更宽松一些,可以在对象构造完成后再注入。
- 接口注入:相对于前两种注入方式来说,接口注入没有那么简单明了。被注入对象如果想要IoC Service Provider为其注入依赖对象,就必须实现某个接口。这个接口提供一个方法,用来为其注入依赖对象。IoC Service Provider最终通过这些接口来了解应该为被注入对象注入什么依赖对象。相对于前两种依赖注入方式,接口注入比较死板和繁琐。
总体来说,构造方法注入和setter方法注入因其侵入性较弱,且易于理解和使用,所以是现在使用最多的注入方式。而接口注入因为侵入性较强,近年来已经不流行了。
@Autowired和@Resource注解有什么区别?
- @Autowired是Spring提供的注解,@Resource是JDK提供的注解;
- @Autowired只能按类型注入,@Resource默认按名称注入,也支持按类型注入;
- @Autowired按类型装配依赖对象,默认情况下它要求依赖对象必须存在,如果运行null值,可以设置它required属性为false,如果我们想按使用名称装配,可以结合@Qualifier注解一起使用。@Resource中有两个重要的属性:name和@type。name属性指定byName,如果没有指定name属性,当注解标注在字段上,即默认取字段的名称作为bean名称寻找依赖对象,当注解标注在属性的setter方法上,即默认取属性名作为bean名称寻找依赖对象。需要注意的是,@Resource如果没有指定name属性,并且按照默认的名称仍然找不到依赖对象时,@Resource注解会回退到按类型装配。但一旦指定了name属性,就只能按名称装配了。
Linux常用命令
查看进程:ps
ps -A //显示当前所有进程
ps -aux | grep PID //查看进程id是PID的进程状态
ps -aux | grep apache //与grep联合使用查找某进程,以名字里带apache的进程为例
top //查看进程运行状态、查看内存使用情况的指令均可用top指令
查看带有关键字的文件(例如日志):grep
- cat 路径/文件名 | grep 关键词
- grep -i 关键词 路径/文件名
cat example.log | grep "keyword" //返回 example.log中所有包含keyword的行
//或者
grep -i "keyword" ./example.log //返回example.log中所有包含keyword的行(-i大小写不敏感)
查看内存:free
参数如下:
-b 以Byte为单位显示内存使用情况
-k 以kB为单位显示内存使用情况
-m 以MB为单位显示内存使用情况
-h 以合适的单位显示内存使用情况,最大为三位数,自动计算对应的单位值
-o 不显示缓冲区调节列
-s<间隔秒数> 持续观察内存使用情况
-t 显示内存总和列
-V 显示版本信息
压缩和解压文件:tar, gz, bz2, compress, zip, unzip
top命令有什么用:显示当前系统正在执行的进程的相关信息,包括进程ID、内存占用率、CPU占用率等
什么是协程
协程是微线程,在子程序内部执行,可在子程序内部中断,转而执行别的子程序,在适当的时候再返回来接着执行。
线程和协程的却别:
- 协程执行效率极高。协程直接操作栈,基本没有内核切换的开销,所以上下文切换非常快,切换开销比线程更小。
- 协程不需要多线程的锁机制,因为多个协程属于一个线程,不存在同时写变量冲突,效率比线程高。
- 一个线程可以有多个协程。
协程的优势
- 协程调用和切换效率比线程高。
- 协程占用内存少:协程执行只需要极少栈内存(4~5kB),而默认情况下线程栈大小约为1MB
- 协程切换开销更小:协程直接操作栈,基本没有内核切换的开销。
为什么协程比线程的切换开销更小?
- 协程执行效率极高,直接操作栈,基本没有内核切换的开销,所以上下文的切换非常快。
- 多个协程属于一个线程,故统一线程中的协程不存在写变量冲突,不需要多线程的锁机制,避免了加锁解锁的开销。
进程和线程有什么区别
- 一个线程从属于一个进程,一个进程可以包含多个线程。进程(主线程)创建了多个线程,多个子线程均拥有自己独立的栈空间(存储函数参数、局部变量等),但是多个子线程和主线程共享堆、全局变量等非栈内存。
- 如果子线程的崩溃是由于自己的一亩三分地引起的,那就不会对主线程和其他子线程产生影响,但是如果子线程的崩溃是因为对共享区域造成了破坏,那么大家就一起崩溃了。
- 进程是系统资源调度的最小单位;线程是CPU调度的最小单位。
- 进程的系统开销显著大于线程的开销,线程需要的系统资源更少。
- 进程在执行时拥有独立的内存单元,多个线程共享进程的内存。例如代码段、数据段、扩展段;但每个线程拥有自己的栈段和寄存器组。
- 进程切换时需要刷新TLB并获取新的地址空间,然后切换硬件上下文和内核栈;线程切换时只需要切换硬件上下文和内核栈。
- 通信方式不同。
说一说乐观锁和悲观锁
悲观锁总是假设最坏的情况,即每次去取数据时都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想取这个数据的时候就会阻塞直至他拿到锁(共享资源每次只给一个线程使用,其他线程阻塞,用完后再将资源转让给其他线程)。传统的关系型数据库里就用到了很多这种锁机制,例如行锁、表锁,读锁、写锁等,都是在做操作前先上锁。
乐观锁总是假设最好的情况,即每次去取数据的时候都认为别人不会修改,所以每次都不上锁。但在更新的时候会判断一下在此期间别人有没有去更新这个数据,判断过程可以根据版本号机制或CAS算法实现。乐观锁适用于多读的场景,可以提高吞吐量。数据库提供的 write_condition 机制,其实就是提供的乐观锁。
说一说CAS
CAS是Compare And Swap的缩写,即比较并交换。CAS需要3个操作数:内存地址V;旧的预期值A;即将要更新的目标值B。
CAS指令执行时,当且仅当内存地址V里存的值与旧预期值A相等时,将内存地址V里存的值修改为新的目标值B,否则就什么都不做。整个CAS操作是一个原子操作。
说一说IO多路复用
IO多路复用是一种同步IO模型,实现一个线程可以监视多个文件句柄。一旦某个文件句柄就绪,就能够通知应用程序进行相应的读写操作。没有文件句柄就绪时就会阻塞应用程序,交出CPU。多路指的是网络连接,复用指的是同一个线程。
IO多路复用有三种实现方式:
- select:时间复杂度O(n)。它仅仅知道有I/O事件发生了,却不知道是哪几个流(可能有一个,可能多个,甚至全部),只能无差别轮询所有流,找出能读出数据,或写入数据的流,对他们进行操作。所以select具有O(n)的无差别轮询复杂度,同时处理的流越多,无差别轮询时间越长。
- poll:时间复杂度O(n)。poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,但是poll没有最大连接数的限制(但是数量过大后性能也会下降),因为它是基于链表来存储的。
- epoll:时间复杂度O(1)。epoll可以理解为event poll,不同于忙轮询和无差别轮询,epoll会把哪个流发生了怎么样的I/O事件通知我们。所以说epoll实际上是事件驱动的,此时对这些流的操作都是有意义的。
域名解析成IP的全过程
1. 检查浏览器缓存中是否缓存过该域名对应的IP地址
用户通过浏览器浏览过某网站之后,浏览器会自动缓存该网站域名对应的IP地址。当用户在此访问的时候,浏览器就混从缓存中查找该域名对应的IP地址。由于缓存有大小限制和时间限制,所以也会存在域名对应的IP找不到的情况。如果浏览器从缓存中找到了该网站域名对应的IP地址,那么整个解析过程结束。如果没有找到,则进行下一步。
btw,对于缓存的时间限制设置问题,不宜设置太长的缓存时间,若时间太长,可能域名对应的IP地址发生变化,那么用户将在一段时间内无法正常访问到网站;若时间太多,又会造成频繁解析域名。
2. 如浏览器缓存中没有找到IP,将继续查找本季系统是否缓存过IP
如果第1步没有完成对域名的解析过程,那么浏览器会去系统缓存中查找系统是否缓存过这个域名对应的IP地址,也可以理解为系统自己也具备域名解析的基本能力。在系统中,可以通过设置文件来将域名手动绑定到某IP上。对于普通用户,并不推荐自己绑定域名和IP;对于开发者来说,通过绑定域名和IP,可以轻松切换环境,可以从测试环境切换到开发环境,方便开发和测试。
3. 向本地域名解析服务系统发起域名解析的请求
如果上述两步都无法完成域名的解析,那么系统只能请求本地域名解析服务系统进行解析。本地域名系统一般都是本地区的域名服务器,例如你连接的校园网,那么域名解析系统就在校园机房里;如果你连接的是电信、移动、联通等网络,那么本地域名解析服务器就在本地区,由各自的运营商来提供服务。大部分的域名解析工作到这一步都已经完成了。
4. 向根域名解析服务器发起域名解析请求
本地域名解析服务器还没有完成解析的话,那么本地域名解析服务器将向根域名服务器发起解析请求。
5. 根域名服务器返回gTLD域名解析服务器地址
本地域名解析服务器向根域名解析服务器发起解析请求,根域名服务器返回的是所查域的通用顶级域地址。
6. 向gTLD服务器发起解析请求
本地域名解析服务器向gTLD服务器发起请求。
7. gTLD服务器接收请求并返回Name Server服务器
gTLD服务器接收本地域名服务器发起的请求,并根据需要解析的域名,找到该域名对应的域名服务器。
8. Name Server服务器返回IP地址给本地服务器
服务器查找域名对应的地址,将地址连同值返回给本地域名服务器。
9. 本地域名服务器缓存解析结果
本地域名解析服务器缓存解析后的结果。
解释IP地址、子网掩码、网关
IP地址
IP地址有一个32位的连接地址,由4个8位的字段(也称为8位位组)组成,每个8位位组之间用点号隔开,用于标识TCP/IP宿主机。每个IP地址都包含两部分:网络ID和主机ID,网络ID标识在同一个物理网络上的所有宿主机,主机ID标识网络上的每一个宿主机,运行TCP/IP的每个计算机都需要唯一的IP地址。
Intenet委员会定义了五种地址类型以适应不同尺寸的网络。地址类型定义网络ID使用哪些位,它也定义了网络的可能数目和每个网络可能的宿主机数目。
子网掩码(Subnet Mask)
使用子网可以把单个大网分成多个物理网络,并用路由器把它们连接起来。子网掩码用于屏蔽IP地址的一部分,使得TCP/IP能够区别网络ID和宿主机ID。子网掩码中的1表示网络ID的位,0表示主机ID的位。例如,如果子网掩码为255.255.255.0,则IP地址的前24位表示网络ID,后8位表示主机ID。当TCP/IP宿主机要通信时,子网掩码用于判断一个宿主机是在本地网络还是在远程网络。
缺省的子网掩码(255.255.255.0)对应于一个不分割成子网的网络,对应于网络ID的所有位都置为1,每个8位位组的十进制数是255,对应于宿主机ID的所有位都置为0。
如果需要将网络分成子网,则需要使用更多的位来表示网络ID和主机ID。用于子网掩码的位数决定可能的子网数目和每个子网的宿主机数目,子网掩码的位数越多,则子网越多,但是宿主机也越少。
网关(Gateway)
网关就是一个网络连接到另一个网络的“关口”。 按照不同的分类标准,网关也有很多种。TCP/IP协议里的网关是最常用的,在这里我们所讲的“网关”均指TCP/ IP协议下的网关。
网关实质上是一个网络通向其他网络的IP地址。比如有网络A和网络B,网络A的IP地址范围为“192.168.1.1192.168.1.254”,子网掩码为255.255.255.0;网络B的IP地址范围为“192.168.2.1192.168.2.254”,子网掩码为255.255.255.0。在没有路由器的情况下,两个网络之间是不能进行TCP/IP通信的,即使是两个网络连接在同一台交换机(或集线器)上,TCP/IP协议也会根据子网掩码(255.255.255.0)判定两个网络中的主机处在不同的网络里。而要实现这两个网络之间的通信,则必须通过网关。
如果网络A中的主机发现数据包的目的主机不在本地网络中,就把数据包转发给它自己的网关,再由网关转发给网络B的网关,网络B的网关再转发给网络B的某个主机。网络B向网络A转发数据包的过程也是如此。故要实现这两个网络之间的通信,则必须通过网关。所以说,只有设置好网关的IP地址,TCP/IP协议才能实现不同网络之间的相互通信。那么这个IP地址是哪台机器的IP地址呢?网关的IP地址是具有路由功能的设备的IP地址,具有路由功能的设备有路由器、启用了路由协议的服务器(实质上相当于一台路由器)、代理服务器(也相当于一台路由器)。
说一下IP如何寻址?
IP寻址包括本地网络寻址和非本地网络寻址两部分。
- 本地网络寻址
假设有2个主机,他们是属于同一个网段。主机A和主机B,首先主机A通过本机的hosts表或者wins系统或dns系统先将主机B的计算机名转换为IP地址,然后用自己的IP地址与子网掩码计算出自己所出的网段,比较目的主机B的ip地址与自己的子网掩码,发现与自己是出于相同的网段,于是在自己的arp缓存中查找是否有主机B的mac地址,如果能找到就直接做数据链路层封装并且通过网卡将封装好的以太网帧发送有物理线路上去。
如果arp缓存中没有主机B的的mac地址,主机A将启动arp协议通过在本地网络上的arp广播来查询主机B的mac地址,获得主机B的mac地址厚写入arp缓存表,进行数据链路层的封装,发送数据。
- 非本地网络寻址
假设2个主机不是相同的网段,不同的数据链路层网络必须分配不同网段的IP地址并且由路由器将其连接起来。
主机A通过本机的hosts表或wins系统或dns系统先主机B的计算机名转换为IP地址,然后用自己的IP地址与子网掩码计算出自己所处的网段,比较目的目的主机B的IP地址,发现与自己处于不同的网段。于是主机A将知道应该将此数据包发送给自己的缺省网关,即路由器的本地接口。
主机A在自己的arp缓存中查找是否有缺省网关的mac地址,如果能够找到就直接做数据链路层封装并通过网卡,将封装好的以太网数据帧发送到物理线路上去,如果arp缓存表中没有缺省网关的mac地址,主机A将启动arp协议通过在本地网络上的arp广播来查询缺省网关的mac地址,获得缺省网关的mac地址后写入arp缓存表,进行数据链路层的封装,发送数据。
数据帧到达路由器的接受接口后首先解封装,变成IP数据包,对IP包进行处理,根据目的IP地址查找路由表,决定转发接口后做适应转发接口数据链路层协议帧的封装,并且发送到下一跳路由器,此过程继续直至到达目的的网络与目的主机。
操作系统的地址有几张,请具体说明
操作系统有物理地址、逻辑地址、线性地址(也叫虚拟地址)三种地址
- 物理地址
在存储器里以字节为单位存储信息,为了正确地存放或取得信息,每一个字节单元给以一个唯一的存储器地址,称为物理地址(Physical Address),又叫实际地址或绝对地址。
地址从0开始编号,顺序地每次加1,因此存储器的物理地址空间是呈线性增长的。它是用二进制数来表示的,是无符号整数,书写格式为十六进制数。它是出现在CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果。用于内存芯片级的单元寻址,与处理器和CPU连接的地址总线相对应。
- 逻辑地址
逻辑地址是指在计算机体系结构中是指应用程序角度看到的内存单元(memory cell)、存储单元(storage element)、网络主机(network host)的地址。 逻辑地址往往不同于物理地址(physical address),通过地址翻译器(address translator)或映射函数可以把逻辑地址转化为物理地址。
在有地址变换功能的计算机中,访问指令给出的地址 (操作数) 叫逻辑地址,也叫相对地址。要经过寻址方式的计算或变换才得到内存储器中的物理地址。把用户程序中使用的地址称为相对地址即逻辑地址。逻辑地址由两个16位的地址分量构成,一个为段基值,另一个为偏移量。两个分量均为无符号数编码。
- 线性地址
线性地址(Linear Address)是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址。
线性地址是一个32位无符号整数,可以用来表示高达4GB的地址,也就是,高达4294967296个内存单元。线性地址通常用十六进制数字表示,值的范围从0x00000000到0xffffffff)。程序代码会产生逻辑地址,通过逻辑地址变换就可以生成一个线性地址。如果启用了分页机制,那么线性地址可以再经过变换以产生一个物理地址。当采用4KB分页大小的时候,线性地址的高10位为页目录项在页目录表中的编号,中间10位为页表中的页号,其低12位则为偏移地址。如果是使用4MB分页机制,则高10位页号,低22位为偏移地址。如果没有启用分页机制,那么线性地址直接就是物理地址。
DNS使用了哪些协议
- DNS在进行区域解析的时候使用TCP协议,其它时候则使用UDP协议。
DNS的规范规定了2种类型的DNS服务器,一个叫主DNS服务器,一个叫辅助DNS服务器。在一个区中主DNS服务器从自己本机的数据文件中读取该区的DNS数据信息,而辅助DNS服务器则从区的主DNS服务器中读取该区的DNS数据信息。当一个辅助DNS服务器启动时,它需要与主DNS服务器通信,并加载数据信息,这就叫做区域传送(zone transfer)。
- 为什么既使用TCP又使用UDP
UDP报文的最大长度为512字节,而TCP则允许报文长度超过512字节。当DNS的查询超过512字节时,协议的TC标志会出现删除标志,这时则使用TCP发送。通常传统的UDP报文一般不会大于512字节。
区域传送时使用TCP,主要有以下两点考虑:
- 辅域名服务器会定时(一般是三个小时)向主域名服务器进行查询以便了解数据是否有变动。如有变动,则会执行一次区域传送,进行数据同步。区域传送将使用TCP而非UDP,因为数据同步传送的数据量比一个请求和应答的数据量要多得多。
- TCP是一种可靠的连接,保证了数据的准确性。
域名解析时使用UDP协议:
客户端向DNS服务器查询域名,一般返回的内容都不超过512字节,用UDP传输即可,不用经过TCP的三次握手,这样DNS服务器的负载更低,响应更快。虽然从理论上来说,客户端也可以指定向DNS服务器查询的时候使用TCP,但事实上,很多DNS服务器进行配置的时候,仅支持UDP查询包。
说一说对Linux内核的了解
内核是操作系统的核心,具有很多基本功能,它负责管理系统的进程、内存、设备驱动程序、文件和网络系统,决定着系统的性能和稳定性。
Linux内核有4项工作:
- 内存管理:追踪记录有多少内存存储什么以及存储在哪里
- 进程管理:确定哪些进程可以使用CPU、何时使用以及持续多长时间
- 设备驱动程序:充当硬件和进程之间的调解程序/解释程序
- 系统调用和安全防护:从流程接收服务请求
在正确实施的情况下,内核对于用户是不可见的,它在内核空间里工作,并从中分配内存和跟踪所有内容的存储位置。用户所看到的内容通过系统调用接口(SCI)与内核进行交互。
为了更具象地理解内核,不妨将 Linux 计算机想象成有三层结构:
硬件:物理机(这是系统的底层结构或基础)是由内存(RAM)、处理器(CPU)以及输入/输出(I/O)设备(例如存储、网络)组成的。其中,CPU 负责执行计算和内存的读写操作。
Linux 内核:操作系统的核心。它是驻留在内存中的软件,用于告诉 CPU 要执行哪些操作。
用户进程:这些是内核所管理的运行程序。用户进程共同构成了用户空间。用户进程有时也简称为进程。内核还允许这些进程和服务器彼此进行通信(称为进程间通信或 IPC)。
系统执行的代码通过以下两种模式之一在 CPU 上运行:内核模式或用户模式。在内核模式下运行的代码可以不受限制地访问硬件,而用户模式则会限制 SCI 对 CPU 和内存的访问。内存也存在类似的分隔情况(内核空间和用户空间)。这两个小细节构成了一些复杂操作的基础,例如安全防护、构建容器和虚拟机的权限分隔。
这也意味着:如果进程在用户模式下失败,则损失有限,无伤大雅,可以由内核进行修复。另一方面,由于内核进程要访问内存和处理器,因此内核进程的崩溃可能会引起整个系统的崩溃。由于用户进程之间会有适当的保护措施和权限要求,因此一个进程的崩溃通常不会引起太多问题。
说一说对 Linux 内核态和用户态的了解
内核态其实从本质上说就是内核,它是一种特殊的软件程序,控制计算机的硬件资源,例如协调CPU资源,分配内存资源,并且提供稳定的环境供应用程序运行。
用户态就是提供应用程序运行的空间,为了使应用程序访问到内核管理的资源例如CPU,内存,I/O。内核必须提供一组通用的访问接口,这些接口就叫系统调用(SCI)。
- 系统调用(SCI)是操作系统的最小功能单位。根据不同的应用场景,不同的Linux发行版本提供的系统调用数量也不尽相同,大致在240-350之间。这些系统调用组成了用户态跟内核态交互的基本接口。
- 从用户态切换到内核态可以通过三种方式:
- 系统调用:系统调用本身就是中断,但是是软件中断,跟硬中断不同。
- 异常:如果当前进程运行在用户态,这个时候发生了异常事件,就会触发切换。例如缺页异常。
- 外设中断:当外设完成用户的请求时,会向CPU发送中断信号。
Linux 如何设置开机启动
- 编辑rc.local脚本
Linux开机后会执行/etc/rc.local文件中的脚本,所以可以直接在/etc/rc.local中添加启动脚本。
$ vim /etc/rc.local
- 添加一个开机启动服务
将启动脚本复制到 /etc/init.d 目录下,并设置脚本权限,假设脚本为test
$ mv test /etc/init.d/test
$ sudo chmod 755 /etc/init.d/test
将该脚本放到启动列表中
$ cd /etc/init.d
$ sudo update-rc.d test defaults 95
其中数字95是脚本启动的顺序号,按照自己的需要相应修改即可。在有多个启动脚本,而它们之间又有先后启动的依赖关系时这个数字的设置就会很有用。
将该脚本从启动列表中剔除
$ cd /etc/init.d
$ sudo update-rc.d -f test remove
计算机的存储层次
常见的计算机存储层次如下:
- 寄存器:CPU提供的,读写是ns级别,容量字节级别。
- CPU缓存:CPU和CPU之间的缓存,读写10ns级别,容量较大一些,数百到千数字节。
- 主存:动态内存,读写100ns级别,容量GB级别。
- 外部存储介质:磁盘,读写ms级别,容量可至TB级别。
谈谈虚拟内存模型
虚拟内存分成五大区,分别是栈区、堆区、全局区(静态区)、文字常量区(常量存储区)、程序代码区。五大区特性如下:
- 栈区(stack):由编译器自动分配释放,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈;
- 堆区(heap):一般由程序员分配释放,若程序员不释放,程序结束时可能由OS回收。注意它与数据结构中的堆是两回事,堆区的分配方式类似于链表。
- 全局区(静态区)(static):全局变量和静态变量的存储是放在一起的,初始化全局变量和静态变量在一块区域,未初始化的全局变量和未初始化的静态变量在相邻的另一块区域。程序结束后由系统释放。
- 文字常量区(常量存储区):常量字符串就是放在这里的。程序结束后由系统释放。这是一块比较特殊的存储区,他们里面存放的是常量,不允许修改。
- 程序代码区:存放函数体的二进制代码。
什么是物理内存和虚拟内存,为什么要有虚拟内存?
物理内存和虚拟内存的定义
物理内存是相对于虚拟内存而言的。物理内存指通过物理内存条而获得的内存空间;而虚拟内存指的是将硬盘的一块区域划分来作为内存。内存的主要作用是在计算机运行时为操作系统和各种程序提供临时存储。
为什么要有虚拟内存
在早期的计算机中,要运行一个程序,会把这些程序全都装入内存,程序都是直接运行在内存上的,也就是说程序中访问的内存地址都是实际的物理内存地址。当计算机同时运行多个程序时,必须保证这些程序用到的内存总量要小于计算机实际物理内存的大小。
早期内存分配方法举例: 某台计算机总的内存大小是 128M ,现在同时运行两个程序 A 和 B , A 需占用内存 10M , B 需占用内存 110M 。计算机在给程序分配内存时会采取这样的方法:先将内存中的前 10M 分配给程序 A ,接着再从内存中剩余的 118M 中划分出 110M 分配给程序 B 。这种分配方法可以保证程序 A 和程序 B 都能运行,但是这种简单的内存分配策略问题很多。
早期的内存分配方法存在如下几个问题(为什么要有虚拟内存):
-
进程地址空间不隔离。由于程序都是直接访问物理内存,所以恶意程序可以随意修改别的进程的内存数据,以达到破坏的目的。有些非恶意的,但是有 bug 的程序也可能不小心修改了其它程序的内存数据,就会导致其它程序的运行出现异常。这种情况对用户来说是无法容忍的,因为用户希望使用计算机的时候,其中一个任务失败了,至少不能影响其它的任务。
-
内存使用效率低。在 A 和 B 都运行的情况下,如果用户又运行了程序 C,而程序 C 需要 20M 大小的内存才能运行,而此时系统只剩下 8M 的空间可供使用,所以此时系统必须在已运行的程序中选择一个将该程序的数据暂时拷贝到硬盘上,释放出部分空间来供程序 C 使用,然后再将程序 C 的数据全部装入内存中运行。可以想象得到,在这个过程中,有大量的数据在装入装出,导致效率十分低下。
-
程序运行的地址不确定。当内存中的剩余空间可以满足程序 C 的要求后,操作系统会在剩余空间中随机分配一段连续的 20M 大小的空间给程序 C 使用,因为是随机分配的,所以程序运行的地址是不确定的。
虚拟内存的实现
- 在装入程序时,不必将其全部装入到内存,而只需将当前要执行的部分页面或部分页面或段装入到内存,就可以让程序开始执行;
- 在程序执行过程中,如果需要执行的指令或访问的数据尚未在内存(称为缺页或缺段),则由处理器通知操作系统将相应的页面或段调入到内存,然后继续执行程序;
- 另一方面,操作系统将内存中暂时不用的页面或段调出保存在外存上,从而腾出更多空间存放将要装入的程序以及将要调入的页面或段。
虚拟内存技术的基本特征:
- 大的用户空间:物理内存和外存相结合形成虚拟空间;
- 部分交换:调入和调出是对部分虚拟地址空间进行的;
- 不连续性:物理内存分配的不连续,虚拟地址空间使用的不连续。
内存与缓存的区别
内存和缓存是计算机的不同组成部件。
内存全称内存储器,其作用是用于暂时存放CPU的运算数据,以及与硬盘等外部存储交换的数据。只要计算机在运行中,CPU就会把需要进行运算的数据调到内存中进行运算,当运算完成CPU再将结果传送出来,内存的运行也决定了计算机的稳定运行。
缓存由于受CPU芯片面积和成本的因素影响,其大小一般都很小。现在一般的缓存都不超过几M,CPU缓存的运行频率极高,一般是和处理器同频运作,工作效率远远大于系统内存和硬盘。实际工作时,CPU往往需要重复读取同样的数据块,而缓存容量的增大,可以大幅度提升CPU内部读取数据的命中率,而不用再到内存或者硬盘上寻找,从而提高系统性能。
深拷贝和浅拷贝的区别是什么?各自的使用场景是什么?
- 浅拷贝只是对指针的拷贝,拷贝后两个指针指向同一个内存空间。
对一个已知对象进行拷贝时,编译系统会自动调用一次构造函数(拷贝构造函数),如果用户未定义拷贝构造函数,则会调用默认拷贝构造函数,调用一次构造函数,调用两次析构函数,两个对象的指针成员所指内存相同,但是程序结束时该内存被释放了两次,会造成内存泄漏问题。
- 深拷贝不仅对指针进行拷贝,而且对指针指向的内容进行拷贝,经深拷贝后的指针是指向两个不同的地址空间。
在对含有指针成员的对象进行拷贝时,必须要自己定义拷贝构造函数,使拷贝后的对象指针成员有自己的内存空间,即进行深拷贝,这样就避免了内存泄漏的发生,调用一次构造函数,一次自定义拷贝构造函数,两次析构函数。两个对象的指针成员所指内容不同。
说一说IO模型
什么是IO
在Unix世界里,一切皆文件。文件是什么?文件就是遗传二进制流而已。无论是socket,还是FIFO、管道、终端,对我们来说,一切皆是文件,一切皆是流。在信息交换的过程中,我们对这些流进行数据的收发操作简称为I/O操作(input and output)。往流中读出数据,系统调用read;写出数据,系统调用write。
计算机里这么多的流,我们怎么知道要操作哪个流呢?
做到这个的就是文件描述符,即通常所说的fd,一个fd就是一个整数,所以对这个整数的操作就是对这个文件(流)的操作。我们创建一个socket,通过系统调用返回一个文件描述符,那么剩下对 socket 的操作就会转化为对这个描述符的操作。不得不说这又是一种分层和抽象的思想。
IO交互
对于一个输入操作来说,进程IO系统调用后,内核会先看缓冲区有没有相应的缓存数据,没有的话再到设备中读取,因为设备IO一般速度比较慢,需要等待,内核缓冲区有数据则直接复制到进程空间。所以,对于一个网络输入操作通常包括两个不同阶段:
- 等待网络数据到达网卡 -> 读取到内核缓冲区
- 从内核缓冲区复制数据 -> 用户空间
IO有内存IO、网络IO和磁盘IO三种,通常我们所说的IO指的是网络IO和磁盘IO两者。
五大IO模型
Linux中有五大IO模型,分别为阻塞IO、同步非阻塞IO、IO多路复用、信号驱动IO、异步IO。五种IO模型特性分别如下:
- 阻塞IO(blocking IO):
最传统的一种IO模型,即在读写数据过程中会发生阻塞现象。
当用户线程发出IO请求之后,内核会去查看数据是否就绪,如果没有就绪就会等待数据就绪,而用户线程就会处于阻塞状态,用户线程交出CPU。当数据就绪之后,内核会将数据拷贝到用户线程,并返回结果给用户线程,用户线程才解除block状态。
- 同步非阻塞IO(nonblocking IO): 当用户线程发起一个read操作后,并不需要等待,而是马上就得到了一个结果。如果结果是一个error,线程就知道数据还没有准备好,于是它可以再次发送read操作。一旦内核中的数据准备好了,并且又再次收到了用户线程的请求,那么线程马上就将数据拷贝到用户线程,然后返回。
所以事实上,在非阻塞IO模型中,用户线程需要不断地轮询内核数据是否就绪,也就是说非阻塞IO不会交出CPU,而是会一直占用CPU。
- IO多路复用(IO multiplexing):
多路复用IO模型是目前使用得比较多的模型。Java NIO实际上就是多路复用IO。
在多路复用IO模型中,会有一个线程不断去轮询多个socket的状态,只有当socket真正有读写事件时,才真正调用实际的IO读写操作。因为在多路复用IO模型中,只需要使用一个线程就可以管理多个socket,系统不需要建立新的进程或者线程,也不必维护这些线程和进程,并且只有在真正有socket读写事件进行时,才会使用IO资源,所以它大大减少了资源占用。
在Java NIO中,是通过selector.select()去查询每个通道是否有到达事件,如果没有事件,则一直阻塞在那里,因此这种方式会导致用户线程的阻塞。
一个想法是可以采用多线程+阻塞IO达到类似的效果,但由于在多线程+阻塞IO中,每个socket对应一个线程,这样会造成很大的资源占用,并且尤其是对于长连接来说,线程的资源一直不会释放,如果后面陆续有很多连接的话,就会造成性能上的瓶颈。
而多路复用IO模式,通过一个线程就可以管理多个socket,只有当socket真正有读写事件发生才会占用资源来进行实际的读写操作。因此,多路复用IO比较适合连接数比较多的情况。
另外多路复用IO为何比非阻塞IO模型的效率高是因为在非阻塞IO中,不断地询问socket状态时通过用户线程去进行的,而在多路复用IO中,轮询每个socket状态是内核在进行的,这个效率要比用户线程要高的多。
多路复用IO模型是通过轮询的方式来检测是否有事件到达,并且对到达的事件逐一进行响应。因此对于多路复用IO模型来说,一旦事件响应体很大,那么就会导致后续的事件迟迟得不到处理,并且会影响新的事件轮询。
- 信号驱动IO(signal driven IO):
多路复用IO模型是通过轮询的方式来检测是否有事件到达,并且对到达的事件逐一进行响应。因此对于多路复用IO模型来说,一旦事件响应体很大,那么就会导致后续的事件迟迟得不到处理,并且会影响新的事件轮询。
- 异步IO(asynchronous IO):
异步IO模型才是最理想的IO模型,在异步IO模型中,当用户线程发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从内核的角度,当它收到一个asynchronous read之后,它会立刻返回,说明read请求已经成功发起了,因此不会对用户线程产生任何阻塞。然后,内核会等待数据准备完成,再将数据拷贝到用户线程,当这一切都完成之后,内核会给用户线程发送一个信号,告诉它read操作完成了。也就说用户线程完全不需要关心实际的整个IO操作是如何进行的,只需要先发起一个请求,当接收内核返回的成功信号时表示IO操作已经完成,可以直接去使用数据了。
也就说在异步IO模型中,IO操作的两个阶段都不会阻塞用户线程,这两个阶段都是由内核自动完成,然后发送一个信号告知用户线程操作已完成。用户线程中不需要再次调用IO函数进行具体的读写。这点是和信号驱动模型有所不同的,在信号驱动模型中,当用户线程接收到信号表示数据已经就绪,然后需要用户线程调用IO函数进行实际的读写操作;而在异步IO模型中,收到信号表示IO操作已经完成,不需要再在用户线程中调用IO函数进行实际的读写操作。
异步IO是需要操作系统底层支持的,在Java 7中,提供了Asynchronous IO(AIO)
JDBC的步骤和问题
JDBC编程步骤:
- 加载数据库驱动;
- 创建并获取数据库链接;
- 创建jdbc statement对象;
- 设置sql语句和参数;
- 通过statement执行sql语句并获取结果;
- 对sql执行结果进行解析处理并获取结果集;
- 释放资源。
可能存在的问题及解决办法
-
数据库连接,使用时就创建,不使用立即释放,对数据库进行频繁连接开启和关闭,造成数据库资源浪费,影响数据库性能。
解:使用数据库连接池管理数据库连接。
-
将sql语句硬编码到java代码中,如果sql语句修改,需要重新编译java代码,不利于系统维护。
解:将sql语句配置在xml配置文件中,即使sql变化,不需要对java代码进行重新编译。
-
向preparedStatement中设置参数,对占位符号位置和设置参数值,硬编码在java代码中,不利于系统维护。
解:将sql语句及占位符号和参数全部配置在xml中。
-
从resultSet中遍历结果集数据时,存在硬编码,将获取表的字段进行硬编码,不利于系统维护。
解:将查询的结果集,自动映射成java对象。
什么是MyBatis
MyBatis是一款优秀的持久层框架,它支持SQL、存储过程以及高级映射。
MyBatis免除了几乎所有JDBC代码以及设置参数和获取结果集的工作。
MyBatis可以通过简单的XML或注解来配置或映射原始类型、接口和Java POJO(Plain Old Java Objects, 普通老式Java对象)为数据库中的记录。