Tricky Java Interview Questions for 7 Years Experience

Navigating tricky java interview questions for 7 Years Experience requires a solid grasp of Java fundamentals, coupled with a strategic approach to problem-solving. Interviewers may delve into nuanced topics, such as concurrency, design patterns, and performance optimization, to assess the candidate’s depth of knowledge and practical experience. It’s crucial for candidates to stay composed, articulate their thought process clearly, and demonstrate their ability to tackle complex scenarios effectively. By preparing thoroughly, staying updated on industry trends, and practicing problem-solving techniques, experienced Java professionals can confidently navigate challenging interview questions and showcase their expertise to potential employers.

Tricky Java Interview Questions for 7 Years Experience

Explain the difference between deep copy and shallow copy in Java, and provide examples of scenarios where each would be appropriate.

Deep copy and shallow copy are two techniques used to copy objects in Java, each with its own characteristics and use cases:

Shallow Copy:

  • Shallow copy creates a new object and then copies the contents of the original object to the new object. However, if the original object contains references to other objects, only the references are copied, not the objects themselves.
  • In a shallow copy, the copied object shares the same references to objects as the original object. Changes made to the copied object’s references will affect the original object, and vice versa.
  • Shallow copying is typically faster and requires less memory compared to deep copying.
  • Example scenario: Shallow copying is appropriate when the objects being copied do not contain nested objects or when sharing data between objects is acceptable.

Deep Copy:

  • Deep copy creates a new object and then recursively copies the contents of the original object and all its nested objects. This ensures that a complete, independent copy of the original object and its nested objects is created.
  • In a deep copy, the copied object and its nested objects are completely independent of the original object. Changes made to the copied object or its nested objects do not affect the original object, and vice versa.
  • Deep copying is typically slower and requires more memory compared to shallow copying, especially for objects with complex nested structures.
  • Example scenario: Deep copying is appropriate when you need to create independent copies of objects, especially if those objects contain nested objects that need to be copied recursively.

Here’s a simple example to illustrate the difference between shallow copy and deep copy:

class Address {
    String city;

    public Address(String city) {
        this.city = city;
    }
}

class Person {
    String name;
    Address address;

    public Person(String name, Address address) {
        this.name = name;
        this.address = address;
    }
}

public class DeepCopyVsShallowCopy {
    public static void main(String[] args) {
        Address originalAddress = new Address("New York");
        Person originalPerson = new Person("John", originalAddress);

        // Shallow copy
        Person shallowCopyPerson = new Person(originalPerson.name, originalPerson.address);

        // Deep copy
        Address deepCopyAddress = new Address(originalPerson.address.city);
        Person deepCopyPerson = new Person(originalPerson.name, deepCopyAddress);

        // Modify the city in the original address
        originalAddress.city = "Los Angeles";

        // Displaying values
        System.out.println("Shallow Copy - Address: " + shallowCopyPerson.address.city); // Output: Los Angeles
        System.out.println("Deep Copy - Address: " + deepCopyPerson.address.city); // Output: New York
    }
}

In this example, modifying the city in the original address affects the shallow copy (since it shares the same reference), but it doesn’t affect the deep copy (since it has its own independent copy of the address).

Describe the principles of multithreading in Java and discuss how you would implement thread safety in a concurrent application.

Multithreading in Java allows multiple threads to execute concurrently within the same process. It enables efficient utilization of CPU resources and can improve the responsiveness and performance of applications. Here are the key principles of multithreading in Java:

  1. Thread Creation: In Java, threads can be created by extending the Thread class or implementing the Runnable interface and passing it to a Thread object.
  2. Thread Lifecycle: Threads in Java go through various states, including New, Runnable, Blocked, Waiting, Timed Waiting, and Terminated. The Thread class provides methods to manage thread states.
  3. Thread Synchronization: In multithreaded environments, it’s essential to ensure thread safety to prevent data corruption and race conditions. Java provides synchronization mechanisms such as synchronized blocks/methods, locks (e.g., ReentrantLock), and concurrent data structures (e.g., ConcurrentHashMap, CopyOnWriteArrayList) to achieve thread safety.
  4. Thread Communication: Threads can communicate and coordinate with each other using methods like wait(), notify(), and notifyAll(). These methods allow threads to wait for signals from other threads and signal them when certain conditions are met.
  5. Thread Interruption: Threads can be interrupted using the interrupt() method to request them to stop execution gracefully. It’s essential to handle thread interruption properly to clean up resources and ensure the application’s stability.
  6. Thread Priorities: Java allows assigning priorities to threads using the setPriority() method. Higher-priority threads are scheduled to run before lower-priority threads, although thread scheduling is platform-dependent.

To implement thread safety in a concurrent application, you can follow these best practices:

  1. Use Synchronization: Use synchronization mechanisms such as synchronized blocks/methods or locks to ensure that critical sections of code are accessed by only one thread at a time. This prevents race conditions and ensures data integrity.
  2. Immutable Objects: Use immutable objects wherever possible to eliminate the need for synchronization. Immutable objects are inherently thread-safe because their state cannot be modified after creation.
  3. Atomic Operations: Use atomic classes and operations provided by the java.util.concurrent.atomic package, such as AtomicInteger, AtomicLong, and AtomicReference, to perform thread-safe atomic operations without explicit synchronization.
  4. Thread-Local Variables: Use thread-local variables (ThreadLocal class) to store data that is specific to each thread. Thread-local variables eliminate the need for synchronization when accessing thread-specific data.
  5. Concurrent Data Structures: Use concurrent data structures provided by the java.util.concurrent package, such as ConcurrentHashMap, ConcurrentLinkedQueue, and ConcurrentSkipListSet, to achieve thread safety when dealing with shared data structures.
  6. Avoid Sharing Mutable State: Minimize sharing mutable state between threads whenever possible. If sharing is unavoidable, use synchronization or other thread-safe techniques to access shared resources safely.

By following these principles and best practices, you can ensure thread safety and build robust concurrent applications in Java.

What are lambda expressions in Java 8, and how do they improve code readability and conciseness? Provide examples of lambda expressions in action.

Lambda expressions were introduced in Java 8 to provide a concise way to represent anonymous functions or “lambda expressions” directly in code. Lambda expressions enhance code readability and conciseness by reducing boilerplate code and making code more expressive, especially when working with functional interfaces.

Here’s an overview of lambda expressions in Java 8:

  1. Syntax: Lambda expressions have a compact syntax consisting of parameters, an arrow token (->), and a body. The parameters represent the input to the function, and the body represents the operation to be performed.
  2. Functional Interfaces: Lambda expressions are primarily used in the context of functional interfaces, which are interfaces with a single abstract method. Lambda expressions can be used to implement the abstract method of a functional interface concisely.
  3. Type Inference: In many cases, the types of parameters in lambda expressions can be inferred by the compiler based on the context in which they are used. This reduces the need for explicit type declarations.
  4. Capturing Variables: Lambda expressions can capture variables from their enclosing scope. These variables must be effectively final or implicitly final, meaning they are not modified after being captured.

Now, let’s look at an example to illustrate lambda expressions in action:

import java.util.ArrayList;
import java.util.List;

public class LambdaExample {
    public static void main(String[] args) {
        // Creating a list of strings
        List<String> names = new ArrayList<>();
        names.add("Alice");
        names.add("Bob");
        names.add("Charlie");
        names.add("David");

        // Using lambda expression to sort the list alphabetically
        names.sort((String s1, String s2) -> s1.compareTo(s2));

        // Printing the sorted list
        System.out.println("Sorted Names:");
        names.forEach(name -> System.out.println(name));

        // Using lambda expression with multiple statements
        names.forEach(name -> {
            System.out.print("Name: " + name);
            System.out.println(", Length: " + name.length());
        });
    }
}

In this example:

  • We have a list of strings names.
  • We use a lambda expression to sort the list alphabetically using the sort method of the List interface. The lambda expression (String s1, String s2) -> s1.compareTo(s2) represents a comparator that compares two strings.
  • We use lambda expressions with the forEach method to print each element of the list. The lambda expression name -> System.out.println(name) represents a consumer that prints the name.
  • We also demonstrate using lambda expressions with multiple statements within curly braces {}.

Lambda expressions enable us to write concise and expressive code, especially when working with functional interfaces like Comparator and Consumer. They improve code readability by eliminating boilerplate code and making the code more focused on the logic being expressed.

Discuss the Java Memory Model (JMM) and explain how it ensures thread safety and memory visibility in concurrent programs.

The Java Memory Model (JMM) defines the rules and behaviors governing how threads interact with shared memory in a Java program. It ensures thread safety and memory visibility in concurrent programs by providing a set of guidelines and guarantees for how memory operations are ordered and synchronized between threads.

Here’s how the Java Memory Model ensures thread safety and memory visibility:

Visibility of Shared Data:

  • The JMM ensures that changes made to shared variables by one thread are visible to other threads. It achieves this by defining rules for when changes made by one thread become visible to other threads.
  • In Java, changes made by a thread to a variable are not guaranteed to be immediately visible to other threads. Instead, the JMM provides mechanisms like synchronization and volatile variables to ensure visibility.

Ordering of Memory Operations:

  • The JMM defines rules for the ordering of memory operations within a single thread and between multiple threads. These rules specify when writes to variables by one thread are guaranteed to be visible to subsequent reads by the same thread or other threads.
  • Without proper synchronization, the JMM allows for reordering of instructions by the compiler or processor, which can lead to unexpected behavior in concurrent programs. Synchronization mechanisms like synchronized blocks and volatile variables ensure proper ordering of memory operations.

Synchronization Guarantees:

  • The JMM provides synchronization mechanisms such as synchronized blocks, intrinsic locks, and volatile variables to ensure thread safety and memory visibility.
  • Synchronized blocks establish happens-before relationships between threads, ensuring that changes made within a synchronized block are visible to other threads acquiring the same lock.
  • Volatile variables ensure that changes made to a variable are immediately visible to other threads. Reads and writes to volatile variables establish happens-before relationships, ensuring proper visibility of shared data.

Happens-Before Relationship:

  • The JMM defines the happens-before relationship, which is a partial ordering of memory operations that guarantees visibility and ordering of shared data between threads.
  • If operation A happens before operation B, then the results of operation A are visible to operation B and the memory effects of operation A are guaranteed to be visible to operation B.

By adhering to the rules and guarantees provided by the Java Memory Model, developers can write concurrent programs that are thread-safe and ensure proper visibility and ordering of shared data. This helps prevent issues such as data races, stale data, and inconsistent behavior in multithreaded applications.

Explain the concept of Java Virtual Machine (JVM) internals, including class loading, garbage collection, and bytecode execution.

The Java Virtual Machine (JVM) is the cornerstone of the Java platform, responsible for executing Java bytecode and providing a runtime environment for Java applications. Understanding JVM internals, including class loading, garbage collection, and bytecode execution, is essential for Java developers to optimize application performance and troubleshoot runtime issues effectively.

Here’s an overview of each aspect of JVM internals:

Class Loading:

  • Class loading is the process of loading Java classes and interfaces into the JVM at runtime. The JVM dynamically loads classes as they are referenced by the application.
  • Class loading follows a hierarchical structure defined by class loaders, such as the Bootstrap Class Loader, Extension Class Loader, and Application Class Loader.
  • Each class loader is responsible for loading classes from specific locations, such as the system classpath or external JAR files.
  • Class loading involves several steps, including loading, linking (verification, preparation, and resolution), and initialization of classes before they can be used by the application.

Garbage Collection (GC):

  • Garbage collection is the process of reclaiming memory occupied by objects that are no longer reachable or in use by the application.
  • The JVM’s garbage collector automatically manages memory allocation and deallocation, allowing developers to focus on application logic rather than memory management.
  • Different garbage collection algorithms, such as the serial collector, parallel collector, CMS (Concurrent Mark-Sweep) collector, and G1 (Garbage-First) collector, are available in the JVM to suit various application requirements and performance goals.
  • Garbage collection involves several phases, including marking, sweeping, and compacting, to identify and reclaim unreachable objects and defragment memory for efficient memory utilization.

Bytecode Execution:

  • Java source code is compiled into platform-independent bytecode instructions, which are executed by the JVM at runtime.
  • Bytecode is an intermediate representation of Java code that can be executed on any platform with a compatible JVM.
  • The JVM’s bytecode execution engine interprets and translates bytecode instructions into native machine code instructions, either through interpretation or just-in-time (JIT) compilation.
  • Just-in-time (JIT) compilation dynamically compiles frequently executed bytecode into native machine code for improved performance.
  • Bytecode verification ensures that bytecode instructions are safe and adhere to the Java language specification, preventing security vulnerabilities and runtime errors.

Understanding JVM internals enables developers to optimize application performance, troubleshoot memory-related issues, and tune garbage collection settings for better resource utilization. By mastering these concepts, Java developers can build robust, efficient, and scalable Java applications that leverage the full power of the Java platform.

Discuss the principles of functional programming in Java, including immutability, higher-order functions, and pure functions.

Functional programming is a programming paradigm that treats computation as the evaluation of mathematical functions and avoids changing state and mutable data. While Java is primarily an object-oriented programming language, it supports functional programming concepts through features introduced in Java 8 and subsequent versions. Here are the principles of functional programming in Java:

Immutability:

  • Immutability refers to the property of objects whose state cannot be modified after creation. In functional programming, immutability is encouraged to avoid side effects and make code more predictable and thread-safe.
  • In Java, you can create immutable objects by declaring fields as final and ensuring that they are initialized only once. You can also make classes immutable by making fields private and providing only getter methods.
  • Immutable objects facilitate safer concurrency and easier reasoning about code behavior since they cannot be changed after creation.

Higher-Order Functions:

  • Higher-order functions are functions that can accept other functions as arguments or return functions as results. They allow for more expressive and reusable code by enabling behavior to be abstracted and passed around as data.
  • In Java, higher-order functions can be implemented using functional interfaces and lambda expressions introduced in Java 8. Functional interfaces are interfaces with a single abstract method, which can be implemented using lambda expressions.
  • Common examples of higher-order functions in Java include map, filter, reduce, and forEach methods in the Stream API, which allow for functional-style operations on collections.

Pure Functions:

  • Pure functions are functions that produce the same output for the same input and have no side effects, meaning they do not modify state outside of the function’s scope.
  • Pure functions are deterministic and easier to reason about since they have no dependencies on external state and do not cause unexpected changes in the program.
  • In Java, you can write pure functions by avoiding side effects, mutable state, and external dependencies within the function. Pure functions are typically stateless and rely only on their input parameters to produce output.
  • Pure functions are key to achieving referential transparency, where expressions can be replaced with their values without changing the program’s behavior.

By embracing these principles of functional programming, Java developers can write code that is more modular, composable, and maintainable. While Java’s support for functional programming is not as extensive as pure functional languages like Haskell or Scala, it provides sufficient features to incorporate functional programming concepts into Java applications and leverage the benefits of immutability, higher-order functions, and pure functions.

Describe the Java Stream API introduced in Java 8 and discuss how it enables functional-style operations on collections.

The Java Stream API, introduced in Java 8, provides a powerful and concise way to perform functional-style operations on collections. It allows developers to express complex data processing pipelines using a fluent and declarative syntax, similar to functional programming languages. Here’s an overview of the Java Stream API and how it enables functional-style operations on collections:

Stream Interface:

  • The Stream interface represents a sequence of elements that supports aggregate operations. It is a new abstraction introduced in Java 8 for processing collections in a functional-style manner.
  • Streams are designed to be lazy, meaning that intermediate operations (such as filter, map, and sorted) are not evaluated until a terminal operation (such as forEach, collect, or reduce) is invoked.

Functional-Style Operations:

  • The Stream API provides a wide range of functional-style operations for transforming and processing elements in a collection.
  • Intermediate operations allow for filtering, mapping, sorting, and transforming elements in the stream without modifying the original collection.
  • Terminal operations trigger the execution of the stream pipeline and produce a result, such as collecting elements into a new collection, aggregating elements into a single value, or performing side effects.

Declarative Syntax:

  • The Stream API uses a fluent and declarative syntax, allowing developers to express complex data processing pipelines using method chaining.
  • This declarative style of programming makes code more readable, concise, and maintainable compared to imperative loops and nested conditional statements.

Parallel Execution:

  • One of the key features of the Stream API is its support for parallel execution of stream operations.
  • By leveraging the parallelStream() method, developers can easily parallelize data processing tasks across multiple threads, leading to improved performance on multi-core processors.

Integration with Lambda Expressions:

  • The Stream API integrates seamlessly with lambda expressions introduced in Java 8, allowing developers to pass behavior to stream operations in a concise and expressive manner.
  • Lambda expressions are commonly used to define predicates for filtering elements, mapping functions for transforming elements, and reduction operations for aggregating elements.

Lazy Evaluation:

  • Streams support lazy evaluation, meaning that intermediate operations are only executed when a terminal operation is invoked.
  • This lazy evaluation strategy enables optimization opportunities such as short-circuiting (early termination) and fusion (combining multiple operations into a single pass over the data).

Overall, the Java Stream API provides a powerful and flexible mechanism for performing functional-style operations on collections. By leveraging its fluent and declarative syntax, developers can write cleaner, more concise, and more maintainable code for data processing tasks in Java applications. Additionally, its support for parallel execution enables efficient utilization of multi-core processors, leading to improved performance for data-intensive tasks.

Explain the principles of dependency injection and inversion of control (IoC) in Java, and discuss how they facilitate loose coupling and maintainability in software design.

Dependency Injection (DI) and Inversion of Control (IoC) are design patterns used in software development to achieve loosely coupled and maintainable code. Here’s an explanation of these principles and how they facilitate loose coupling and maintainability in software design:

Dependency Injection (DI):

  • Dependency Injection is a design pattern in which the dependencies of a class are provided from external sources rather than being created internally by the class itself.
  • In DI, dependencies are “injected” into a class either through constructor injection, setter injection, or method injection.
  • By externalizing dependencies, DI promotes modular design, separation of concerns, and reusability of components.
  • DI allows for easier testing by enabling the use of mock objects or stubs to replace real dependencies during unit testing.
  • In Java, DI is commonly implemented using frameworks like Spring, Google Guice, or Java EE CDI (Contexts and Dependency Injection).

Inversion of Control (IoC):

  • Inversion of Control is a design principle in which the control of object creation and lifecycle management is inverted or delegated to an external container or framework.
  • In IoC, rather than creating and managing objects directly within the application code, the responsibility for creating, configuring, and managing objects is shifted to a central container or framework.
  • IoC promotes loose coupling by decoupling the components of an application from their concrete implementations and reducing the dependency on specific classes or libraries.
  • IoC facilitates modularity, extensibility, and maintainability by promoting a modular and composable design where components can be easily replaced or extended.
  • In Java, IoC is commonly implemented using dependency injection frameworks like Spring Framework, which provides a comprehensive IoC container for managing dependencies and lifecycle of objects.

Together, Dependency Injection and Inversion of Control promote a modular, flexible, and maintainable architecture by decoupling components, reducing dependencies, and promoting a separation of concerns. By externalizing dependencies and delegating control to external containers or frameworks, DI and IoC enable easier testing, better encapsulation, and improved scalability of software systems. These principles are foundational to modern software development practices and are widely used in enterprise Java applications to achieve robust and maintainable codebases.

Discuss the pros and cons of using Java serialization for object persistence and provide alternatives for serializing Java objects.

Java serialization is a mechanism provided by the Java platform for converting Java objects into a stream of bytes, which can be saved to a file, transmitted over a network, or stored in a database. While Java serialization offers convenience, it has several drawbacks and limitations. Here are the pros and cons of using Java serialization for object persistence, along with alternative approaches:

Pros of Java Serialization:

  1. Convenience: Java serialization is straightforward to implement, requiring minimal code to serialize and deserialize objects.
  2. Automatic Handling of Object Graphs: Java serialization automatically handles object graphs, including complex object hierarchies and circular references, without requiring explicit coding.
  3. Integration with Java IO: Java serialization seamlessly integrates with Java IO, allowing serialized objects to be written to and read from files, streams, and sockets.

Cons of Java Serialization:

  1. Performance Overhead: Java serialization can impose a significant performance overhead, especially for large object graphs, due to serialization and deserialization processes and the verbosity of serialized data.
  2. Versioning Issues: Java serialization is sensitive to changes in class definitions, making it challenging to maintain backward and forward compatibility when evolving classes. Changes such as adding or removing fields or altering class hierarchies can break compatibility with previously serialized objects.
  3. Security Risks: Java serialization is susceptible to security vulnerabilities, such as deserialization vulnerabilities, which can be exploited to execute arbitrary code or cause denial-of-service attacks.
  4. Platform Dependency: Serialized data produced by Java serialization is platform-dependent and tied to the Java platform, making it challenging to interoperate with non-Java systems or migrate data to other platforms.

Alternatives to Java Serialization:

  1. JSON or XML Serialization: JSON (JavaScript Object Notation) and XML (eXtensible Markup Language) serialization formats provide platform-independent, human-readable, and lightweight alternatives to Java serialization. Libraries like Jackson (for JSON) and JAXB (for XML) offer support for converting Java objects to and from JSON or XML representations.
  2. Protocol Buffers: Protocol Buffers, developed by Google, provide a compact and efficient binary serialization format for structured data. Protocol Buffers are language-neutral and offer strong backward and forward compatibility support.
  3. Apache Avro: Apache Avro is a data serialization framework that provides rich data structures, compact binary encoding, and schema evolution capabilities. Avro is particularly well-suited for use in distributed systems and data-intensive applications.
  4. Custom Serialization: Implementing custom serialization using externalizable or transient keywords allows developers to have fine-grained control over the serialization process and avoid some of the limitations and overhead associated with default Java serialization.

When choosing a serialization approach, developers should consider factors such as performance, interoperability, versioning, and security requirements. While Java serialization may be suitable for simple use cases or legacy systems, alternatives like JSON, XML, Protocol Buffers, or custom serialization provide more flexibility, efficiency, and compatibility with modern software development practices.

Explain the principles of design patterns in Java, including creational, structural, and behavioral patterns, and provide examples of each.

Design patterns are reusable solutions to common software design problems that have been proven effective over time. They provide a structured approach to solving design challenges and promoting code reuse, maintainability, and scalability. In Java, design patterns are implemented using object-oriented programming principles and language features. There are three main categories of design patterns: creational, structural, and behavioral patterns. Here’s an explanation of each category along with examples:

Creational Patterns:

  • Creational patterns focus on object creation mechanisms, providing flexible ways to instantiate objects while hiding the creation logic from the client.
  • Examples of creational patterns include:
    • Singleton Pattern: Ensures that a class has only one instance and provides a global point of access to that instance. Example: java.lang.Runtime.
    • Factory Method Pattern: Defines an interface for creating objects, but allows subclasses to alter the type of objects that will be created. Example: java.util.Calendar.
    • Abstract Factory Pattern: Provides an interface for creating families of related or dependent objects without specifying their concrete classes. Example: javax.xml.parsers.DocumentBuilderFactory.

Structural Patterns:

  • Structural patterns deal with the composition of classes and objects, focusing on how classes and objects are structured to form larger structures.
  • Examples of structural patterns include:
    • Adapter Pattern: Allows incompatible interfaces to work together by wrapping an interface around an existing class. Example: java.util.Arrays.asList().
    • Decorator Pattern: Dynamically adds new functionality to objects by wrapping them in an object of a decorator class. Example: java.io.BufferedInputStream.
    • Composite Pattern: Composes objects into tree structures to represent part-whole hierarchies. Example: java.awt.Container.

Behavioral Patterns:

  • Behavioral patterns focus on communication between objects and the assignment of responsibilities between them.
  • Examples of behavioral patterns include:
    • Observer Pattern: Defines a one-to-many dependency between objects, where changes to one object trigger updates to its dependents. Example: java.util.Observable and java.util.Observer.
    • Strategy Pattern: Defines a family of algorithms, encapsulates each one, and makes them interchangeable. Example: java.util.Comparator.
    • Template Method Pattern: Defines the skeleton of an algorithm in the superclass but allows subclasses to override specific steps of the algorithm without changing its structure. Example: java.util.AbstractList.

By understanding and applying these design patterns, Java developers can create well-structured, maintainable, and extensible software systems. Design patterns provide a common language and set of solutions for addressing recurring design problems, enabling developers to write cleaner, more modular code and improve overall software quality.

Conclusions

In conclusion, tackling Tricky Java Interview Questions for 7 Years Experience requires not only a deep understanding of Java fundamentals but also the ability to think critically, solve complex problems, and demonstrate real-world experience. Interviewers may present challenging scenarios or edge cases to assess the candidate’s problem-solving skills, attention to detail, and ability to apply advanced Java concepts. It’s essential for experienced candidates to remain calm, approach questions methodically, and communicate their thought process clearly. By preparing thoroughly, staying up-to-date with industry trends, and practicing problem-solving exercises, experienced Java professionals can confidently navigate challenging interview questions and showcase their expertise to potential employers.

Java 8 Interview

For Other Awesome Article visit AKCODING.COM and read articles in Medium

Share with